Files
GameTime/server/lib/api/share_api.dart
Blomios db6e84be06 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>
2026-07-19 10:34:09 +02:00

196 lines
5.9 KiB
Dart

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