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:
2026-07-19 10:34:09 +02:00
parent c232bdcec1
commit db6e84be06
15 changed files with 1531 additions and 13 deletions

View File

@ -2,5 +2,5 @@
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
Authentication and sync use cases live here and depend only on repository /
security ports. Concrete adapters are wired from `bin/server.dart`.
Authentication, sync and targeted sharing use cases live here and depend only on
repository / security ports. Concrete adapters are wired from `bin/server.dart`.

View File

@ -1,3 +1,4 @@
export 'ports.dart';
export 'share_use_cases.dart';
export 'sync_use_cases.dart';
export 'use_cases.dart';

View File

@ -40,6 +40,40 @@ abstract interface class SyncedResourceRepository {
});
}
final class ShareInboxItem {
const ShareInboxItem({required this.share, required this.recipient});
final Share share;
final ShareRecipient recipient;
}
abstract interface class ShareRepository {
Future<void> insertShare({
required Share share,
required List<ShareRecipient> recipients,
});
Future<List<ShareInboxItem>> listInbox(String recipientUserId);
Future<Share?> findShareById(String shareId);
Future<ShareRecipient?> findRecipient({
required String shareId,
required String recipientUserId,
});
Future<void> updateRecipientStatus({
required String recipientId,
required ShareRecipientStatus status,
required DateTime respondedAt,
});
Future<void> revokeShare({
required String shareId,
required DateTime revokedAt,
});
}
abstract interface class PasswordHasher {
Future<String> hash(String password);

View 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;
}