Files
GameTime/server/lib/application/share_use_cases.dart
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
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>
2026-07-28 16:48:54 +02:00

383 lines
10 KiB
Dart

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<String> recipientUserIds;
final List<String> unresolvedEmails;
}
final class AcceptShareResult {
const AcceptShareResult({required this.createdResources});
final List<SyncedResource> createdResources;
SyncedResource get createdResource => createdResources.single;
}
final class SharePackItemInput {
const SharePackItemInput({
required this.resourceType,
required this.payloadJson,
});
final String resourceType;
final Map<String, Object?> 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<CreateShareResult> execute({
required String senderUserId,
String shareKind = 'single',
String? packName,
required String? resourceType,
required Map<String, Object?>? payloadJson,
List<SharePackItemInput> packItems = const [],
required List<String> 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 = <String>[];
final unresolvedEmails = <String>[];
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<List<ShareInboxItem>> 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<AcceptShareResult> 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<void> 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<void> 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<String, Object?> payloadJson;
}
_ShareContent _shareContent({
required ShareKind kind,
required String? packName,
required String? resourceType,
required Map<String, Object?>? payloadJson,
required List<SharePackItemInput> 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<String, Object?>? 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<SharePackItemInput> 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<String, Object?> 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<String, Object?> 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<String, Object?>.from(rawItem))
else
throw const ValidationException('Pack item must be a JSON object.'),
];
}
_ShareResourceData _packResourceFromPayload(Map<String, Object?> 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<String, Object?>) {
return _ShareResourceData(
resourceType: _shareResourceType(resourceType),
payloadJson: payload,
);
}
if (payload is Map) {
return _ShareResourceData(
resourceType: _shareResourceType(resourceType),
payloadJson: Map<String, Object?>.from(payload),
);
}
throw const ValidationException('Pack item payload must be a JSON object.');
}
List<String> _normalizedEmails(List<String> 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<String, Object?> payloadJson) {
final schemaVersion = payloadJson['schemaVersion'];
return schemaVersion is int && schemaVersion > 0 ? schemaVersion : 1;
}