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:
@ -4,9 +4,14 @@ import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
import 'auth_api.dart';
|
||||
import 'share_api.dart';
|
||||
import 'sync_api.dart';
|
||||
|
||||
Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) {
|
||||
Handler buildApiHandler({
|
||||
AuthApi? authApi,
|
||||
SyncApi? syncApi,
|
||||
ShareApi? shareApi,
|
||||
}) {
|
||||
final router = Router()
|
||||
..get('/health', (Request request) {
|
||||
return Response.ok(
|
||||
@ -49,5 +54,39 @@ Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) {
|
||||
);
|
||||
}
|
||||
|
||||
if (shareApi != null) {
|
||||
router
|
||||
..post(
|
||||
'/shares',
|
||||
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||
shareApi.create,
|
||||
),
|
||||
)
|
||||
..get(
|
||||
'/shares/inbox',
|
||||
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||
shareApi.inbox,
|
||||
),
|
||||
)
|
||||
..post(
|
||||
'/shares/<id>/accept',
|
||||
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||
(request) => shareApi.accept(request, request.params['id']!),
|
||||
),
|
||||
)
|
||||
..post(
|
||||
'/shares/<id>/decline',
|
||||
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||
(request) => shareApi.decline(request, request.params['id']!),
|
||||
),
|
||||
)
|
||||
..post(
|
||||
'/shares/<id>/revoke',
|
||||
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||
(request) => shareApi.revoke(request, request.params['id']!),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
@ -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`.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export 'ports.dart';
|
||||
export 'share_use_cases.dart';
|
||||
export 'sync_use_cases.dart';
|
||||
export 'use_cases.dart';
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
@ -5,4 +5,5 @@ Pure server domain entities and invariants.
|
||||
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
|
||||
other infrastructure concerns.
|
||||
|
||||
Current entities: `UserAccount`, `AuthSession` and `SyncedResource`.
|
||||
Current entities: `UserAccount`, `AuthSession`, `SyncedResource`, `Share` and
|
||||
`ShareRecipient`.
|
||||
|
||||
@ -23,6 +23,14 @@ final class UnauthorizedException extends DomainException {
|
||||
const UnauthorizedException() : super('Unauthorized.');
|
||||
}
|
||||
|
||||
final class ShareNotFoundException extends DomainException {
|
||||
const ShareNotFoundException() : super('Share not found.');
|
||||
}
|
||||
|
||||
final class ShareConflictException extends DomainException {
|
||||
const ShareConflictException(super.message);
|
||||
}
|
||||
|
||||
final class UserAccount {
|
||||
UserAccount({
|
||||
required String id,
|
||||
@ -145,6 +153,76 @@ final class SyncedResource {
|
||||
final String? originDeviceId;
|
||||
}
|
||||
|
||||
enum ShareRecipientStatus {
|
||||
pending('pending'),
|
||||
accepted('accepted'),
|
||||
declined('declined'),
|
||||
revoked('revoked');
|
||||
|
||||
const ShareRecipientStatus(this.wireName);
|
||||
|
||||
final String wireName;
|
||||
|
||||
static ShareRecipientStatus parse(String value) {
|
||||
for (final status in values) {
|
||||
if (status.wireName == value) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
throw ValidationException('Unsupported share recipient status: $value.');
|
||||
}
|
||||
}
|
||||
|
||||
final class Share {
|
||||
Share({
|
||||
required String id,
|
||||
required String senderUserId,
|
||||
required this.resourceType,
|
||||
required Map<String, Object?> payloadJson,
|
||||
required DateTime createdAt,
|
||||
DateTime? revokedAt,
|
||||
}) : id = _nonBlank(id, 'Share id'),
|
||||
senderUserId = _nonBlank(senderUserId, 'Sender user id'),
|
||||
payloadJson = Map.unmodifiable(payloadJson),
|
||||
createdAt = createdAt.toUtc(),
|
||||
revokedAt = revokedAt?.toUtc() {
|
||||
if (resourceType != SyncedResourceType.program &&
|
||||
resourceType != SyncedResourceType.workoutTemplate) {
|
||||
throw const ValidationException(
|
||||
'Shares only support program and workoutTemplate resources.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String senderUserId;
|
||||
final SyncedResourceType resourceType;
|
||||
final Map<String, Object?> payloadJson;
|
||||
final DateTime createdAt;
|
||||
final DateTime? revokedAt;
|
||||
|
||||
bool get isRevoked => revokedAt != null;
|
||||
}
|
||||
|
||||
final class ShareRecipient {
|
||||
ShareRecipient({
|
||||
required String id,
|
||||
required String shareId,
|
||||
required String recipientUserId,
|
||||
this.status = ShareRecipientStatus.pending,
|
||||
DateTime? respondedAt,
|
||||
}) : id = _nonBlank(id, 'Share recipient id'),
|
||||
shareId = _nonBlank(shareId, 'Share id'),
|
||||
recipientUserId = _nonBlank(recipientUserId, 'Recipient user id'),
|
||||
respondedAt = respondedAt?.toUtc();
|
||||
|
||||
final String id;
|
||||
final String shareId;
|
||||
final String recipientUserId;
|
||||
final ShareRecipientStatus status;
|
||||
final DateTime? respondedAt;
|
||||
}
|
||||
|
||||
String _nonBlank(String value, String label) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
|
||||
@ -2,5 +2,5 @@
|
||||
|
||||
Connection and migration utilities for server-side PostgreSQL.
|
||||
|
||||
Current adapters cover auth users/sessions and the generic `synced_resources`
|
||||
sync table.
|
||||
Current adapters cover auth users/sessions, the generic `synced_resources` sync
|
||||
table, and targeted sharing through `shares` / `share_recipients`.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export 'auth_repositories.dart';
|
||||
export 'postgres_database.dart';
|
||||
export 'share_repository.dart';
|
||||
export 'synced_resource_repository.dart';
|
||||
|
||||
237
server/lib/infrastructure/postgres/share_repository.dart
Normal file
237
server/lib/infrastructure/postgres/share_repository.dart
Normal file
@ -0,0 +1,237 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:postgres/postgres.dart';
|
||||
|
||||
import '../../application/application.dart';
|
||||
import '../../domain/domain.dart';
|
||||
|
||||
final class PostgresShareRepository implements ShareRepository {
|
||||
const PostgresShareRepository(this.connection);
|
||||
|
||||
final Connection connection;
|
||||
|
||||
@override
|
||||
Future<void> insertShare({
|
||||
required Share share,
|
||||
required List<ShareRecipient> recipients,
|
||||
}) async {
|
||||
await connection.execute(
|
||||
Sql.named('''
|
||||
INSERT INTO shares (
|
||||
id, sender_user_id, resource_type, payload_json, created_at,
|
||||
revoked_at
|
||||
)
|
||||
VALUES (
|
||||
@id::uuid, @sender_user_id::uuid, @resource_type,
|
||||
@payload_json::jsonb, @created_at, @revoked_at
|
||||
)
|
||||
'''),
|
||||
parameters: {
|
||||
'id': share.id,
|
||||
'sender_user_id': share.senderUserId,
|
||||
'resource_type': share.resourceType.wireName,
|
||||
'payload_json': jsonEncode(share.payloadJson),
|
||||
'created_at': share.createdAt,
|
||||
'revoked_at': share.revokedAt,
|
||||
},
|
||||
);
|
||||
|
||||
for (final recipient in recipients) {
|
||||
await connection.execute(
|
||||
Sql.named('''
|
||||
INSERT INTO share_recipients (
|
||||
id, share_id, recipient_user_id, status, responded_at
|
||||
)
|
||||
VALUES (
|
||||
@id::uuid, @share_id::uuid, @recipient_user_id::uuid, @status,
|
||||
@responded_at
|
||||
)
|
||||
ON CONFLICT (share_id, recipient_user_id) DO NOTHING
|
||||
'''),
|
||||
parameters: {
|
||||
'id': recipient.id,
|
||||
'share_id': recipient.shareId,
|
||||
'recipient_user_id': recipient.recipientUserId,
|
||||
'status': recipient.status.wireName,
|
||||
'responded_at': recipient.respondedAt,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
|
||||
final result = await connection.execute(
|
||||
Sql.named('''
|
||||
SELECT
|
||||
s.id AS share_id,
|
||||
s.sender_user_id,
|
||||
s.resource_type,
|
||||
s.payload_json,
|
||||
s.created_at,
|
||||
s.revoked_at,
|
||||
sr.id AS recipient_id,
|
||||
sr.recipient_user_id,
|
||||
sr.status,
|
||||
sr.responded_at
|
||||
FROM share_recipients sr
|
||||
INNER JOIN shares s ON s.id = sr.share_id
|
||||
WHERE sr.recipient_user_id = @recipient_user_id::uuid
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
'''),
|
||||
parameters: {'recipient_user_id': recipientUserId},
|
||||
);
|
||||
return [
|
||||
for (final row in result)
|
||||
_inboxItemFromValues(row.toColumnMap() as Map<String, Object?>),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Share?> findShareById(String shareId) async {
|
||||
final result = await connection.execute(
|
||||
Sql.named('''
|
||||
SELECT id, sender_user_id, resource_type, payload_json, created_at,
|
||||
revoked_at
|
||||
FROM shares
|
||||
WHERE id = @id::uuid
|
||||
LIMIT 1
|
||||
'''),
|
||||
parameters: {'id': shareId},
|
||||
);
|
||||
return result.isEmpty
|
||||
? null
|
||||
: _shareFromValues(result.single.toColumnMap() as Map<String, Object?>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShareRecipient?> findRecipient({
|
||||
required String shareId,
|
||||
required String recipientUserId,
|
||||
}) async {
|
||||
final result = await connection.execute(
|
||||
Sql.named('''
|
||||
SELECT id, share_id, recipient_user_id, status, responded_at
|
||||
FROM share_recipients
|
||||
WHERE share_id = @share_id::uuid
|
||||
AND recipient_user_id = @recipient_user_id::uuid
|
||||
LIMIT 1
|
||||
'''),
|
||||
parameters: {'share_id': shareId, 'recipient_user_id': recipientUserId},
|
||||
);
|
||||
return result.isEmpty
|
||||
? null
|
||||
: _recipientFromValues(
|
||||
result.single.toColumnMap() as Map<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRecipientStatus({
|
||||
required String recipientId,
|
||||
required ShareRecipientStatus status,
|
||||
required DateTime respondedAt,
|
||||
}) async {
|
||||
await connection.execute(
|
||||
Sql.named('''
|
||||
UPDATE share_recipients
|
||||
SET status = @status, responded_at = @responded_at
|
||||
WHERE id = @id::uuid
|
||||
'''),
|
||||
parameters: {
|
||||
'id': recipientId,
|
||||
'status': status.wireName,
|
||||
'responded_at': respondedAt.toUtc(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
}) async {
|
||||
await connection.execute(
|
||||
Sql.named('''
|
||||
UPDATE shares
|
||||
SET revoked_at = @revoked_at
|
||||
WHERE id = @id::uuid AND revoked_at IS NULL
|
||||
'''),
|
||||
parameters: {'id': shareId, 'revoked_at': revokedAt.toUtc()},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ShareInboxItem _inboxItemFromValues(Map<String, Object?> values) {
|
||||
return ShareInboxItem(
|
||||
share: _shareFromValues(values),
|
||||
recipient: _recipientFromValues(values),
|
||||
);
|
||||
}
|
||||
|
||||
Share _shareFromValues(Map<String, Object?> values) {
|
||||
return Share(
|
||||
id: _stringValue(values['share_id'] ?? values['id']),
|
||||
senderUserId: _stringValue(values['sender_user_id']),
|
||||
resourceType: SyncedResourceType.parse(
|
||||
_stringValue(values['resource_type']),
|
||||
),
|
||||
payloadJson: _payloadValue(values['payload_json']),
|
||||
createdAt: _dateTimeValue(values['created_at']),
|
||||
revokedAt: _nullableDateTimeValue(values['revoked_at']),
|
||||
);
|
||||
}
|
||||
|
||||
ShareRecipient _recipientFromValues(Map<String, Object?> values) {
|
||||
return ShareRecipient(
|
||||
id: _stringValue(values['recipient_id'] ?? values['id']),
|
||||
shareId: _stringValue(values['share_id']),
|
||||
recipientUserId: _stringValue(values['recipient_user_id']),
|
||||
status: ShareRecipientStatus.parse(_stringValue(values['status'])),
|
||||
respondedAt: _nullableDateTimeValue(values['responded_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _payloadValue(Object? value) {
|
||||
if (value is Map<String, Object?>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return Map<String, Object?>.from(value);
|
||||
}
|
||||
if (value is String) {
|
||||
final decoded = jsonDecode(value);
|
||||
if (decoded is Map<String, Object?>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return Map<String, Object?>.from(decoded);
|
||||
}
|
||||
}
|
||||
throw const FormatException('Expected JSON object payload.');
|
||||
}
|
||||
|
||||
String _stringValue(Object? value) {
|
||||
if (value == null) {
|
||||
throw const FormatException('Expected non-null string value.');
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
DateTime _dateTimeValue(Object? value) {
|
||||
final result = _nullableDateTimeValue(value);
|
||||
if (result == null) {
|
||||
throw const FormatException('Expected non-null DateTime value.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
DateTime? _nullableDateTimeValue(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value.toUtc();
|
||||
}
|
||||
return DateTime.parse(value.toString()).toUtc();
|
||||
}
|
||||
Reference in New Issue
Block a user