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>
This commit is contained in:
@ -68,6 +68,12 @@ abstract interface class ShareRepository {
|
||||
required DateTime respondedAt,
|
||||
});
|
||||
|
||||
Future<List<SyncedResource>> acceptShare({
|
||||
required String recipientId,
|
||||
required DateTime respondedAt,
|
||||
required List<SyncedResource> resources,
|
||||
});
|
||||
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
|
||||
@ -14,9 +14,21 @@ final class CreateShareResult {
|
||||
}
|
||||
|
||||
final class AcceptShareResult {
|
||||
const AcceptShareResult({required this.createdResource});
|
||||
const AcceptShareResult({required this.createdResources});
|
||||
|
||||
final SyncedResource createdResource;
|
||||
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 {
|
||||
@ -34,11 +46,21 @@ final class CreateShareUseCase {
|
||||
|
||||
Future<CreateShareResult> execute({
|
||||
required String senderUserId,
|
||||
required String resourceType,
|
||||
required Map<String, Object?> payloadJson,
|
||||
String shareKind = 'single',
|
||||
String? packName,
|
||||
required String? resourceType,
|
||||
required Map<String, Object?>? payloadJson,
|
||||
List<SharePackItemInput> packItems = const [],
|
||||
required List<String> recipientEmails,
|
||||
}) async {
|
||||
final parsedType = _shareResourceType(resourceType);
|
||||
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.');
|
||||
@ -60,8 +82,10 @@ final class CreateShareUseCase {
|
||||
final share = Share(
|
||||
id: ids.newId(),
|
||||
senderUserId: senderUserId,
|
||||
resourceType: parsedType,
|
||||
payloadJson: payloadJson,
|
||||
kind: parsedKind,
|
||||
resourceType: shareContent.resourceType,
|
||||
packName: shareContent.packName,
|
||||
payloadJson: shareContent.payloadJson,
|
||||
createdAt: now,
|
||||
);
|
||||
final recipients = [
|
||||
@ -94,13 +118,11 @@ final class ListInboxUseCase {
|
||||
final class AcceptShareUseCase {
|
||||
const AcceptShareUseCase({
|
||||
required this.shares,
|
||||
required this.resources,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
});
|
||||
|
||||
final ShareRepository shares;
|
||||
final SyncedResourceRepository resources;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
|
||||
@ -124,23 +146,25 @@ final class AcceptShareUseCase {
|
||||
}
|
||||
|
||||
final now = clock.now().toUtc();
|
||||
final resource = SyncedResource(
|
||||
serverId: ids.newId(),
|
||||
ownerUserId: recipientUserId,
|
||||
resourceType: share.resourceType,
|
||||
clientId: ids.newId(),
|
||||
payloadJson: share.payloadJson,
|
||||
schemaVersion: _schemaVersionFromPayload(share.payloadJson),
|
||||
clientUpdatedAt: now,
|
||||
serverUpdatedAt: now,
|
||||
);
|
||||
final written = await resources.upsertWithLww(resource);
|
||||
await shares.updateRecipientStatus(
|
||||
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,
|
||||
status: ShareRecipientStatus.accepted,
|
||||
respondedAt: now,
|
||||
resources: resourcesToCreate,
|
||||
);
|
||||
return AcceptShareResult(createdResource: written.resource);
|
||||
return AcceptShareResult(createdResources: written);
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,6 +229,140 @@ SyncedResourceType _shareResourceType(String value) {
|
||||
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())
|
||||
@ -213,6 +371,11 @@ List<String> _normalizedEmails(List<String> emails) {
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user