feat(server): partage ciblé de programmes et séances entre comptes (ticket #51)
Ajoute l'endpoint api/share_api.dart, les use cases de partage (application/share_use_cases.dart) et l'adapter Postgres (infrastructure/postgres/share_repository.dart). L'acceptation d'un partage crée une nouvelle ressource synced_resources avec de nouveaux IDs pour le destinataire, sans jamais modifier la ressource source de l'émetteur ; gestion des conflits révoqué/déjà répondu. dart pub get OK, dart analyze clean, dart test 24/24 vert. Use case d'acceptation 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>
This commit is contained in:
219
server/lib/application/share_use_cases.dart
Normal file
219
server/lib/application/share_use_cases.dart
Normal file
@ -0,0 +1,219 @@
|
||||
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.createdResource});
|
||||
|
||||
final SyncedResource createdResource;
|
||||
}
|
||||
|
||||
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,
|
||||
required String resourceType,
|
||||
required Map<String, Object?> payloadJson,
|
||||
required List<String> recipientEmails,
|
||||
}) async {
|
||||
final parsedType = _shareResourceType(resourceType);
|
||||
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,
|
||||
resourceType: parsedType,
|
||||
payloadJson: 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.resources,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
});
|
||||
|
||||
final ShareRepository shares;
|
||||
final SyncedResourceRepository resources;
|
||||
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 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(
|
||||
recipientId: recipient.id,
|
||||
status: ShareRecipientStatus.accepted,
|
||||
respondedAt: now,
|
||||
);
|
||||
return AcceptShareResult(createdResource: written.resource);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
List<String> _normalizedEmails(List<String> emails) {
|
||||
return emails
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.where((email) => email.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
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