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>
383 lines
10 KiB
Dart
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;
|
|
}
|