chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)

Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -30,14 +30,21 @@ final class ShareApi {
}
try {
final body = await _readJsonObject(request);
final shareKind = _optionalString(body, 'shareKind') ?? 'single';
final result = await createShare.execute(
senderUserId: authenticated.user.id,
resourceType: _requiredString(body, 'resourceType'),
payloadJson: _requiredObject(body, 'payload'),
shareKind: shareKind,
packName: _optionalString(body, 'packName'),
resourceType: _optionalString(body, 'resourceType'),
payloadJson: _optionalObject(body, 'payload'),
packItems: shareKind == ShareKind.pack.wireName
? _packItems(body['items'])
: const [],
recipientEmails: _requiredStringList(body, 'recipientEmails'),
);
return _jsonResponse(201, {
'shareId': result.share.id,
'shareKind': result.share.kind.wireName,
'recipientUserIds': result.recipientUserIds,
'unresolvedEmails': result.unresolvedEmails,
});
@ -62,7 +69,9 @@ final class ShareApi {
{
'shareId': item.share.id,
'senderUserId': item.share.senderUserId,
'resourceType': item.share.resourceType.wireName,
'shareKind': item.share.kind.wireName,
'packName': item.share.packName,
'resourceType': item.share.resourceType?.wireName,
'payload': item.share.payloadJson,
'status': item.recipient.status.wireName,
'createdAt': item.share.createdAt.toIso8601String(),
@ -83,7 +92,12 @@ final class ShareApi {
recipientUserId: authenticated.user.id,
);
return _jsonResponse(200, {
'createdResource': _resourceJson(result.createdResource),
if (result.createdResources.length == 1)
'createdResource': _resourceJson(result.createdResource),
'createdResources': [
for (final resource in result.createdResources)
_resourceJson(resource),
],
});
} on ShareNotFoundException catch (error) {
return _errorResponse(404, error.message);
@ -140,6 +154,26 @@ Map<String, Object?> _resourceJson(SyncedResource resource) {
};
}
List<SharePackItemInput> _packItems(Object? rawItems) {
if (rawItems is! List) {
throw const FormatException('items must be an array.');
}
return [
for (final rawItem in rawItems)
if (rawItem is Map)
_packItem(Map<String, Object?>.from(rawItem))
else
throw const FormatException('items must contain only objects.'),
];
}
SharePackItemInput _packItem(Map<String, Object?> item) {
return SharePackItemInput(
resourceType: _requiredString(item, 'resourceType'),
payloadJson: _requiredObject(item, 'payload'),
);
}
Future<Map<String, Object?>> _readJsonObject(Request request) async {
final raw = await request.readAsString();
final decoded = jsonDecode(raw);
@ -157,6 +191,18 @@ String _requiredString(Map<String, Object?> body, String key) {
return value;
}
String? _optionalString(Map<String, Object?> body, String key) {
final value = body[key];
if (value == null) {
return null;
}
if (value is! String) {
throw FormatException('$key must be a string.');
}
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
Map<String, Object?> _requiredObject(Map<String, Object?> body, String key) {
final value = body[key];
if (value is Map<String, Object?>) {
@ -168,6 +214,13 @@ Map<String, Object?> _requiredObject(Map<String, Object?> body, String key) {
throw FormatException('$key must be a JSON object.');
}
Map<String, Object?>? _optionalObject(Map<String, Object?> body, String key) {
if (!body.containsKey(key)) {
return null;
}
return _requiredObject(body, key);
}
List<String> _requiredStringList(Map<String, Object?> body, String key) {
final value = body[key];
if (value is! List) {

View File

@ -68,6 +68,12 @@ abstract interface class ShareRepository {
required DateTime respondedAt,
});
Future<List<SyncedResource>> acceptShare({
required String recipientId,
required DateTime respondedAt,
required List<SyncedResource> resources,
});
Future<void> revokeShare({
required String shareId,
required DateTime revokedAt,

View File

@ -14,9 +14,21 @@ final class CreateShareResult {
}
final class AcceptShareResult {
const AcceptShareResult({required this.createdResource});
const AcceptShareResult({required this.createdResources});
final SyncedResource createdResource;
final List<SyncedResource> createdResources;
SyncedResource get createdResource => createdResources.single;
}
final class SharePackItemInput {
const SharePackItemInput({
required this.resourceType,
required this.payloadJson,
});
final String resourceType;
final Map<String, Object?> payloadJson;
}
final class CreateShareUseCase {
@ -34,11 +46,21 @@ final class CreateShareUseCase {
Future<CreateShareResult> execute({
required String senderUserId,
required String resourceType,
required Map<String, Object?> payloadJson,
String shareKind = 'single',
String? packName,
required String? resourceType,
required Map<String, Object?>? payloadJson,
List<SharePackItemInput> packItems = const [],
required List<String> recipientEmails,
}) async {
final parsedType = _shareResourceType(resourceType);
final parsedKind = ShareKind.parse(shareKind);
final shareContent = _shareContent(
kind: parsedKind,
packName: packName,
resourceType: resourceType,
payloadJson: payloadJson,
packItems: packItems,
);
final emails = _normalizedEmails(recipientEmails);
if (emails.isEmpty) {
throw const ValidationException('recipientEmails must not be empty.');
@ -60,8 +82,10 @@ final class CreateShareUseCase {
final share = Share(
id: ids.newId(),
senderUserId: senderUserId,
resourceType: parsedType,
payloadJson: payloadJson,
kind: parsedKind,
resourceType: shareContent.resourceType,
packName: shareContent.packName,
payloadJson: shareContent.payloadJson,
createdAt: now,
);
final recipients = [
@ -94,13 +118,11 @@ final class ListInboxUseCase {
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;
@ -124,23 +146,25 @@ final class AcceptShareUseCase {
}
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(
final resourcesToCreate = [
for (final item in _resourcesFromShare(share))
SyncedResource(
serverId: ids.newId(),
ownerUserId: recipientUserId,
resourceType: item.resourceType,
clientId: ids.newId(),
payloadJson: item.payloadJson,
schemaVersion: _schemaVersionFromPayload(item.payloadJson),
clientUpdatedAt: now,
serverUpdatedAt: now,
),
];
final written = await shares.acceptShare(
recipientId: recipient.id,
status: ShareRecipientStatus.accepted,
respondedAt: now,
resources: resourcesToCreate,
);
return AcceptShareResult(createdResource: written.resource);
return AcceptShareResult(createdResources: written);
}
}
@ -205,6 +229,140 @@ SyncedResourceType _shareResourceType(String value) {
return type;
}
final class _ShareContent {
const _ShareContent({
required this.resourceType,
required this.packName,
required this.payloadJson,
});
final SyncedResourceType? resourceType;
final String? packName;
final Map<String, Object?> payloadJson;
}
_ShareContent _shareContent({
required ShareKind kind,
required String? packName,
required String? resourceType,
required Map<String, Object?>? payloadJson,
required List<SharePackItemInput> packItems,
}) {
return switch (kind) {
ShareKind.single => _singleShareContent(
resourceType: resourceType,
payloadJson: payloadJson,
),
ShareKind.pack => _packShareContent(packName: packName, items: packItems),
};
}
_ShareContent _singleShareContent({
required String? resourceType,
required Map<String, Object?>? payloadJson,
}) {
final normalizedType = _blankToNull(resourceType);
if (normalizedType == null) {
throw const ValidationException('resourceType must not be blank.');
}
final payload = payloadJson;
if (payload == null) {
throw const ValidationException('payload is required.');
}
return _ShareContent(
resourceType: _shareResourceType(normalizedType),
packName: null,
payloadJson: payload,
);
}
_ShareContent _packShareContent({
required String? packName,
required List<SharePackItemInput> items,
}) {
final normalizedPackName = _blankToNull(packName);
if (normalizedPackName == null) {
throw const ValidationException('packName must not be blank.');
}
if (items.isEmpty) {
throw const ValidationException('items must not be empty.');
}
return _ShareContent(
resourceType: null,
packName: normalizedPackName,
payloadJson: {
'items': [
for (final item in items)
{
'resourceType': _shareResourceType(item.resourceType).wireName,
'payload': item.payloadJson,
},
],
},
);
}
final class _ShareResourceData {
const _ShareResourceData({
required this.resourceType,
required this.payloadJson,
});
final SyncedResourceType resourceType;
final Map<String, Object?> payloadJson;
}
List<_ShareResourceData> _resourcesFromShare(Share share) {
return switch (share.kind) {
ShareKind.single => [
_ShareResourceData(
resourceType: share.resourceType!,
payloadJson: share.payloadJson,
),
],
ShareKind.pack => _packResourcesFromPayload(share.payloadJson),
};
}
List<_ShareResourceData> _packResourcesFromPayload(
Map<String, Object?> payloadJson,
) {
final rawItems = payloadJson['items'];
if (rawItems is! List || rawItems.isEmpty) {
throw const ValidationException('Pack share payload must contain items.');
}
return [
for (final rawItem in rawItems)
if (rawItem is Map)
_packResourceFromPayload(Map<String, Object?>.from(rawItem))
else
throw const ValidationException('Pack item must be a JSON object.'),
];
}
_ShareResourceData _packResourceFromPayload(Map<String, Object?> item) {
final resourceType = item['resourceType'];
if (resourceType is! String || resourceType.trim().isEmpty) {
throw const ValidationException(
'Pack item resourceType must not be blank.',
);
}
final payload = item['payload'];
if (payload is Map<String, Object?>) {
return _ShareResourceData(
resourceType: _shareResourceType(resourceType),
payloadJson: payload,
);
}
if (payload is Map) {
return _ShareResourceData(
resourceType: _shareResourceType(resourceType),
payloadJson: Map<String, Object?>.from(payload),
);
}
throw const ValidationException('Pack item payload must be a JSON object.');
}
List<String> _normalizedEmails(List<String> emails) {
return emails
.map((email) => email.trim().toLowerCase())
@ -213,6 +371,11 @@ List<String> _normalizedEmails(List<String> emails) {
.toList(growable: false);
}
String? _blankToNull(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
int _schemaVersionFromPayload(Map<String, Object?> payloadJson) {
final schemaVersion = payloadJson['schemaVersion'];
return schemaVersion is int && schemaVersion > 0 ? schemaVersion : 1;

View File

@ -173,30 +173,69 @@ enum ShareRecipientStatus {
}
}
enum ShareKind {
single('single'),
pack('pack');
const ShareKind(this.wireName);
final String wireName;
static ShareKind parse(String value) {
for (final kind in values) {
if (kind.wireName == value) {
return kind;
}
}
throw ValidationException('Unsupported share kind: $value.');
}
}
final class Share {
Share({
required String id,
required String senderUserId,
this.kind = ShareKind.single,
required this.resourceType,
String? packName,
required Map<String, Object?> payloadJson,
required DateTime createdAt,
DateTime? revokedAt,
}) : id = _nonBlank(id, 'Share id'),
senderUserId = _nonBlank(senderUserId, 'Sender user id'),
packName = _blankToNull(packName),
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.',
);
if (kind == ShareKind.single) {
final type = resourceType;
if (type != SyncedResourceType.program &&
type != SyncedResourceType.workoutTemplate) {
throw const ValidationException(
'Single shares only support program and workoutTemplate resources.',
);
}
}
if (kind == ShareKind.pack) {
if (resourceType != null) {
throw const ValidationException(
'Pack shares must not set resourceType.',
);
}
if (this.packName == null) {
throw const ValidationException('Pack name must not be blank.');
}
}
if (kind == ShareKind.pack && payloadJson['items'] is! List) {
throw const ValidationException('Pack share payload must contain items.');
}
}
final String id;
final String senderUserId;
final SyncedResourceType resourceType;
final ShareKind kind;
final SyncedResourceType? resourceType;
final String? packName;
final Map<String, Object?> payloadJson;
final DateTime createdAt;
final DateTime? revokedAt;
@ -230,3 +269,8 @@ String _nonBlank(String value, String label) {
}
return trimmed;
}
String? _blankToNull(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}

View File

@ -18,18 +18,20 @@ final class PostgresShareRepository implements ShareRepository {
await connection.execute(
Sql.named('''
INSERT INTO shares (
id, sender_user_id, resource_type, payload_json, created_at,
revoked_at
id, sender_user_id, share_kind, pack_name, resource_type,
payload_json, created_at, revoked_at
)
VALUES (
@id::uuid, @sender_user_id::uuid, @resource_type,
@payload_json::jsonb, @created_at, @revoked_at
@id::uuid, @sender_user_id::uuid, @share_kind, @pack_name,
@resource_type, @payload_json::jsonb, @created_at, @revoked_at
)
'''),
parameters: {
'id': share.id,
'sender_user_id': share.senderUserId,
'resource_type': share.resourceType.wireName,
'share_kind': share.kind.wireName,
'pack_name': share.packName,
'resource_type': share.resourceType?.wireName,
'payload_json': jsonEncode(share.payloadJson),
'created_at': share.createdAt,
'revoked_at': share.revokedAt,
@ -66,6 +68,8 @@ final class PostgresShareRepository implements ShareRepository {
SELECT
s.id AS share_id,
s.sender_user_id,
s.share_kind,
s.pack_name,
s.resource_type,
s.payload_json,
s.created_at,
@ -91,8 +95,8 @@ final class PostgresShareRepository implements ShareRepository {
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
SELECT id, sender_user_id, share_kind, pack_name, resource_type,
payload_json, created_at, revoked_at
FROM shares
WHERE id = @id::uuid
LIMIT 1
@ -146,6 +150,33 @@ final class PostgresShareRepository implements ShareRepository {
);
}
@override
Future<List<SyncedResource>> acceptShare({
required String recipientId,
required DateTime respondedAt,
required List<SyncedResource> resources,
}) {
return connection.runTx((session) async {
final written = <SyncedResource>[];
for (final resource in resources) {
written.add(await _upsertResource(session, resource));
}
await session.execute(
Sql.named('''
UPDATE share_recipients
SET status = @status, responded_at = @responded_at
WHERE id = @id::uuid
'''),
parameters: {
'id': recipientId,
'status': ShareRecipientStatus.accepted.wireName,
'responded_at': respondedAt.toUtc(),
},
);
return written;
});
}
@override
Future<void> revokeShare({
required String shareId,
@ -173,9 +204,11 @@ 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']),
kind: ShareKind.parse(
_stringValue(values['share_kind'] ?? ShareKind.single.wireName),
),
packName: values['pack_name'] as String?,
resourceType: _nullableResourceType(values['resource_type']),
payloadJson: _payloadValue(values['payload_json']),
createdAt: _dateTimeValue(values['created_at']),
revokedAt: _nullableDateTimeValue(values['revoked_at']),
@ -192,6 +225,90 @@ ShareRecipient _recipientFromValues(Map<String, Object?> values) {
);
}
Future<SyncedResource> _upsertResource(
Session session,
SyncedResource resource,
) async {
final result = await session.execute(
Sql.named('''
WITH upserted AS (
INSERT INTO synced_resources (
server_id, owner_user_id, resource_type, client_id, payload_json,
schema_version, client_updated_at, deleted_at, origin_device_id
)
VALUES (
@server_id::uuid, @owner_user_id::uuid, @resource_type, @client_id,
@payload_json::jsonb, @schema_version, @client_updated_at,
@deleted_at, @origin_device_id
)
ON CONFLICT (owner_user_id, resource_type, client_id)
DO UPDATE SET
payload_json = EXCLUDED.payload_json,
schema_version = EXCLUDED.schema_version,
client_updated_at = EXCLUDED.client_updated_at,
deleted_at = EXCLUDED.deleted_at,
origin_device_id = EXCLUDED.origin_device_id
WHERE synced_resources.client_updated_at < EXCLUDED.client_updated_at
RETURNING
server_id, owner_user_id, resource_type, client_id, payload_json,
schema_version, client_updated_at, server_updated_at, deleted_at,
origin_device_id
)
SELECT * FROM upserted
UNION ALL
SELECT
existing.server_id, existing.owner_user_id, existing.resource_type,
existing.client_id, existing.payload_json, existing.schema_version,
existing.client_updated_at, existing.server_updated_at,
existing.deleted_at, existing.origin_device_id
FROM synced_resources existing
WHERE existing.owner_user_id = @owner_user_id::uuid
AND existing.resource_type = @resource_type
AND existing.client_id = @client_id
AND NOT EXISTS (SELECT 1 FROM upserted)
LIMIT 1
'''),
parameters: {
'server_id': resource.serverId,
'owner_user_id': resource.ownerUserId,
'resource_type': resource.resourceType.wireName,
'client_id': resource.clientId,
'payload_json': jsonEncode(resource.payloadJson),
'schema_version': resource.schemaVersion,
'client_updated_at': resource.clientUpdatedAt,
'deleted_at': resource.deletedAt,
'origin_device_id': resource.originDeviceId,
},
);
return _resourceFromValues(
result.single.toColumnMap() as Map<String, Object?>,
);
}
SyncedResource _resourceFromValues(Map<String, Object?> values) {
return SyncedResource(
serverId: _stringValue(values['server_id']),
ownerUserId: _stringValue(values['owner_user_id']),
resourceType: SyncedResourceType.parse(
_stringValue(values['resource_type']),
),
clientId: _stringValue(values['client_id']),
payloadJson: _payloadValue(values['payload_json']),
schemaVersion: values['schema_version'] as int,
clientUpdatedAt: _dateTimeValue(values['client_updated_at']),
serverUpdatedAt: _dateTimeValue(values['server_updated_at']),
deletedAt: _nullableDateTimeValue(values['deleted_at']),
originDeviceId: values['origin_device_id'] as String?,
);
}
SyncedResourceType? _nullableResourceType(Object? value) {
if (value == null) {
return null;
}
return SyncedResourceType.parse(_stringValue(value));
}
Map<String, Object?> _payloadValue(Object? value) {
if (value is Map<String, Object?>) {
return value;