Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
7.5 KiB
Dart
277 lines
7.5 KiB
Dart
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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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;
|
|
}
|