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:
195
server/lib/api/share_api.dart
Normal file
195
server/lib/api/share_api.dart
Normal file
@ -0,0 +1,195 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'auth_api.dart';
|
||||
|
||||
final class ShareApi {
|
||||
const ShareApi({
|
||||
required this.createShare,
|
||||
required this.listInbox,
|
||||
required this.acceptShare,
|
||||
required this.declineShare,
|
||||
required this.revokeShare,
|
||||
required this.authenticateRequest,
|
||||
});
|
||||
|
||||
final CreateShareUseCase createShare;
|
||||
final ListInboxUseCase listInbox;
|
||||
final AcceptShareUseCase acceptShare;
|
||||
final DeclineShareUseCase declineShare;
|
||||
final RevokeShareUseCase revokeShare;
|
||||
final AuthenticateRequestUseCase authenticateRequest;
|
||||
|
||||
Future<Response> create(Request request) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
try {
|
||||
final body = await _readJsonObject(request);
|
||||
final result = await createShare.execute(
|
||||
senderUserId: authenticated.user.id,
|
||||
resourceType: _requiredString(body, 'resourceType'),
|
||||
payloadJson: _requiredObject(body, 'payload'),
|
||||
recipientEmails: _requiredStringList(body, 'recipientEmails'),
|
||||
);
|
||||
return _jsonResponse(201, {
|
||||
'shareId': result.share.id,
|
||||
'recipientUserIds': result.recipientUserIds,
|
||||
'unresolvedEmails': result.unresolvedEmails,
|
||||
});
|
||||
} on ValidationException catch (error) {
|
||||
return _errorResponse(400, error.message);
|
||||
} on FormatException catch (error) {
|
||||
return _errorResponse(400, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> inbox(Request request) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
final items = await listInbox.execute(
|
||||
recipientUserId: authenticated.user.id,
|
||||
);
|
||||
return _jsonResponse(200, {
|
||||
'items': [
|
||||
for (final item in items)
|
||||
{
|
||||
'shareId': item.share.id,
|
||||
'senderUserId': item.share.senderUserId,
|
||||
'resourceType': item.share.resourceType.wireName,
|
||||
'payload': item.share.payloadJson,
|
||||
'status': item.recipient.status.wireName,
|
||||
'createdAt': item.share.createdAt.toIso8601String(),
|
||||
'respondedAt': item.recipient.respondedAt?.toIso8601String(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Future<Response> accept(Request request, String shareId) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
try {
|
||||
final result = await acceptShare.execute(
|
||||
shareId: shareId,
|
||||
recipientUserId: authenticated.user.id,
|
||||
);
|
||||
return _jsonResponse(200, {
|
||||
'createdResource': _resourceJson(result.createdResource),
|
||||
});
|
||||
} on ShareNotFoundException catch (error) {
|
||||
return _errorResponse(404, error.message);
|
||||
} on ShareConflictException catch (error) {
|
||||
return _errorResponse(409, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> decline(Request request, String shareId) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
try {
|
||||
await declineShare.execute(
|
||||
shareId: shareId,
|
||||
recipientUserId: authenticated.user.id,
|
||||
);
|
||||
return Response(204);
|
||||
} on ShareNotFoundException catch (error) {
|
||||
return _errorResponse(404, error.message);
|
||||
} on ShareConflictException catch (error) {
|
||||
return _errorResponse(409, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> revoke(Request request, String shareId) async {
|
||||
final authenticated = authenticatedRequestFrom(request);
|
||||
if (authenticated == null) {
|
||||
return _errorResponse(401, 'Unauthorized.');
|
||||
}
|
||||
try {
|
||||
await revokeShare.execute(
|
||||
shareId: shareId,
|
||||
senderUserId: authenticated.user.id,
|
||||
);
|
||||
return Response(204);
|
||||
} on ShareNotFoundException catch (error) {
|
||||
return _errorResponse(404, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _resourceJson(SyncedResource resource) {
|
||||
return {
|
||||
'serverId': resource.serverId,
|
||||
'clientId': resource.clientId,
|
||||
'resourceType': resource.resourceType.wireName,
|
||||
'schemaVersion': resource.schemaVersion,
|
||||
'clientUpdatedAt': resource.clientUpdatedAt.toIso8601String(),
|
||||
'serverUpdatedAt': resource.serverUpdatedAt.toIso8601String(),
|
||||
'deletedAt': resource.deletedAt?.toIso8601String(),
|
||||
'payload': resource.payloadJson,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readJsonObject(Request request) async {
|
||||
final raw = await request.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('Request body must be a JSON object.');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, Object?> body, String key) {
|
||||
final value = body[key];
|
||||
if (value is! String || value.trim().isEmpty) {
|
||||
throw FormatException('$key must be a non-empty string.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Map<String, Object?> _requiredObject(Map<String, Object?> body, String key) {
|
||||
final value = body[key];
|
||||
if (value is Map<String, Object?>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return Map<String, Object?>.from(value);
|
||||
}
|
||||
throw FormatException('$key must be a JSON object.');
|
||||
}
|
||||
|
||||
List<String> _requiredStringList(Map<String, Object?> body, String key) {
|
||||
final value = body[key];
|
||||
if (value is! List) {
|
||||
throw FormatException('$key must be an array.');
|
||||
}
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is String)
|
||||
item
|
||||
else
|
||||
throw FormatException('$key must contain only strings.'),
|
||||
];
|
||||
}
|
||||
|
||||
Response _jsonResponse(int statusCode, Map<String, Object?> body) {
|
||||
return Response(
|
||||
statusCode,
|
||||
body: jsonEncode(body),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
Response _errorResponse(int statusCode, String message) {
|
||||
return _jsonResponse(statusCode, {'error': message});
|
||||
}
|
||||
Reference in New Issue
Block a user