didvan-app/lib/services/content/comment_service.dart

317 lines
10 KiB
Dart

// lib/services/content/comment_service.dart
//
// سرویس کامنت برای endpoint های جدید (content-service):
// - GET /comments?filter.contentId=$eq:<uuid> فهرستِ کامنت‌های محتوا
// - 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<String> likes;
final List<String> 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<String, dynamic> 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()
: <String>[];
final dislikes = dislikesRaw is List
? dislikesRaw.map((e) => e.toString()).toList()
: <String>[];
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<T> {
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<String, String> _headers() {
final headers = <String, String>{
'Accept': 'application/json',
'Content-Type': 'application/json',
};
final t = TokenStorage.accessToken;
if (t != null && t.isNotEmpty) headers['Authorization'] = 'Bearer $t';
return headers;
}
/// فهرست کامنت‌های یک محتوا.
Future<CommentServiceResult<List<CommentDto>>> listByContent(
String contentUuid, {
int page = 1,
int limit = 50,
}) async {
await AuthInterceptor.ensureFreshToken();
try {
final params = <String, String>{
'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>()
.map((e) => CommentDto.fromJson(Map<String, dynamic>.from(e)))
.toList()
: <CommentDto>[];
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<CommentServiceResult<CommentDto>> 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 = <String, dynamic>{
'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<String, dynamic>) {
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<CommentServiceResult<void>> 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<CommentServiceResult<void>> 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<T> _withRetry<T>(Future<T> 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;
}
}