317 lines
10 KiB
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;
|
|
}
|
|
}
|