// lib/services/content/comment_service.dart // // سرویس کامنت برای endpoint های جدید (content-service): // - GET /comments?filter.contentId=$eq: فهرستِ کامنت‌های محتوا // - POST /comments ایجاد کامنت (با body, contentId, replyTo?) // - DELETE /comments/:id حذف // - PATCH /comments/:id ویرایش import 'dart:async'; import 'dart:convert'; import 'dart:developer' as dev; import 'dart:io' show SocketException; // ignore: depend_on_referenced_packages import 'package:http/http.dart' as http; import 'package:didvan/config/auth_config.dart'; import 'package:didvan/services/auth/auth_interceptor.dart'; import 'package:didvan/services/auth/token_storage.dart'; class CommentDto { final String id; final String contentId; final String userId; final String body; final String? status; final String? replyToId; final DateTime? createdAt; final String? userFirstName; final String? userLastName; final String? userAvatar; final int likeCount; final int dislikeCount; final List likes; final List dislikes; const CommentDto({ required this.id, required this.contentId, required this.userId, required this.body, this.status, this.replyToId, this.createdAt, this.userFirstName, this.userLastName, this.userAvatar, this.likeCount = 0, this.dislikeCount = 0, this.likes = const [], this.dislikes = const [], }); factory CommentDto.fromJson(Map json) { DateTime? createdAt; final ca = json['createdAt']; if (ca is String && ca.isNotEmpty) createdAt = DateTime.tryParse(ca); String? replyToId; final rt = json['replyTo'] ?? json['replyToId'] ?? json['parentId']; if (rt is String) replyToId = rt; if (rt is Map) replyToId = rt['id']?.toString(); final user = json['user']; String? fn, ln, av; if (user is Map) { fn = user['firstName']?.toString(); ln = user['lastName']?.toString(); av = user['avatar']?.toString(); } final likesRaw = json['likes']; final dislikesRaw = json['dislikes']; final likes = likesRaw is List ? likesRaw.map((e) => e.toString()).toList() : []; final dislikes = dislikesRaw is List ? dislikesRaw.map((e) => e.toString()).toList() : []; return CommentDto( id: json['id']?.toString() ?? '', contentId: json['contentId']?.toString() ?? '', userId: json['userId']?.toString() ?? '', body: json['body']?.toString() ?? '', status: json['status']?.toString(), replyToId: replyToId, createdAt: createdAt, userFirstName: fn, userLastName: ln, userAvatar: av, likeCount: (json['likeCount'] as num?)?.toInt() ?? likes.length, dislikeCount: (json['dislikeCount'] as num?)?.toInt() ?? dislikes.length, likes: likes, dislikes: dislikes, ); } } class CommentServiceResult { final bool isSuccess; final int? statusCode; final T? data; final String? errorMessage; const CommentServiceResult({ required this.isSuccess, this.statusCode, this.data, this.errorMessage, }); } class CommentService { CommentService._(); static final CommentService instance = CommentService._(); String get _base => AuthConfig.apiBaseUrl; static const _timeout = Duration(seconds: 15); Map _headers() { final headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; final t = TokenStorage.accessToken; if (t != null && t.isNotEmpty) headers['Authorization'] = 'Bearer $t'; return headers; } /// فهرست کامنت‌های یک محتوا. Future>> listByContent( String contentUuid, { int page = 1, int limit = 50, }) async { await AuthInterceptor.ensureFreshToken(); try { final params = { 'page': page.toString(), 'limit': limit.toString(), 'sortBy': 'createdAt:DESC', 'filter.contentId': '\$eq:$contentUuid', // cache-bust چون CacheInterceptor سمت سرور احتمالاً فعال است 't': DateTime.now().millisecondsSinceEpoch.toString(), }; final uri = Uri.parse('$_base/comments').replace(queryParameters: params); final response = await _withRetry( () => http.get(uri, headers: _headers()).timeout(_timeout), ); if (response.statusCode == 200) { final body = json.decode(response.body); final dataRaw = body is Map ? body['data'] : null; final list = dataRaw is List ? dataRaw .whereType() .map((e) => CommentDto.fromJson(Map.from(e))) .toList() : []; return CommentServiceResult( isSuccess: true, statusCode: 200, data: list, ); } return CommentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در دریافت کامنت‌ها.', ); } catch (e) { dev.log('listByContent error: $e', name: 'CommentService'); return const CommentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } /// ایجاد کامنت برای محتوای [contentUuid]. در صورت پاسخ‌گویی به کامنت /// دیگری، [replyTo] را uuid آن کامنت بفرستید. Future> create({ required String contentUuid, required String body, String? replyTo, }) async { await AuthInterceptor.ensureFreshToken(); try { // backend از token sub برای userId استفاده می‌کند، ولی DTO آن را اجبار // می‌کند؛ از TokenStorage.userId می‌گذاریم. final userId = TokenStorage.userId ?? ''; final payload = { 'contentId': contentUuid, 'body': body, 'userId': userId, if (replyTo != null && replyTo.isNotEmpty) 'replyTo': replyTo, }; final response = await _withRetry(() => http .post( Uri.parse('$_base/comments'), headers: _headers(), body: json.encode(payload), ) .timeout(_timeout)); if (response.statusCode == 200 || response.statusCode == 201) { final b = json.decode(response.body); if (b is Map) { return CommentServiceResult( isSuccess: true, statusCode: response.statusCode, data: CommentDto.fromJson(b), ); } return const CommentServiceResult(isSuccess: true); } return CommentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در ثبت کامنت.', ); } catch (e) { dev.log('create error: $e', name: 'CommentService'); return const CommentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } Future> toggleReaction({ required String commentId, required String type, // 'like' or 'dislike' }) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _withRetry(() => http .post( Uri.parse('$_base/comments/$commentId/$type'), headers: _headers(), ) .timeout(_timeout)); if (response.statusCode >= 200 && response.statusCode < 300) { return const CommentServiceResult(isSuccess: true); } return CommentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در ثبت واکنش.', ); } catch (e) { dev.log('toggleReaction error: $e', name: 'CommentService'); return const CommentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } Future> delete(String id) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _withRetry(() => http .delete(Uri.parse('$_base/comments/$id'), headers: _headers()) .timeout(_timeout)); if (response.statusCode >= 200 && response.statusCode < 300) { return const CommentServiceResult(isSuccess: true); } return CommentServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _extractError(response.body) ?? 'خطا در حذف کامنت.', ); } catch (e) { dev.log('delete error: $e', name: 'CommentService'); return const CommentServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.', ); } } Future _withRetry(Future Function() body) async { for (var attempt = 0; attempt < 3; attempt++) { try { return await body(); } on SocketException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on http.ClientException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on TimeoutException { if (attempt == 2) rethrow; } } throw StateError('retry loop exited without return'); } String? _extractError(String body) { if (body.isEmpty) return null; try { final data = json.decode(body); if (data is Map) { final msg = data['message'] ?? data['error']; if (msg is String && msg.isNotEmpty) return msg; if (msg is List && msg.isNotEmpty) return msg.first.toString(); } } catch (_) {} return null; } }