import '../domain/domain.dart'; import 'ports.dart'; final class CreateShareResult { const CreateShareResult({ required this.share, required this.recipientUserIds, required this.unresolvedEmails, }); final Share share; final List recipientUserIds; final List unresolvedEmails; } final class AcceptShareResult { const AcceptShareResult({required this.createdResources}); final List createdResources; SyncedResource get createdResource => createdResources.single; } final class SharePackItemInput { const SharePackItemInput({ required this.resourceType, required this.payloadJson, }); final String resourceType; final Map payloadJson; } final class CreateShareUseCase { const CreateShareUseCase({ required this.users, required this.shares, required this.clock, required this.ids, }); final UserRepository users; final ShareRepository shares; final Clock clock; final IdGenerator ids; Future execute({ required String senderUserId, String shareKind = 'single', String? packName, required String? resourceType, required Map? payloadJson, List packItems = const [], required List recipientEmails, }) async { final parsedKind = ShareKind.parse(shareKind); final shareContent = _shareContent( kind: parsedKind, packName: packName, resourceType: resourceType, payloadJson: payloadJson, packItems: packItems, ); final emails = _normalizedEmails(recipientEmails); if (emails.isEmpty) { throw const ValidationException('recipientEmails must not be empty.'); } final recipientUserIds = []; final unresolvedEmails = []; for (final email in emails) { final user = await users.findByEmail(email); if (user == null) { unresolvedEmails.add(email); } else if (user.id != senderUserId && !recipientUserIds.contains(user.id)) { recipientUserIds.add(user.id); } } final now = clock.now().toUtc(); final share = Share( id: ids.newId(), senderUserId: senderUserId, kind: parsedKind, resourceType: shareContent.resourceType, packName: shareContent.packName, payloadJson: shareContent.payloadJson, createdAt: now, ); final recipients = [ for (final recipientUserId in recipientUserIds) ShareRecipient( id: ids.newId(), shareId: share.id, recipientUserId: recipientUserId, ), ]; await shares.insertShare(share: share, recipients: recipients); return CreateShareResult( share: share, recipientUserIds: recipientUserIds, unresolvedEmails: unresolvedEmails, ); } } final class ListInboxUseCase { const ListInboxUseCase({required this.shares}); final ShareRepository shares; Future> execute({required String recipientUserId}) { return shares.listInbox(recipientUserId); } } final class AcceptShareUseCase { const AcceptShareUseCase({ required this.shares, required this.clock, required this.ids, }); final ShareRepository shares; final Clock clock; final IdGenerator ids; Future execute({ required String shareId, required String recipientUserId, }) async { final share = await shares.findShareById(shareId); final recipient = await shares.findRecipient( shareId: shareId, recipientUserId: recipientUserId, ); if (share == null || recipient == null) { throw const ShareNotFoundException(); } if (share.isRevoked) { throw const ShareConflictException('Share has been revoked.'); } if (recipient.status != ShareRecipientStatus.pending) { throw const ShareConflictException('Share has already been answered.'); } final now = clock.now().toUtc(); final resourcesToCreate = [ for (final item in _resourcesFromShare(share)) SyncedResource( serverId: ids.newId(), ownerUserId: recipientUserId, resourceType: item.resourceType, clientId: ids.newId(), payloadJson: item.payloadJson, schemaVersion: _schemaVersionFromPayload(item.payloadJson), clientUpdatedAt: now, serverUpdatedAt: now, ), ]; final written = await shares.acceptShare( recipientId: recipient.id, respondedAt: now, resources: resourcesToCreate, ); return AcceptShareResult(createdResources: written); } } final class DeclineShareUseCase { const DeclineShareUseCase({required this.shares, required this.clock}); final ShareRepository shares; final Clock clock; Future execute({ required String shareId, required String recipientUserId, }) async { final share = await shares.findShareById(shareId); final recipient = await shares.findRecipient( shareId: shareId, recipientUserId: recipientUserId, ); if (share == null || recipient == null) { throw const ShareNotFoundException(); } if (recipient.status != ShareRecipientStatus.pending) { throw const ShareConflictException('Share has already been answered.'); } await shares.updateRecipientStatus( recipientId: recipient.id, status: ShareRecipientStatus.declined, respondedAt: clock.now().toUtc(), ); } } final class RevokeShareUseCase { const RevokeShareUseCase({required this.shares, required this.clock}); final ShareRepository shares; final Clock clock; Future execute({ required String shareId, required String senderUserId, }) async { final share = await shares.findShareById(shareId); if (share == null || share.senderUserId != senderUserId) { throw const ShareNotFoundException(); } if (share.isRevoked) { return; } await shares.revokeShare(shareId: shareId, revokedAt: clock.now().toUtc()); } } SyncedResourceType _shareResourceType(String value) { final type = SyncedResourceType.parse(value); if (type != SyncedResourceType.program && type != SyncedResourceType.workoutTemplate) { throw const ValidationException( 'resourceType must be program or workoutTemplate.', ); } return type; } final class _ShareContent { const _ShareContent({ required this.resourceType, required this.packName, required this.payloadJson, }); final SyncedResourceType? resourceType; final String? packName; final Map payloadJson; } _ShareContent _shareContent({ required ShareKind kind, required String? packName, required String? resourceType, required Map? payloadJson, required List packItems, }) { return switch (kind) { ShareKind.single => _singleShareContent( resourceType: resourceType, payloadJson: payloadJson, ), ShareKind.pack => _packShareContent(packName: packName, items: packItems), }; } _ShareContent _singleShareContent({ required String? resourceType, required Map? payloadJson, }) { final normalizedType = _blankToNull(resourceType); if (normalizedType == null) { throw const ValidationException('resourceType must not be blank.'); } final payload = payloadJson; if (payload == null) { throw const ValidationException('payload is required.'); } return _ShareContent( resourceType: _shareResourceType(normalizedType), packName: null, payloadJson: payload, ); } _ShareContent _packShareContent({ required String? packName, required List items, }) { final normalizedPackName = _blankToNull(packName); if (normalizedPackName == null) { throw const ValidationException('packName must not be blank.'); } if (items.isEmpty) { throw const ValidationException('items must not be empty.'); } return _ShareContent( resourceType: null, packName: normalizedPackName, payloadJson: { 'items': [ for (final item in items) { 'resourceType': _shareResourceType(item.resourceType).wireName, 'payload': item.payloadJson, }, ], }, ); } final class _ShareResourceData { const _ShareResourceData({ required this.resourceType, required this.payloadJson, }); final SyncedResourceType resourceType; final Map payloadJson; } List<_ShareResourceData> _resourcesFromShare(Share share) { return switch (share.kind) { ShareKind.single => [ _ShareResourceData( resourceType: share.resourceType!, payloadJson: share.payloadJson, ), ], ShareKind.pack => _packResourcesFromPayload(share.payloadJson), }; } List<_ShareResourceData> _packResourcesFromPayload( Map payloadJson, ) { final rawItems = payloadJson['items']; if (rawItems is! List || rawItems.isEmpty) { throw const ValidationException('Pack share payload must contain items.'); } return [ for (final rawItem in rawItems) if (rawItem is Map) _packResourceFromPayload(Map.from(rawItem)) else throw const ValidationException('Pack item must be a JSON object.'), ]; } _ShareResourceData _packResourceFromPayload(Map item) { final resourceType = item['resourceType']; if (resourceType is! String || resourceType.trim().isEmpty) { throw const ValidationException( 'Pack item resourceType must not be blank.', ); } final payload = item['payload']; if (payload is Map) { return _ShareResourceData( resourceType: _shareResourceType(resourceType), payloadJson: payload, ); } if (payload is Map) { return _ShareResourceData( resourceType: _shareResourceType(resourceType), payloadJson: Map.from(payload), ); } throw const ValidationException('Pack item payload must be a JSON object.'); } List _normalizedEmails(List emails) { return emails .map((email) => email.trim().toLowerCase()) .where((email) => email.isNotEmpty) .toSet() .toList(growable: false); } String? _blankToNull(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; } int _schemaVersionFromPayload(Map payloadJson) { final schemaVersion = payloadJson['schemaVersion']; return schemaVersion is int && schemaVersion > 0 ? schemaVersion : 1; }