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:
2026-07-19 10:34:09 +02:00
parent c232bdcec1
commit db6e84be06
15 changed files with 1531 additions and 13 deletions

View File

@ -104,18 +104,34 @@ server clock if no resource is returned.
`pushResults` included so per-item validation errors remain visible to the `pushResults` included so per-item validation errors remain visible to the
client. client.
## Sharing
Ticket #51 adds authenticated targeted sharing for programs and workout
templates.
Endpoints:
- `POST /shares` with `Authorization: Bearer <token>`.
- `GET /shares/inbox` with `Authorization: Bearer <token>`.
- `POST /shares/{id}/accept` with `Authorization: Bearer <token>`.
- `POST /shares/{id}/decline` with `Authorization: Bearer <token>`.
- `POST /shares/{id}/revoke` with `Authorization: Bearer <token>`.
Creating a share stores a snapshot payload and creates pending recipient rows
for known recipient emails. Unknown emails are reported as `unresolvedEmails`
without failing the whole request. Accepting a share creates a new
`synced_resources` copy owned by the recipient with a server-generated
`clientId`; the sender's original resource is never modified. Accept returns
`409` for revoked or already answered shares, while missing shares or recipients
return `404`.
## Scope ## Scope
Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and
the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48 the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48
adds authentication. Ticket #50 adds sync only; sharing behavior is still adds authentication. Ticket #50 adds sync. Ticket #51 adds targeted sharing.
implemented in a later ticket.
Upcoming tickets will fill the empty adapters and use cases: Upcoming tickets will fill the empty adapters and use cases:
- #48: user authentication and API tokens.
- #49: PostgreSQL schema and repositories.
- #50: incremental sync API.
- #51: targeted sharing.
- #52: Docker and registry packaging. - #52: Docker and registry packaging.
- #53: API, contract and integration tests. - #53: API, contract and integration tests.

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:gametime_server/api/auth_api.dart'; import 'package:gametime_server/api/auth_api.dart';
import 'package:gametime_server/api/router.dart'; import 'package:gametime_server/api/router.dart';
import 'package:gametime_server/api/share_api.dart';
import 'package:gametime_server/api/sync_api.dart'; import 'package:gametime_server/api/sync_api.dart';
import 'package:gametime_server/application/application.dart'; import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/infrastructure/postgres/postgres.dart'; import 'package:gametime_server/infrastructure/postgres/postgres.dart';
@ -17,6 +18,7 @@ Future<void> main(List<String> arguments) async {
final users = PostgresUserRepository(connection); final users = PostgresUserRepository(connection);
final sessions = PostgresAuthSessionRepository(connection); final sessions = PostgresAuthSessionRepository(connection);
final resources = PostgresSyncedResourceRepository(connection); final resources = PostgresSyncedResourceRepository(connection);
final shares = PostgresShareRepository(connection);
final passwordHasher = Pbkdf2PasswordHasher(); final passwordHasher = Pbkdf2PasswordHasher();
final tokens = SecureOpaqueTokenService(); final tokens = SecureOpaqueTokenService();
const clock = SystemClock(); const clock = SystemClock();
@ -57,9 +59,27 @@ Future<void> main(List<String> arguments) async {
exchangeSync: ExchangeSyncUseCase(push: pushSync, pull: pullSync), exchangeSync: ExchangeSyncUseCase(push: pushSync, pull: pullSync),
authenticateRequest: authenticateRequest, authenticateRequest: authenticateRequest,
); );
final shareApi = ShareApi(
createShare: CreateShareUseCase(
users: users,
shares: shares,
clock: clock,
ids: ids,
),
listInbox: ListInboxUseCase(shares: shares),
acceptShare: AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
),
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
authenticateRequest: authenticateRequest,
);
final server = await shelf_io.serve( final server = await shelf_io.serve(
buildApiHandler(authApi: authApi, syncApi: syncApi), buildApiHandler(authApi: authApi, syncApi: syncApi, shareApi: shareApi),
InternetAddress.anyIPv4, InternetAddress.anyIPv4,
port, port,
); );

