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:
@ -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