final class DomainException implements Exception { const DomainException(this.message); final String message; @override String toString() => message; } final class ValidationException extends DomainException { const ValidationException(super.message); } final class EmailAlreadyTakenException extends DomainException { const EmailAlreadyTakenException() : super('Email already taken.'); } final class InvalidCredentialsException extends DomainException { const InvalidCredentialsException() : super('Invalid credentials.'); } final class UnauthorizedException extends DomainException { const UnauthorizedException() : super('Unauthorized.'); } final class ShareNotFoundException extends DomainException { const ShareNotFoundException() : super('Share not found.'); } final class ShareConflictException extends DomainException { const ShareConflictException(super.message); } final class UserAccount { UserAccount({ required String id, required String email, required String passwordHash, this.displayName, required DateTime createdAt, required DateTime updatedAt, DateTime? disabledAt, }) : id = _nonBlank(id, 'User id'), email = _nonBlank(email, 'Email').toLowerCase(), passwordHash = _nonBlank(passwordHash, 'Password hash'), createdAt = createdAt.toUtc(), updatedAt = updatedAt.toUtc(), disabledAt = disabledAt?.toUtc(); final String id; final String email; final String passwordHash; final String? displayName; final DateTime createdAt; final DateTime updatedAt; final DateTime? disabledAt; bool get isDisabled => disabledAt != null; } final class AuthSession { AuthSession({ required String id, required String userId, required String tokenHash, required DateTime issuedAt, required DateTime expiresAt, DateTime? revokedAt, this.userAgent, this.deviceLabel, }) : id = _nonBlank(id, 'Auth session id'), userId = _nonBlank(userId, 'User id'), tokenHash = _nonBlank(tokenHash, 'Token hash'), issuedAt = issuedAt.toUtc(), expiresAt = expiresAt.toUtc(), revokedAt = revokedAt?.toUtc() { if (!this.expiresAt.isAfter(this.issuedAt)) { throw const ValidationException( 'Auth session expiration must be after issue time.', ); } } final String id; final String userId; final String tokenHash; final DateTime issuedAt; final DateTime expiresAt; final DateTime? revokedAt; final String? userAgent; final String? deviceLabel; bool isActiveAt(DateTime now) { final instant = now.toUtc(); return revokedAt == null && expiresAt.isAfter(instant); } } enum SyncedResourceType { exercise('exercise'), program('program'), workoutTemplate('workoutTemplate'), workoutHistory('workoutHistory'), mediaAsset('mediaAsset'); const SyncedResourceType(this.wireName); final String wireName; static SyncedResourceType parse(String value) { for (final type in values) { if (type.wireName == value) { return type; } } throw ValidationException('Unsupported resource type: $value.'); } } final class SyncedResource { SyncedResource({ required String serverId, required String ownerUserId, required this.resourceType, required String clientId, required Map payloadJson, required this.schemaVersion, required DateTime clientUpdatedAt, required DateTime serverUpdatedAt, DateTime? deletedAt, this.originDeviceId, }) : serverId = _nonBlank(serverId, 'Server resource id'), ownerUserId = _nonBlank(ownerUserId, 'Owner user id'), clientId = _nonBlank(clientId, 'Client resource id'), payloadJson = Map.unmodifiable(payloadJson), clientUpdatedAt = clientUpdatedAt.toUtc(), serverUpdatedAt = serverUpdatedAt.toUtc(), deletedAt = deletedAt?.toUtc() { if (schemaVersion <= 0) { throw const ValidationException('Schema version must be positive.'); } } final String serverId; final String ownerUserId; final SyncedResourceType resourceType; final String clientId; final Map payloadJson; final int schemaVersion; final DateTime clientUpdatedAt; final DateTime serverUpdatedAt; final DateTime? deletedAt; final String? originDeviceId; } enum ShareRecipientStatus { pending('pending'), accepted('accepted'), declined('declined'), revoked('revoked'); const ShareRecipientStatus(this.wireName); final String wireName; static ShareRecipientStatus parse(String value) { for (final status in values) { if (status.wireName == value) { return status; } } throw ValidationException('Unsupported share recipient status: $value.'); } } enum ShareKind { single('single'), pack('pack'); const ShareKind(this.wireName); final String wireName; static ShareKind parse(String value) { for (final kind in values) { if (kind.wireName == value) { return kind; } } throw ValidationException('Unsupported share kind: $value.'); } } final class Share { Share({ required String id, required String senderUserId, this.kind = ShareKind.single, required this.resourceType, String? packName, required Map payloadJson, required DateTime createdAt, DateTime? revokedAt, }) : id = _nonBlank(id, 'Share id'), senderUserId = _nonBlank(senderUserId, 'Sender user id'), packName = _blankToNull(packName), payloadJson = Map.unmodifiable(payloadJson), createdAt = createdAt.toUtc(), revokedAt = revokedAt?.toUtc() { if (kind == ShareKind.single) { final type = resourceType; if (type != SyncedResourceType.program && type != SyncedResourceType.workoutTemplate) { throw const ValidationException( 'Single shares only support program and workoutTemplate resources.', ); } } if (kind == ShareKind.pack) { if (resourceType != null) { throw const ValidationException( 'Pack shares must not set resourceType.', ); } if (this.packName == null) { throw const ValidationException('Pack name must not be blank.'); } } if (kind == ShareKind.pack && payloadJson['items'] is! List) { throw const ValidationException('Pack share payload must contain items.'); } } final String id; final String senderUserId; final ShareKind kind; final SyncedResourceType? resourceType; final String? packName; final Map payloadJson; final DateTime createdAt; final DateTime? revokedAt; bool get isRevoked => revokedAt != null; } final class ShareRecipient { ShareRecipient({ required String id, required String shareId, required String recipientUserId, this.status = ShareRecipientStatus.pending, DateTime? respondedAt, }) : id = _nonBlank(id, 'Share recipient id'), shareId = _nonBlank(shareId, 'Share id'), recipientUserId = _nonBlank(recipientUserId, 'Recipient user id'), respondedAt = respondedAt?.toUtc(); final String id; final String shareId; final String recipientUserId; final ShareRecipientStatus status; final DateTime? respondedAt; } String _nonBlank(String value, String label) { final trimmed = value.trim(); if (trimmed.isEmpty) { throw ValidationException('$label must not be blank.'); } return trimmed; } String? _blankToNull(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; }