View File

@ -4,9 +4,14 @@ import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
import 'auth_api.dart'; import 'auth_api.dart';
import 'share_api.dart';
import 'sync_api.dart'; import 'sync_api.dart';
Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) { Handler buildApiHandler({
AuthApi? authApi,
SyncApi? syncApi,
ShareApi? shareApi,
}) {
final router = Router() final router = Router()
..get('/health', (Request request) { ..get('/health', (Request request) {
return Response.ok( 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); return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
} }

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

View File

@ -2,5 +2,5 @@
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL. Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
Authentication and sync use cases live here and depend only on repository / Authentication, sync and targeted sharing use cases live here and depend only on
security ports. Concrete adapters are wired from `bin/server.dart`. repository / security ports. Concrete adapters are wired from `bin/server.dart`.

View File

@ -1,3 +1,4 @@
export 'ports.dart'; export 'ports.dart';
export 'share_use_cases.dart';
export 'sync_use_cases.dart'; export 'sync_use_cases.dart';
export 'use_cases.dart'; export 'use_cases.dart';

View File

@ -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 { abstract interface class PasswordHasher {
Future<String> hash(String password); Future<String> hash(String password);

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

View File

@ -5,4 +5,5 @@ Pure server domain entities and invariants.
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
other infrastructure concerns. other infrastructure concerns.
Current entities: `UserAccount`, `AuthSession` and `SyncedResource`. Current entities: `UserAccount`, `AuthSession`, `SyncedResource`, `Share` and
`ShareRecipient`.

View File

@ -23,6 +23,14 @@ final class UnauthorizedException extends DomainException {
const UnauthorizedException() : super('Unauthorized.'); 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 { final class UserAccount {
UserAccount({ UserAccount({
required String id, required String id,
@ -145,6 +153,76 @@ final class SyncedResource {
final String? originDeviceId; 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) { String _nonBlank(String value, String label) {
final trimmed = value.trim(); final trimmed = value.trim();
if (trimmed.isEmpty) { if (trimmed.isEmpty) {

View File

@ -2,5 +2,5 @@
Connection and migration utilities for server-side PostgreSQL. Connection and migration utilities for server-side PostgreSQL.
Current adapters cover auth users/sessions and the generic `synced_resources` Current adapters cover auth users/sessions, the generic `synced_resources` sync
sync table. table, and targeted sharing through `shares` / `share_recipients`.

View File

@ -1,3 +1,4 @@
export 'auth_repositories.dart'; export 'auth_repositories.dart';
export 'postgres_database.dart'; export 'postgres_database.dart';
export 'share_repository.dart';
export 'synced_resource_repository.dart'; export 'synced_resource_repository.dart';

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

View File

@ -0,0 +1,205 @@
import 'package:gametime_server/api/router.dart';
import 'package:gametime_server/api/share_api.dart';
import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:shelf/shelf.dart';
import 'package:test/test.dart';
void main() {
test('share routes require bearer authentication', () async {
final handler = buildApiHandler(shareApi: _shareApi());
final requests = [
Request('POST', Uri.parse('http://localhost/shares')),
Request('GET', Uri.parse('http://localhost/shares/inbox')),
Request('POST', Uri.parse('http://localhost/shares/share-1/accept')),
Request('POST', Uri.parse('http://localhost/shares/share-1/decline')),
Request('POST', Uri.parse('http://localhost/shares/share-1/revoke')),
];
for (final request in requests) {
final response = await handler(request);
expect(response.statusCode, 401, reason: request.url.path);
}
});
}
ShareApi _shareApi() {
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
final ids = _FakeIds();
final users = _FakeUserRepository(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hash',
createdAt: clock.now(),
updatedAt: clock.now(),
),
);
final sessions = _FakeAuthSessionRepository(
AuthSession(
id: 'session-1',
userId: 'user-1',
tokenHash: 'token-hash:valid-token',
issuedAt: clock.now(),
expiresAt: clock.now().add(const Duration(days: 1)),
),
);
final tokens = _FakeTokenService();
final authenticate = AuthenticateRequestUseCase(
users: users,
sessions: sessions,
tokens: tokens,
clock: clock,
);
final shares = _FakeShareRepository();
final resources = _FakeSyncedResourceRepository();
return ShareApi(
createShare: CreateShareUseCase(
users: users,
shares: shares,
clock: clock,
ids: ids,
),
listInbox: ListInboxUseCase(shares: shares),
acceptShare: AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
),
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
authenticateRequest: authenticate,
);
}
final class _FakeUserRepository implements UserRepository {
const _FakeUserRepository(this.user);
final UserAccount user;
@override
Future<UserAccount?> findByEmail(String email) async {
return user.email == email.toLowerCase() ? user : null;
}
@override
Future<UserAccount?> findById(String id) async {
return id == user.id ? user : null;
}
@override
Future<void> insert(UserAccount user) async {}
@override
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
}) async {}
}
final class _FakeAuthSessionRepository implements AuthSessionRepository {
const _FakeAuthSessionRepository(this.session);
final AuthSession session;
@override
Future<AuthSession?> findByTokenHash(String tokenHash) async {
return tokenHash == session.tokenHash ? session : null;
}
@override
Future<void> insert(AuthSession session) async {}
@override
Future<void> revoke({
required String sessionId,
required DateTime revokedAt,
}) async {}
}
final class _FakeShareRepository implements ShareRepository {
@override
Future<void> insertShare({
required Share share,
required List<ShareRecipient> recipients,
}) async {}
@override
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
return const [];
}
@override
Future<Share?> findShareById(String shareId) async {
return null;
}
@override
Future<ShareRecipient?> findRecipient({
required String shareId,
required String recipientUserId,
}) async {
return null;
}
@override
Future<void> updateRecipientStatus({
required String recipientId,
required ShareRecipientStatus status,
required DateTime respondedAt,
}) async {}
@override
Future<void> revokeShare({
required String shareId,
required DateTime revokedAt,
}) async {}
}
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
@override
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
return SyncWriteResult(
status: SyncWriteStatus.accepted,
resource: resource,
);
}
@override
Future<List<SyncedResource>> findAllForUserSince({
required String ownerUserId,
DateTime? since,
}) async {
return const [];
}
}
final class _FakeTokenService implements OpaqueTokenService {
@override
String generateToken() => 'valid-token';
@override
String hashToken(String token) => 'token-hash:$token';
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}

View File

@ -0,0 +1,472 @@
import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:test/test.dart';
void main() {
late _FakeUserRepository users;
late _FakeShareRepository shares;
late _FakeSyncedResourceRepository resources;
late _FakeClock clock;
late _FakeIds ids;
setUp(() {
clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
ids = _FakeIds();
users = _FakeUserRepository()
..put(_user('sender', 'sender@example.com'))
..put(_user('recipient-1', 'friend@example.com'))
..put(_user('recipient-2', 'coach@example.com'));
shares = _FakeShareRepository();
resources = _FakeSyncedResourceRepository();
});
test('create share resolves known and unknown recipient emails', () async {
final useCase = CreateShareUseCase(
users: users,
shares: shares,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
senderUserId: 'sender',
resourceType: 'program',
payloadJson: {'name': 'Push day'},
recipientEmails: [
'friend@example.com',
'missing@example.com',
'coach@example.com',
],
);
expect(result.share.senderUserId, 'sender');
expect(result.recipientUserIds, ['recipient-1', 'recipient-2']);
expect(result.unresolvedEmails, ['missing@example.com']);
expect(shares.recipients.map((recipient) => recipient.recipientUserId), [
'recipient-1',
'recipient-2',
]);
});
test(
'accept creates a copied synced resource without touching source',
() async {
final share = _share(
id: 'share-1',
senderUserId: 'sender',
resourceType: SyncedResourceType.workoutTemplate,
payloadJson: {'schemaVersion': 3, 'name': 'Full body'},
);
shares.putShare(share);
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final source = _resource(
serverId: 'source-server',
ownerUserId: 'sender',
resourceType: SyncedResourceType.workoutTemplate,
clientId: 'source-client',
payloadJson: {'name': 'Source'},
);
resources.put(source);
final useCase = AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
shareId: 'share-1',
recipientUserId: 'recipient-1',
);
expect(result.createdResource.ownerUserId, 'recipient-1');
expect(result.createdResource.clientId, isNot('source-client'));
expect(result.createdResource.payloadJson, share.payloadJson);
expect(result.createdResource.schemaVersion, 3);
expect(resources.getByServerId('source-server'), same(source));
expect(
shares.recipient('share-1', 'recipient-1')?.status,
ShareRecipientStatus.accepted,
);
},
);
test('accept on already answered share fails', () async {
shares.putShare(_share(id: 'share-1'));
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
status: ShareRecipientStatus.declined,
respondedAt: clock.now(),
),
);
final useCase = AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
);
expect(
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
throwsA(isA<ShareConflictException>()),
);
});
test('accept on revoked share fails', () async {
shares.putShare(_share(id: 'share-1', revokedAt: clock.now()));
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final useCase = AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
);
expect(
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
throwsA(isA<ShareConflictException>()),
);
});
test('decline marks a pending recipient as declined', () async {
shares.putShare(_share(id: 'share-1'));
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final useCase = DeclineShareUseCase(shares: shares, clock: clock);
await useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1');
final recipient = shares.recipient('share-1', 'recipient-1');
expect(recipient?.status, ShareRecipientStatus.declined);
expect(recipient?.respondedAt, clock.now());
});
test('revoke by sender marks the share revoked', () async {
shares.putShare(_share(id: 'share-1', senderUserId: 'sender'));
final useCase = RevokeShareUseCase(shares: shares, clock: clock);
await useCase.execute(shareId: 'share-1', senderUserId: 'sender');
expect(shares.findShareById('share-1'), completion(isNotNull));
expect(shares.shares.single.revokedAt, clock.now());
});
test('revoke by a non-sender is refused', () async {
shares.putShare(_share(id: 'share-1', senderUserId: 'sender'));
final useCase = RevokeShareUseCase(shares: shares, clock: clock);
expect(
() => useCase.execute(shareId: 'share-1', senderUserId: 'recipient-1'),
throwsA(isA<ShareNotFoundException>()),
);
});
test('inbox is sorted from newest to oldest', () async {
shares.putShare(
_share(id: 'older', createdAt: DateTime.utc(2026, 7, 19, 10)),
);
shares.putShare(
_share(id: 'newer', createdAt: DateTime.utc(2026, 7, 19, 11)),
);
shares
..putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'older',
recipientUserId: 'recipient-1',
),
)
..putRecipient(
ShareRecipient(
id: 'recipient-row-2',
shareId: 'newer',
recipientUserId: 'recipient-1',
),
);
final useCase = ListInboxUseCase(shares: shares);
final result = await useCase.execute(recipientUserId: 'recipient-1');
expect(result.map((item) => item.share.id), ['newer', 'older']);
});
}
UserAccount _user(String id, String email) {
return UserAccount(
id: id,
email: email,
passwordHash: 'hash',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
);
}
Share _share({
required String id,
String senderUserId = 'sender',
SyncedResourceType resourceType = SyncedResourceType.program,
Map<String, Object?> payloadJson = const {'schemaVersion': 1},
DateTime? createdAt,
DateTime? revokedAt,
}) {
return Share(
id: id,
senderUserId: senderUserId,
resourceType: resourceType,
payloadJson: payloadJson,
createdAt: createdAt ?? DateTime.utc(2026, 7, 19, 12),
revokedAt: revokedAt,
);
}
SyncedResource _resource({
required String serverId,
required String ownerUserId,
required SyncedResourceType resourceType,
required String clientId,
required Map<String, Object?> payloadJson,
}) {
return SyncedResource(
serverId: serverId,
ownerUserId: ownerUserId,
resourceType: resourceType,
clientId: clientId,
payloadJson: payloadJson,
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 10),
);
}
final class _FakeUserRepository implements UserRepository {
final _usersByEmail = <String, UserAccount>{};
void put(UserAccount user) {
_usersByEmail[user.email] = user;
}
@override
Future<UserAccount?> findByEmail(String email) async {
return _usersByEmail[email.toLowerCase()];
}
@override
Future<UserAccount?> findById(String id) async {
return _usersByEmail.values.where((user) => user.id == id).firstOrNull;
}
@override
Future<void> insert(UserAccount user) async {
put(user);
}
@override
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
}) async {}
}
final class _FakeShareRepository implements ShareRepository {
final shares = <Share>[];
final recipients = <ShareRecipient>[];
void putShare(Share share) {
shares.removeWhere((item) => item.id == share.id);
shares.add(share);
}
void putRecipient(ShareRecipient recipient) {
recipients.removeWhere(
(item) =>
item.shareId == recipient.shareId &&
item.recipientUserId == recipient.recipientUserId,
);
recipients.add(recipient);
}
ShareRecipient? recipient(String shareId, String recipientUserId) {
return recipients
.where(
(item) =>
item.shareId == shareId &&
item.recipientUserId == recipientUserId,
)
.firstOrNull;
}
@override
Future<void> insertShare({
required Share share,
required List<ShareRecipient> recipients,
}) async {
putShare(share);
for (final recipient in recipients) {
putRecipient(recipient);
}
}
@override
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
final items =
[
for (final recipient in recipients)
if (recipient.recipientUserId == recipientUserId)
ShareInboxItem(
share: shares.singleWhere(
(share) => share.id == recipient.shareId,
),
recipient: recipient,
),
]..sort(
(left, right) =>
right.share.createdAt.compareTo(left.share.createdAt),
);
return items;
}
@override
Future<Share?> findShareById(String shareId) async {
return shares.where((share) => share.id == shareId).firstOrNull;
}
@override
Future<ShareRecipient?> findRecipient({
required String shareId,
required String recipientUserId,
}) async {
return recipient(shareId, recipientUserId);
}
@override
Future<void> updateRecipientStatus({
required String recipientId,
required ShareRecipientStatus status,
required DateTime respondedAt,
}) async {
final index = recipients.indexWhere((item) => item.id == recipientId);
final recipient = recipients[index];
recipients[index] = ShareRecipient(
id: recipient.id,
shareId: recipient.shareId,
recipientUserId: recipient.recipientUserId,
status: status,
respondedAt: respondedAt,
);
}
@override
Future<void> revokeShare({
required String shareId,
required DateTime revokedAt,
}) async {
final index = shares.indexWhere((share) => share.id == shareId);
final share = shares[index];
shares[index] = Share(
id: share.id,
senderUserId: share.senderUserId,
resourceType: share.resourceType,
payloadJson: share.payloadJson,
createdAt: share.createdAt,
revokedAt: revokedAt,
);
}
}
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
final _items = <String, SyncedResource>{};
void put(SyncedResource resource) {
_items[_key(
resource.ownerUserId,
resource.resourceType,
resource.clientId,
)] =
resource;
}
SyncedResource? getByServerId(String serverId) {
return _items.values.where((item) => item.serverId == serverId).firstOrNull;
}
@override
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
final key = _key(
resource.ownerUserId,
resource.resourceType,
resource.clientId,
);
final existing = _items[key];
if (existing != null &&
!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
return SyncWriteResult(
status: SyncWriteStatus.ignoredOlder,
resource: existing,
);
}
_items[key] = resource;
return SyncWriteResult(
status: SyncWriteStatus.accepted,
resource: resource,
);
}
@override
Future<List<SyncedResource>> findAllForUserSince({
required String ownerUserId,
DateTime? since,
}) async {
return _items.values
.where((item) => item.ownerUserId == ownerUserId)
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
.toList();
}
}
String _key(
String ownerUserId,
SyncedResourceType resourceType,
String clientId,
) {
return '$ownerUserId:${resourceType.wireName}:$clientId';
}
final class _FakeClock implements Clock {
_FakeClock(this.value);
DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}