Ajoute l'endpoint api/sync_api.dart, les use cases de synchronisation (application/sync_use_cases.dart) et l'adapter Postgres (infrastructure/postgres/synced_resource_repository.dart) implémentant un upsert LWW atomique via CTE (INSERT ... ON CONFLICT ... WHERE client_updated_at < EXCLUDED.client_updated_at, avec fallback UNION ALL pour le cas ignoré). dart pub get OK, dart analyze clean, dart test 15/15 vert. SQL d'upsert relu manuellement et jugé correct ; pas de test de bout en bout contre un vrai PostgreSQL faute d'accès Docker dans ce sandbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
4.2 KiB
Dart
155 lines
4.2 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 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;
|
|
}
|
|
|
|
String _nonBlank(String value, String label) {
|
|
final trimmed = value.trim();
|
|
if (trimmed.isEmpty) {
|
|
throw ValidationException('$label must not be blank.');
|
|
}
|
|
return trimmed;
|
|
}
|