# Flutter ↔ Viață la Țară API — Integration Guide

> **Audience:** the Claude CLI agent wiring the Flutter app to this backend.
> **Goal:** replace the app's in-memory/mock data layer with real HTTP calls to this API.
> Everything here is copy-paste ready. Follow the **Integration Checklist** at the bottom in order.

---

## 0. TL;DR for the integrating agent

1. Add deps (§2), drop the **plumbing files** (§3) into `lib/core/network/`.
2. Drop the **models** (§4) into `lib/core/models/`.
3. Drop the **services** (§6) into `lib/core/services/` (one per feature).
4. Point your existing repositories/providers at these services instead of the in-memory store.
5. Set `ApiConfig.baseUrl` per environment (§1).
6. Wire the **auth token lifecycle** (§5): store tokens on login/register, clear on logout, auto-refresh is already handled by the interceptor.
7. Optional realtime chat: wire the STOMP client (§7).

**Do not** invent field names — every model below matches the server's JSON exactly (verified against the running API).

---

## 1. Base configuration

| Env | Base URL |
|-----|----------|
| Local (emulator → host) — Android | `http://10.0.2.2:3838` |
| Local — iOS simulator / desktop / web | `http://localhost:3838` |
| Local — physical device | `http://<your-LAN-ip>:3838` |
| Production | `https://<your-domain>` |

- **All REST paths are prefixed with `/api`** (except `GET /health`).
- The server enables **CORS with an allowlist** (no `*`). For Flutter Web, add your web origin (e.g. `http://localhost:PORT`) to the backend `CORS_ORIGINS` env. Mobile/desktop are unaffected by CORS.
- No trailing slashes on paths.

```dart
// lib/core/network/api_config.dart
class ApiConfig {
  // Override per build flavor / --dart-define if you like.
  static const String baseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'http://localhost:3838',
  );
  static const String apiPrefix = '/api';
}
```

Build with a custom URL: `flutter run --dart-define=API_BASE_URL=http://10.0.2.2:3838`

---

## 2. Dependencies

Add to `pubspec.yaml` (versions are minimums; use latest compatible):

```yaml
dependencies:
  dio: ^5.4.0                    # HTTP client
  flutter_secure_storage: ^9.0.0 # token storage
  web_socket_channel: ^3.0.0     # only if doing realtime chat (§7)
```

Then `flutter pub get`.

---

## 3. Network plumbing (drop-in)

### The response envelope — the single most important rule

**Every** response body is:

```jsonc
// success
{ "data": <payload>, "error": null }
// failure
{ "data": null, "error": { "code": "STRING_CODE", "message": "...", "details": [ { "field": "body.email", "message": "Invalid email" } ] } }
```

- `details` only present on `VALIDATION_ERROR` (HTTP 422).
- Paginated payloads are `{ "items": [...], "total": N, "page": N, "size": N }`.
- Some endpoints return a **plain array** in `data` (categories, cart, chat threads, my-locations) — noted per-endpoint in §8.

### 3.1 `api_exception.dart`

```dart
// lib/core/network/api_exception.dart
class FieldError {
  final String field;
  final String message;
  FieldError(this.field, this.message);
  factory FieldError.fromJson(Map<String, dynamic> j) =>
      FieldError(j['field'] as String, j['message'] as String);
}

class ApiException implements Exception {
  final int statusCode;
  final String code;      // e.g. VALIDATION_ERROR, UNAUTHENTICATED, NOT_FOUND
  final String message;
  final List<FieldError> details;

  ApiException({
    required this.statusCode,
    required this.code,
    required this.message,
    this.details = const [],
  });

  bool get isValidation => code == 'VALIDATION_ERROR';
  bool get isAuth => code == 'UNAUTHENTICATED';
  bool get isForbidden => code == 'FORBIDDEN';
  bool get isNotFound => code == 'NOT_FOUND';
  bool get isConflict => code == 'CONFLICT';

  /// First validation message for a given field (strip the `body.`/`query.` prefix when matching).
  String? fieldMessage(String field) {
    for (final d in details) {
      if (d.field == field || d.field.endsWith('.$field')) return d.message;
    }
    return null;
  }

  @override
  String toString() => 'ApiException($statusCode $code): $message';
}
```

**Error codes the server can return:** `BAD_REQUEST` (400), `VALIDATION_ERROR` (422),
`UNAUTHENTICATED` (401), `FORBIDDEN` (403), `NOT_FOUND` (404), `CONFLICT` (409),
`UNPROCESSABLE` (422), `INTERNAL_ERROR` (500).

### 3.2 `token_store.dart`

```dart
// lib/core/network/token_store.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class TokenStore {
  static const _kAccess = 'vt_access_token';
  static const _kRefresh = 'vt_refresh_token';
  final _storage = const FlutterSecureStorage();

  String? _accessCache; // in-memory mirror so the interceptor is sync-fast

  Future<void> save({required String accessToken, required String refreshToken}) async {
    _accessCache = accessToken;
    await _storage.write(key: _kAccess, value: accessToken);
    await _storage.write(key: _kRefresh, value: refreshToken);
  }

  Future<void> saveAccess(String accessToken) async {
    _accessCache = accessToken;
    await _storage.write(key: _kAccess, value: accessToken);
  }

  Future<String?> get accessToken async => _accessCache ??= await _storage.read(key: _kAccess);
  Future<String?> get refreshToken async => _storage.read(key: _kRefresh);
  Future<bool> get hasSession async => (await refreshToken) != null;

  Future<void> clear() async {
    _accessCache = null;
    await _storage.delete(key: _kAccess);
    await _storage.delete(key: _kRefresh);
  }
}
```

### 3.3 `api_client.dart` — Dio + auth header + auto-refresh + envelope unwrap

```dart
// lib/core/network/api_client.dart
import 'package:dio/dio.dart';
import 'api_config.dart';
import 'api_exception.dart';
import 'token_store.dart';

/// Called when refresh fails / session is dead. Route the user to login here.
typedef OnSessionExpired = void Function();

class ApiClient {
  final Dio _dio;
  final TokenStore tokens;
  final OnSessionExpired? onSessionExpired;

  ApiClient({required this.tokens, this.onSessionExpired})
      : _dio = Dio(BaseOptions(
          baseUrl: '${ApiConfig.baseUrl}${ApiConfig.apiPrefix}',
          connectTimeout: const Duration(seconds: 15),
          receiveTimeout: const Duration(seconds: 20),
          headers: {'Content-Type': 'application/json'},
          // We handle non-2xx ourselves so we can read the envelope.
          validateStatus: (s) => s != null && s < 500,
        )) {
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) async {
        if (options.extra['skipAuth'] != true) {
          final t = await tokens.accessToken;
          if (t != null) options.headers['Authorization'] = 'Bearer $t';
        }
        handler.next(options);
      },
    ));
  }

  bool _refreshing = false;

  // ---- Public verbs. Each returns the unwrapped `data` payload. ----
  Future<dynamic> get(String path, {Map<String, dynamic>? query}) =>
      _send(() => _dio.get(path, queryParameters: _clean(query)));

  Future<dynamic> post(String path, {Object? body, bool auth = true}) =>
      _send(() => _dio.post(path, data: body, options: Options(extra: {'skipAuth': !auth})));

  Future<dynamic> put(String path, {Object? body}) =>
      _send(() => _dio.put(path, data: body));

  Future<dynamic> patch(String path, {Object? body}) =>
      _send(() => _dio.patch(path, data: body));

  Future<dynamic> delete(String path, {Object? body}) =>
      _send(() => _dio.delete(path, data: body));

  // ---- Core: run request, handle 401→refresh→retry, unwrap envelope ----
  Future<dynamic> _send(Future<Response> Function() run, {bool isRetry = false}) async {
    final Response res;
    try {
      res = await run();
    } on DioException catch (e) {
      throw ApiException(
        statusCode: e.response?.statusCode ?? 0,
        code: 'NETWORK_ERROR',
        message: e.message ?? 'Network error',
      );
    }

    final status = res.statusCode ?? 0;
    final body = res.data;

    if (status >= 200 && status < 300) {
      if (body == null || body is! Map) return null; // e.g. 204 logout
      return body['data'];
    }

    // Attempt one transparent refresh on auth failure.
    final code = (body is Map && body['error'] is Map) ? body['error']['code'] : null;
    if (status == 401 && code == 'UNAUTHENTICATED' && !isRetry) {
      final ok = await _tryRefresh();
      if (ok) return _send(run, isRetry: true);
      onSessionExpired?.call();
    }

    _throwFromEnvelope(status, body);
  }

  Never _throwFromEnvelope(int status, dynamic body) {
    final err = (body is Map) ? body['error'] : null;
    final details = <FieldError>[];
    if (err is Map && err['details'] is List) {
      for (final d in err['details']) {
        details.add(FieldError.fromJson(Map<String, dynamic>.from(d)));
      }
    }
    throw ApiException(
      statusCode: status,
      code: (err is Map ? err['code'] : null) ?? 'INTERNAL_ERROR',
      message: (err is Map ? err['message'] : null) ?? 'Request failed',
      details: details,
    );
  }

  Future<bool> _tryRefresh() async {
    if (_refreshing) return false;
    _refreshing = true;
    try {
      final rt = await tokens.refreshToken;
      if (rt == null) return false;
      final res = await _dio.post('/auth/refresh',
          data: {'refreshToken': rt}, options: Options(extra: {'skipAuth': true}));
      if (res.statusCode == 200 && res.data is Map && res.data['data'] is Map) {
        final data = res.data['data'] as Map;
        await tokens.save(
          accessToken: data['accessToken'] as String,
          refreshToken: data['refreshToken'] as String,
        );
        return true;
      }
      return false;
    } catch (_) {
      return false;
    } finally {
      _refreshing = false;
    }
  }

  Map<String, dynamic>? _clean(Map<String, dynamic>? q) {
    if (q == null) return null;
    final m = <String, dynamic>{};
    q.forEach((k, v) { if (v != null) m[k] = v; });
    return m;
  }
}
```

### 3.4 `paginated.dart`

```dart
// lib/core/network/paginated.dart
class Paginated<T> {
  final List<T> items;
  final int total;
  final int page;
  final int size;
  Paginated({required this.items, required this.total, required this.page, required this.size});

  factory Paginated.fromJson(Map<String, dynamic> j, T Function(Map<String, dynamic>) fromItem) =>
      Paginated(
        items: (j['items'] as List).map((e) => fromItem(Map<String, dynamic>.from(e))).toList(),
        total: j['total'] as int,
        page: j['page'] as int,
        size: j['size'] as int,
      );

  bool get hasMore => page * size < total;
}
```

---

## 4. Data models

> Rules that hold for **every** model:
> - IDs are always `id` (String, a Mongo ObjectId hex). There is **no** `_id`.
> - Timestamps (`createdAt`, `updatedAt`, `lastMessageAt`) are **ISO-8601 UTC strings** → parse with `DateTime.parse(...)`.
> - Fields marked `?` below are **omitted entirely** when null (not sent as `null`). Use null-safe reads.
> - `categories`, `participants`, `photos`, `readBy` are arrays of String IDs.

```dart
// lib/core/models/user.dart
class UserModel {
  final String id, email, name, role;
  final String? phone, avatarUrl, provider;
  final bool emailVerified;
  final DateTime createdAt, updatedAt;

  UserModel({
    required this.id, required this.email, required this.name, required this.role,
    required this.emailVerified, required this.createdAt, required this.updatedAt,
    this.phone, this.avatarUrl, this.provider,
  });

  factory UserModel.fromJson(Map<String, dynamic> j) => UserModel(
        id: j['id'], email: j['email'], name: j['name'], role: j['role'],
        emailVerified: j['emailVerified'] ?? false,
        phone: j['phone'], avatarUrl: j['avatarUrl'], provider: j['provider'],
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']),
      );

  bool get isClient => role == 'client';
  bool get isBusiness => role == 'business';
  bool get isAdmin => role == 'admin';
}
```

```dart
// lib/core/models/category.dart
class CategoryModel {
  final String id, name, slug;
  final String? icon;
  final int order;
  CategoryModel({required this.id, required this.name, required this.slug, required this.order, this.icon});
  factory CategoryModel.fromJson(Map<String, dynamic> j) =>
      CategoryModel(id: j['id'], name: j['name'], slug: j['slug'], order: j['order'] ?? 0, icon: j['icon']);
}
```

```dart
// lib/core/models/product.dart
// unit ∈ {kg, g, l, ml, piece, bunch, box};  status ∈ {pending, approved, rejected, requestInfo}
class ProductModel {
  final String id, name, description, unit, categoryId, producerId, status;
  final double price, rating;
  final int stock, reviewCount;
  final List<String> photos;
  final bool available;
  final String? rejectionReason;
  final DateTime createdAt, updatedAt;

  ProductModel({
    required this.id, required this.name, required this.description, required this.price,
    required this.unit, required this.stock, required this.photos, required this.categoryId,
    required this.producerId, required this.status, required this.available, required this.rating,
    required this.reviewCount, required this.createdAt, required this.updatedAt, this.rejectionReason,
  });

  factory ProductModel.fromJson(Map<String, dynamic> j) => ProductModel(
        id: j['id'], name: j['name'], description: j['description'],
        price: (j['price'] as num).toDouble(), unit: j['unit'], stock: j['stock'] ?? 0,
        photos: List<String>.from(j['photos'] ?? const []),
        categoryId: j['categoryId'], producerId: j['producerId'], status: j['status'],
        available: j['available'] ?? true, rating: (j['rating'] as num?)?.toDouble() ?? 0,
        reviewCount: j['reviewCount'] ?? 0, rejectionReason: j['rejectionReason'],
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']),
      );
}
```

```dart
// lib/core/models/producer.dart
// status ∈ {pending, approved, rejected}
class ProducerModel {
  final String id, userId, businessName, description, address, status;
  final String? website, phone;
  final List<String> categories, photos;
  final bool verified;
  final double rating;
  final int reviewCount;
  final DateTime createdAt, updatedAt;

  ProducerModel({
    required this.id, required this.userId, required this.businessName, required this.description,
    required this.address, required this.status, required this.categories, required this.photos,
    required this.verified, required this.rating, required this.reviewCount,
    required this.createdAt, required this.updatedAt, this.website, this.phone,
  });

  factory ProducerModel.fromJson(Map<String, dynamic> j) => ProducerModel(
        id: j['id'], userId: j['userId'], businessName: j['businessName'], description: j['description'],
        address: j['address'], status: j['status'], website: j['website'], phone: j['phone'],
        categories: List<String>.from(j['categories'] ?? const []),
        photos: List<String>.from(j['photos'] ?? const []),
        verified: j['verified'] ?? false, rating: (j['rating'] as num?)?.toDouble() ?? 0,
        reviewCount: j['reviewCount'] ?? 0,
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']),
      );
}
```

```dart
// lib/core/models/booking_place.dart
// status ∈ {pending, approved, rejected}
// locality/county/latitude/longitude are OPTIONAL — omitted from the JSON when not set.
// latitude/longitude are sent/returned together as decimal degrees (lat −90..90, lng −180..180).
class BookingPlaceModel {
  final String id, userId, name, description, address, status;
  final String? locality, county;         // town/city + county (județ)
  final double? latitude, longitude;      // coordinates in decimal degrees
  final int capacity, reviewCount;
  final double pricePerNight, rating;
  final List<String> photos, amenities;
  final String? rejectionReason;
  final DateTime createdAt, updatedAt;

  BookingPlaceModel({
    required this.id, required this.userId, required this.name, required this.description,
    required this.address, required this.capacity, required this.pricePerNight, required this.photos,
    required this.amenities, required this.status, required this.rating, required this.reviewCount,
    required this.createdAt, required this.updatedAt, this.rejectionReason,
    this.locality, this.county, this.latitude, this.longitude,
  });

  factory BookingPlaceModel.fromJson(Map<String, dynamic> j) => BookingPlaceModel(
        id: j['id'], userId: j['userId'], name: j['name'], description: j['description'],
        address: j['address'], capacity: j['capacity'] ?? 0,
        pricePerNight: (j['pricePerNight'] as num).toDouble(),
        locality: j['locality'], county: j['county'],
        latitude: (j['latitude'] as num?)?.toDouble(),
        longitude: (j['longitude'] as num?)?.toDouble(),
        photos: List<String>.from(j['photos'] ?? const []),
        amenities: List<String>.from(j['amenities'] ?? const []),
        status: j['status'], rating: (j['rating'] as num?)?.toDouble() ?? 0,
        reviewCount: j['reviewCount'] ?? 0, rejectionReason: j['rejectionReason'],
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']),
      );
}
```

> **Edit-form prefill (my-locations):** `POST`/`PUT /api/my-locations` now accept
> `locality`, `county`, `latitude`, `longitude` (all optional) in the body, and every booking-place
> GET response (`/my-locations`, `/booking-places`, `/booking-places/:id`) returns them when set.
> Send `latitude`/`longitude` as numbers (not strings). Validation: `latitude` −90..90,
> `longitude` −180..180, `locality`/`county` 1–100 chars.

```dart
// lib/core/models/cart_item.dart
class CartItemModel {
  final String id, productId;
  final int quantity;
  final ProductModel? product; // embedded when the product still exists
  final DateTime createdAt, updatedAt;
  CartItemModel({required this.id, required this.productId, required this.quantity,
      required this.createdAt, required this.updatedAt, this.product});
  factory CartItemModel.fromJson(Map<String, dynamic> j) => CartItemModel(
        id: j['id'], productId: j['productId'], quantity: j['quantity'],
        product: j['product'] != null ? ProductModel.fromJson(Map<String, dynamic>.from(j['product'])) : null,
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']),
      );
}
```

```dart
// lib/core/models/favorite.dart
// targetType ∈ {product, producer, bookingPlace}
class FavoriteModel {
  final String id, userId, targetType, targetId;
  final DateTime createdAt;
  FavoriteModel({required this.id, required this.userId, required this.targetType,
      required this.targetId, required this.createdAt});
  factory FavoriteModel.fromJson(Map<String, dynamic> j) => FavoriteModel(
        id: j['id'], userId: j['userId'], targetType: j['targetType'],
        targetId: j['targetId'], createdAt: DateTime.parse(j['createdAt']));
}
```

```dart
// lib/core/models/review.dart
class ReviewModel {
  final String id, userId, targetType, targetId;
  final int rating; // 1..5
  final String? comment;
  final DateTime createdAt, updatedAt;
  ReviewModel({required this.id, required this.userId, required this.targetType, required this.targetId,
      required this.rating, required this.createdAt, required this.updatedAt, this.comment});
  factory ReviewModel.fromJson(Map<String, dynamic> j) => ReviewModel(
        id: j['id'], userId: j['userId'], targetType: j['targetType'], targetId: j['targetId'],
        rating: j['rating'], comment: j['comment'],
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']));
}
```

```dart
// lib/core/models/partner_application.dart
// status ∈ {pending, approved, rejected}
class PartnerApplicationModel {
  final String id, userId, businessName, description, phone, address, status;
  final String? website, rejectionReason;
  final List<String> categories;
  final DateTime createdAt, updatedAt;
  PartnerApplicationModel({required this.id, required this.userId, required this.businessName,
      required this.description, required this.phone, required this.address, required this.status,
      required this.categories, required this.createdAt, required this.updatedAt,
      this.website, this.rejectionReason});
  factory PartnerApplicationModel.fromJson(Map<String, dynamic> j) => PartnerApplicationModel(
        id: j['id'], userId: j['userId'], businessName: j['businessName'], description: j['description'],
        phone: j['phone'], address: j['address'], status: j['status'], website: j['website'],
        rejectionReason: j['rejectionReason'], categories: List<String>.from(j['categories'] ?? const []),
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']));
}
```

```dart
// lib/core/models/chat.dart
class ChatThreadModel {
  final String id;
  final List<String> participants; // user IDs
  final int unreadCount;
  final DateTime? lastMessageAt;
  final String? lastMessagePreview;
  final DateTime createdAt, updatedAt;
  ChatThreadModel({required this.id, required this.participants, required this.unreadCount,
      required this.createdAt, required this.updatedAt, this.lastMessageAt, this.lastMessagePreview});
  factory ChatThreadModel.fromJson(Map<String, dynamic> j) => ChatThreadModel(
        id: j['id'], participants: List<String>.from(j['participants'] ?? const []),
        unreadCount: j['unreadCount'] ?? 0,
        lastMessageAt: j['lastMessageAt'] != null ? DateTime.parse(j['lastMessageAt']) : null,
        lastMessagePreview: j['lastMessagePreview'],
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']));
}

class MessageModel {
  final String id, threadId, senderId, text;
  final List<String> readBy;
  final DateTime createdAt, updatedAt;
  MessageModel({required this.id, required this.threadId, required this.senderId, required this.text,
      required this.readBy, required this.createdAt, required this.updatedAt});
  factory MessageModel.fromJson(Map<String, dynamic> j) => MessageModel(
        id: j['id'], threadId: j['threadId'], senderId: j['senderId'], text: j['text'],
        readBy: List<String>.from(j['readBy'] ?? const []),
        createdAt: DateTime.parse(j['createdAt']), updatedAt: DateTime.parse(j['updatedAt']));
}
```

---

## 5. Auth lifecycle

**Token model (decision D1):** the client always sends `Authorization: Bearer <accessToken>` on protected calls. Firebase tokens are **never** sent to the API except the one-time ID-token exchange at `/auth/social`.

- **Access token** — short-lived (default 15m). Sent on every protected request.
- **Refresh token** — long-lived (default 30d), rotating. Stored securely. Used only at `/auth/refresh`.
- **Rotation & reuse detection:** each refresh returns a **new** pair and invalidates the old refresh token. If a revoked refresh token is replayed, the server kills the whole session family → next call 401s. The `ApiClient` handles the happy path (auto-refresh once on 401); if refresh itself fails, it calls `onSessionExpired` → send the user to login.

Response shapes:

| Endpoint | Success body (`data`) |
|---|---|
| `POST /auth/register` (201) | `{ user: UserModel, accessToken, refreshToken }` |
| `POST /auth/login` (200) | `{ user: UserModel, accessToken, refreshToken }` |
| `POST /auth/social` (200) | `{ user: UserModel, accessToken, refreshToken }` |
| `POST /auth/refresh` (200) | `{ accessToken, refreshToken }` — **no user** |
| `POST /auth/logout` (204) | *(empty body)* |

```dart
// lib/core/services/auth_service.dart
import '../models/user.dart';
import '../network/api_client.dart';
import '../network/token_store.dart';

class AuthResult {
  final UserModel user;
  AuthResult(this.user);
}

class AuthService {
  final ApiClient _api;
  final TokenStore _tokens;
  AuthService(this._api, this._tokens);

  Future<AuthResult> register({required String email, required String password, required String name}) async {
    final d = await _api.post('/auth/register',
        auth: false, body: {'email': email, 'password': password, 'name': name});
    await _persist(d);
    return AuthResult(UserModel.fromJson(Map<String, dynamic>.from(d['user'])));
  }

  Future<AuthResult> login({required String email, required String password}) async {
    final d = await _api.post('/auth/login', auth: false, body: {'email': email, 'password': password});
    await _persist(d);
    return AuthResult(UserModel.fromJson(Map<String, dynamic>.from(d['user'])));
  }

  /// idToken = Firebase ID token from Google/Apple sign-in on the client.
  Future<AuthResult> social(String firebaseIdToken) async {
    final d = await _api.post('/auth/social', auth: false, body: {'idToken': firebaseIdToken});
    await _persist(d);
    return AuthResult(UserModel.fromJson(Map<String, dynamic>.from(d['user'])));
  }

  Future<void> logout() async {
    final rt = await _tokens.refreshToken;
    if (rt != null) {
      try { await _api.post('/auth/logout', auth: false, body: {'refreshToken': rt}); } catch (_) {}
    }
    await _tokens.clear();
  }

  Future<bool> get hasSession => _tokens.hasSession;

  Future<void> _persist(Map d) => _tokens.save(
        accessToken: d['accessToken'] as String,
        refreshToken: d['refreshToken'] as String,
      );
}
```

**Role changes (partner approval):** when an admin approves a partner application, the server flips the user's role to `business` and sends an FCM data message `{ action: "REFRESH_TOKEN" }`. On receiving it, call `/auth/refresh` (or just let the next 401 trigger auto-refresh) so the new access token carries `role: business`. Then re-fetch `GET /users/me`.

---

## 6. Feature services

All services take an `ApiClient`. Each method returns typed models. The client already unwraps `data` and throws `ApiException` on error.

```dart
// lib/core/services/user_service.dart
class UserService {
  final ApiClient _api;
  UserService(this._api);

  Future<UserModel> me() async =>
      UserModel.fromJson(Map<String, dynamic>.from(await _api.get('/users/me')));

  Future<UserModel> updateMe({String? name, String? phone, String? avatarUrl}) async {
    final d = await _api.put('/users/me', body: {
      if (name != null) 'name': name,
      if (phone != null) 'phone': phone,
      if (avatarUrl != null) 'avatarUrl': avatarUrl,
    });
    return UserModel.fromJson(Map<String, dynamic>.from(d));
  }

  Future<void> changePassword({required String currentPassword, required String newPassword}) =>
      _api.put('/users/me/password',
          body: {'currentPassword': currentPassword, 'newPassword': newPassword});
}
```

```dart
// lib/core/services/catalog_service.dart  (public browse — products, producers, categories, booking places)
import '../network/paginated.dart';

class CatalogService {
  final ApiClient _api;
  CatalogService(this._api);

  // Categories: plain array.
  Future<List<CategoryModel>> categories() async {
    final d = await _api.get('/categories') as List;
    return d.map((e) => CategoryModel.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<Paginated<ProductModel>> products({
    int page = 1, int size = 20, String? categoryId, String? producerId,
    String? search, double? minPrice, double? maxPrice,
  }) async {
    final d = await _api.get('/products', query: {
      'page': page, 'size': size, 'categoryId': categoryId, 'producerId': producerId,
      'search': search, 'minPrice': minPrice, 'maxPrice': maxPrice,
    });
    return Paginated.fromJson(Map<String, dynamic>.from(d), (m) => ProductModel.fromJson(m));
  }

  Future<ProductModel> product(String id) async =>
      ProductModel.fromJson(Map<String, dynamic>.from(await _api.get('/products/$id')));

  Future<Paginated<ProducerModel>> producers({
    int page = 1, int size = 20, String? categoryId, String? search, bool? verified,
  }) async {
    final d = await _api.get('/producers', query: {
      'page': page, 'size': size, 'categoryId': categoryId, 'search': search,
      'verified': verified?.toString(), // server expects the string 'true'/'false'
    });
    return Paginated.fromJson(Map<String, dynamic>.from(d), (m) => ProducerModel.fromJson(m));
  }

  Future<ProducerModel> producer(String id) async =>
      ProducerModel.fromJson(Map<String, dynamic>.from(await _api.get('/producers/$id')));

  Future<Paginated<BookingPlaceModel>> bookingPlaces({int page = 1, int size = 20, String? search}) async {
    final d = await _api.get('/booking-places', query: {'page': page, 'size': size, 'search': search});
    return Paginated.fromJson(Map<String, dynamic>.from(d), (m) => BookingPlaceModel.fromJson(m));
  }

  Future<BookingPlaceModel> bookingPlace(String id) async =>
      BookingPlaceModel.fromJson(Map<String, dynamic>.from(await _api.get('/booking-places/$id')));
}
```

```dart
// lib/core/services/cart_service.dart   (GET returns a PLAIN ARRAY)
class CartService {
  final ApiClient _api;
  CartService(this._api);

  Future<List<CartItemModel>> get() async {
    final d = await _api.get('/cart') as List;
    return d.map((e) => CartItemModel.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<CartItemModel> add({required String productId, required int quantity}) async =>
      CartItemModel.fromJson(Map<String, dynamic>.from(
          await _api.post('/cart', body: {'productId': productId, 'quantity': quantity})));

  Future<CartItemModel> updateQty({required String productId, required int quantity}) async =>
      CartItemModel.fromJson(Map<String, dynamic>.from(
          await _api.put('/cart/$productId', body: {'quantity': quantity})));

  Future<void> remove(String productId) => _api.delete('/cart/$productId'); // → { removed: true }
  Future<void> clear() => _api.delete('/cart');
}
```

```dart
// lib/core/services/favorites_service.dart
class FavoritesService {
  final ApiClient _api;
  FavoritesService(this._api);

  Future<Paginated<FavoriteModel>> list({String? targetType, int page = 1, int size = 20}) async {
    final d = await _api.get('/favorites', query: {'targetType': targetType, 'page': page, 'size': size});
    return Paginated.fromJson(Map<String, dynamic>.from(d), (m) => FavoriteModel.fromJson(m));
  }

  // targetType ∈ {product, producer, bookingPlace}
  Future<FavoriteModel> add({required String targetType, required String targetId}) async =>
      FavoriteModel.fromJson(Map<String, dynamic>.from(
          await _api.post('/favorites', body: {'targetType': targetType, 'targetId': targetId})));

  /// NOTE: :id here is the **favorite's own id** (from the list), not the targetId.
  Future<void> remove(String favoriteId) => _api.delete('/favorites/$favoriteId');
}
```

```dart
// lib/core/services/reviews_service.dart
class ReviewsService {
  final ApiClient _api;
  ReviewsService(this._api);

  Future<Paginated<ReviewModel>> list({
    required String targetType, required String targetId, int page = 1, int size = 20,
  }) async {
    final d = await _api.get('/reviews',
        query: {'targetType': targetType, 'targetId': targetId, 'page': page, 'size': size});
    return Paginated.fromJson(Map<String, dynamic>.from(d), (m) => ReviewModel.fromJson(m));
  }

  Future<ReviewModel> create({
    required String targetType, required String targetId, required int rating, String? comment,
  }) async =>
      ReviewModel.fromJson(Map<String, dynamic>.from(await _api.post('/reviews', body: {
        'targetType': targetType, 'targetId': targetId, 'rating': rating,
        if (comment != null) 'comment': comment,
      })));
}
```

```dart
// lib/core/services/chat_service.dart   (threads list = PLAIN ARRAY)
class ChatService {
  final ApiClient _api;
  ChatService(this._api);

  Future<List<ChatThreadModel>> threads() async {
    final d = await _api.get('/chat/threads') as List;
    return d.map((e) => ChatThreadModel.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<ChatThreadModel> openThread(String participantId) async =>
      ChatThreadModel.fromJson(Map<String, dynamic>.from(
          await _api.post('/chat/threads', body: {'participantId': participantId})));

  Future<Paginated<MessageModel>> messages(String threadId, {int page = 1, int size = 20}) async {
    final d = await _api.get('/chat/threads/$threadId/messages', query: {'page': page, 'size': size});
    return Paginated.fromJson(Map<String, dynamic>.from(d), (m) => MessageModel.fromJson(m));
  }

  Future<MessageModel> send(String threadId, String text) async =>
      MessageModel.fromJson(Map<String, dynamic>.from(
          await _api.post('/chat/threads/$threadId/messages', body: {'text': text})));

  Future<void> markRead(String threadId) => _api.post('/chat/threads/$threadId/read');
}
```

```dart
// lib/core/services/devices_service.dart   (register FCM token for push)
class DevicesService {
  final ApiClient _api;
  DevicesService(this._api);

  // platform ∈ {ios, android, web}
  Future<void> register({required String fcmToken, String platform = 'android'}) =>
      _api.post('/devices', body: {'token': fcmToken, 'platform': platform});

  Future<void> unregister(String fcmToken) => _api.delete('/devices/$fcmToken');
}
```

```dart
// lib/core/services/partner_service.dart   (client applies to become a business)
class PartnerService {
  final ApiClient _api;
  PartnerService(this._api);

  Future<PartnerApplicationModel> apply({
    required String businessName, required String description, String? website,
    required String phone, required String address, required List<String> categoryIds,
  }) async =>
      PartnerApplicationModel.fromJson(Map<String, dynamic>.from(await _api.post('/partner/apply', body: {
        'businessName': businessName, 'description': description,
        if (website != null) 'website': website,
        'phone': phone, 'address': address, 'categories': categoryIds,
      })));

  /// Returns the current user's application, or null if none.
  Future<PartnerApplicationModel?> myApplication() async {
    final d = await _api.get('/partner/application');
    if (d == null) return null;
    return PartnerApplicationModel.fromJson(Map<String, dynamic>.from(d));
  }
}
```

```dart
// lib/core/services/shop_service.dart   (BUSINESS role — own producer profile + products)
class ShopService {
  final ApiClient _api;
  ShopService(this._api);

  Future<ProducerModel?> myProducer() async {
    final d = await _api.get('/shop/producer');
    return d == null ? null : ProducerModel.fromJson(Map<String, dynamic>.from(d));
  }

  Future<ProducerModel> createProducer(Map<String, dynamic> body) async =>
      ProducerModel.fromJson(Map<String, dynamic>.from(await _api.post('/shop/producer', body: body)));

  Future<ProducerModel> updateProducer(Map<String, dynamic> body) async =>
      ProducerModel.fromJson(Map<String, dynamic>.from(await _api.put('/shop/producer', body: body)));

  Future<Paginated<ProductModel>> myProducts({int page = 1, int size = 20, String? status}) async {
    final d = await _api.get('/shop/products', query: {'page': page, 'size': size, 'status': status});
    return Paginated.fromJson(Map<String, dynamic>.from(d), (m) => ProductModel.fromJson(m));
  }

  // body: { name, description, price, unit, stock?, photos?, categoryId, available? }
  Future<ProductModel> createProduct(Map<String, dynamic> body) async =>
      ProductModel.fromJson(Map<String, dynamic>.from(await _api.post('/shop/products', body: body)));

  Future<ProductModel> updateProduct(String id, Map<String, dynamic> body) async =>
      ProductModel.fromJson(Map<String, dynamic>.from(await _api.put('/shop/products/$id', body: body)));

  Future<void> deleteProduct(String id) => _api.delete('/shop/products/$id');
}
```

```dart
// lib/core/services/my_locations_service.dart   (BUSINESS role — own booking places; list = PLAIN ARRAY)
class MyLocationsService {
  final ApiClient _api;
  MyLocationsService(this._api);

  Future<List<BookingPlaceModel>> list() async {
    final d = await _api.get('/my-locations') as List;
    return d.map((e) => BookingPlaceModel.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  // body: { name, description, address, capacity, pricePerNight, photos?, amenities? }
  Future<BookingPlaceModel> create(Map<String, dynamic> body) async =>
      BookingPlaceModel.fromJson(Map<String, dynamic>.from(await _api.post('/my-locations', body: body)));

  Future<BookingPlaceModel> update(String id, Map<String, dynamic> body) async =>
      BookingPlaceModel.fromJson(Map<String, dynamic>.from(await _api.put('/my-locations/$id', body: body)));

  Future<void> delete(String id) => _api.delete('/my-locations/$id');
}
```

> **Admin endpoints** (`/api/admin/**`, role `admin`) are for a moderation console, not the consumer app. If you need them, follow the exact same service pattern using the reference table in §8. Skip unless the Flutter app has an admin surface.

### Wiring it together (composition root)

```dart
// lib/core/services/services.dart  — build once, inject via Provider/GetIt/Riverpod.
final tokenStore = TokenStore();
late final ApiClient apiClient;

void buildServices({void Function()? onSessionExpired}) {
  apiClient = ApiClient(tokens: tokenStore, onSessionExpired: onSessionExpired);
}

// Then: AuthService(apiClient, tokenStore), UserService(apiClient), CatalogService(apiClient), ...
```

---

## 7. Realtime chat (STOMP over WebSocket) — optional

REST chat (§6) fully works on its own (send via `POST .../messages`, poll `GET .../messages`). Realtime just pushes new messages live. The server speaks a **minimal STOMP 1.2** dialect over `ws(s)://<host>/ws`, matching Flutter's `@stomp/stompjs` conventions.

- **Connect URL:** `ws://localhost:3838/ws` (use `wss://` when the API is HTTPS).
- **Auth:** either append `?token=<accessToken>` to the URL **or** send it in the `CONNECT` frame `Authorization: Bearer <accessToken>` header. On success the server replies with a `CONNECTED` frame.
- **Subscribe** (receive):
  - `/user/queue/thread/<threadId>` → new `MessageModel` JSON for that thread.
  - `/user/queue/threads` → updated thread list (array of `ChatThreadModel`) after each message.
- **Send** a message: STOMP `SEND` frame to any destination with JSON body `{"threadId":"...","text":"..."}`.
  (The server also persists it — no separate REST call needed when using WS.)
- Frames are newline-delimited with a null terminator, standard STOMP. If using the `stomp_dart_client` package, point it at the `/ws` URL and pass the token via `stompConnectHeaders: {'Authorization': 'Bearer <token>'}`.

Recommended: start with **REST-only chat**, add STOMP later if you want live updates. Refresh the access token before (re)connecting since the socket authenticates with it.

---

## 8. Full endpoint reference

Auth column: 🔓 public · 🔑 any logged-in user · 🏪 business role · 🛡️ admin role · 🟡 optional (works logged-out, richer logged-in).
List shape: **P** = paginated `{items,total,page,size}` · **A** = plain array · **O** = single object.

| # | Method | Path | Auth | Body / Query | Returns |
|---|--------|------|------|--------------|---------|
| | GET | `/health` | 🔓 | — | `{status:"ok"}` |
| **Auth** |
| | POST | `/api/auth/register` | 🔓 | `{email,password(≥8),name}` | O `{user,accessToken,refreshToken}` (201) |
| | POST | `/api/auth/login` | 🔓 | `{email,password}` | O `{user,accessToken,refreshToken}` |
| | POST | `/api/auth/refresh` | 🔓 | `{refreshToken}` | O `{accessToken,refreshToken}` |
| | POST | `/api/auth/logout` | 🔓 | `{refreshToken}` | 204 empty |
| | POST | `/api/auth/social` | 🔓 | `{idToken}` (Firebase) | O `{user,accessToken,refreshToken}` |
| **Users** |
| | GET | `/api/users/me` | 🔑 | — | O UserModel |
| | PUT | `/api/users/me` | 🔑 | `{name?,phone?,avatarUrl?}` | O UserModel |
| | PUT | `/api/users/me/password` | 🔑 | `{currentPassword,newPassword(≥8)}` | O |
| **Categories** |
| | GET | `/api/categories` | 🔓 | — | **A** CategoryModel |
| **Products** |
| | GET | `/api/products` | 🟡 | `page,size,categoryId?,producerId?,search?,minPrice?,maxPrice?` | **P** ProductModel |
| | GET | `/api/products/:id` | 🟡 | — | O ProductModel |
| **Producers** |
| | GET | `/api/producers` | 🟡 | `page,size,categoryId?,search?,verified?(true\|false)` | **P** ProducerModel |
| | GET | `/api/producers/:id` | 🟡 | — | O ProducerModel |
| **Booking places** |
| | GET | `/api/booking-places` | 🟡 | `page,size,search?` | **P** BookingPlaceModel |
| | GET | `/api/booking-places/:id` | 🟡 | — | O BookingPlaceModel |
| **Reviews** |
| | GET | `/api/reviews` | 🔓 | `targetType,targetId,page,size` | **P** ReviewModel |
| | POST | `/api/reviews` | 🔑 | `{targetType,targetId,rating(1-5),comment?}` | O ReviewModel |
| **Favorites** |
| | GET | `/api/favorites` | 🔑 | `targetType?,page,size` | **P** FavoriteModel |
| | POST | `/api/favorites` | 🔑 | `{targetType,targetId}` | O FavoriteModel |
| | DELETE | `/api/favorites/:id` | 🔑 | :id = favorite id | O |
| **Cart** |
| | GET | `/api/cart` | 🔑 | — | **A** CartItemModel |
| | POST | `/api/cart` | 🔑 | `{productId,quantity(1-999)}` | O CartItemModel (201) |
| | PUT | `/api/cart/:productId` | 🔑 | `{quantity}` | O CartItemModel |
| | DELETE | `/api/cart/:productId` | 🔑 | — | O `{removed:true}` |
| | DELETE | `/api/cart` | 🔑 | — | O (clears cart) |
| **Devices (push)** |
| | POST | `/api/devices` | 🔑 | `{token,platform(ios\|android\|web)}` | O |
| | DELETE | `/api/devices/:token` | 🔑 | — | O |
| **Partner** |
| | POST | `/api/partner/apply` | 🔑 | `{businessName,description,website?,phone,address,categories[]}` | O PartnerApplicationModel |
| | GET | `/api/partner/application` | 🔑 | — | O PartnerApplicationModel / null |
| **Shop (business)** |
| | GET | `/api/shop/producer` | 🏪 | — | O ProducerModel / null |
| | POST | `/api/shop/producer` | 🏪 | `{businessName,description,website?,phone?,address,categories[],photos?}` | O ProducerModel |
| | PUT | `/api/shop/producer` | 🏪 | partial of above | O ProducerModel |
| | GET | `/api/shop/products` | 🏪 | `page,size,status?` | **P** ProductModel |
| | POST | `/api/shop/products` | 🏪 | `{name,description,price,unit,stock?,photos?,categoryId,available?}` | O ProductModel |
| | PUT | `/api/shop/products/:id` | 🏪 | partial of above | O ProductModel |
| | DELETE | `/api/shop/products/:id` | 🏪 | — | O |
| **My locations (business)** |
| | GET | `/api/my-locations` | 🏪 | — | **A** BookingPlaceModel |
| | POST | `/api/my-locations` | 🏪 | `{name,description,address,capacity,pricePerNight,photos?,amenities?}` | O BookingPlaceModel |
| | PUT | `/api/my-locations/:id` | 🏪 | partial of above | O BookingPlaceModel |
| | DELETE | `/api/my-locations/:id` | 🏪 | — | O |
| **Chat** |
| | GET | `/api/chat/threads` | 🔑 | — | **A** ChatThreadModel |
| | POST | `/api/chat/threads` | 🔑 | `{participantId}` | O ChatThreadModel |
| | GET | `/api/chat/threads/:id/messages` | 🔑 | `page,size` | **P** MessageModel |
| | POST | `/api/chat/threads/:id/messages` | 🔑 | `{text}` | O MessageModel |
| | POST | `/api/chat/threads/:id/read` | 🔑 | — | O |
| **Admin (admin role)** — moderation console only |
| | GET | `/api/admin/products` | 🛡️ | `status?,page,size` | **P** ProductModel |
| | GET | `/api/admin/products/:id` | 🛡️ | — | O |
| | PATCH | `/api/admin/products/:id/approve` | 🛡️ | — | O |
| | PATCH | `/api/admin/products/:id/reject` | 🛡️ | `{reason}` | O |
| | PATCH | `/api/admin/products/:id/request-info` | 🛡️ | `{note?}` | O |
| | PATCH | `/api/admin/products/:id/sections/:section` | 🛡️ | `{action(approve\|requestChanges),note?}` | O |
| | GET | `/api/admin/producers` | 🛡️ | `status?,page,size` | **P** ProducerModel |
| | GET | `/api/admin/producers/:id` | 🛡️ | — | O |
| | PATCH | `/api/admin/producers/:id/approve` | 🛡️ | — | O |
| | PATCH | `/api/admin/producers/:id/reject` | 🛡️ | `{reason}` | O |
| | PATCH | `/api/admin/producers/:id/request-info` | 🛡️ | `{note?}` | O |
| | PATCH | `/api/admin/producers/:id/verify` | 🛡️ | — | O |
| | GET | `/api/admin/applications` | 🛡️ | `status?,page,size` | **P** PartnerApplicationModel |
| | GET | `/api/admin/applications/:id` | 🛡️ | — | O |
| | PATCH | `/api/admin/applications/:id/approve` | 🛡️ | — | O (promotes user to business) |
| | PATCH | `/api/admin/applications/:id/reject` | 🛡️ | `{reason}` | O |
| | GET | `/api/admin/booking-places` | 🛡️ | `status?,page,size` | **P** BookingPlaceModel |
| | GET | `/api/admin/booking-places/:id` | 🛡️ | — | O |
| | PATCH | `/api/admin/booking-places/:id/approve` | 🛡️ | — | O |
| | PATCH | `/api/admin/booking-places/:id/reject` | 🛡️ | `{reason}` | O |
| | PATCH | `/api/admin/booking-places/:id/request-info` | 🛡️ | `{note?}` | O |

---

## 9. Integration checklist (do in this order)

1. **Deps:** add `dio` + `flutter_secure_storage` (and `web_socket_channel` if doing §7). `flutter pub get`.
2. **Plumbing:** create `lib/core/network/` with `api_config.dart`, `api_exception.dart`, `token_store.dart`, `api_client.dart`, `paginated.dart` (§3).
3. **Models:** create `lib/core/models/` with all classes from §4.
4. **Services:** create `lib/core/services/` with the services from §6 you actually use (auth, user, catalog, cart, favorites, reviews, chat, devices, partner; shop + my_locations only if the app has a business surface).
5. **Composition root:** build the singleton `ApiClient`/`TokenStore` once; pass `onSessionExpired` to navigate to the login screen.
6. **Swap the data source:** in each existing repository/provider that currently reads the in-memory store, call the matching service method instead. Keep the same return types where possible so widgets don't change. Map `ApiException.isValidation`/`.fieldMessage(...)` into your form error UI.
7. **Auth gate on startup:** if `TokenStore.hasSession`, call `GET /users/me` to hydrate the current user; on `ApiException(isAuth)` clear tokens and show login.
8. **Config per environment:** set `API_BASE_URL` via `--dart-define`. On Android emulator use `http://10.0.2.2:3838`.
9. **Android cleartext (local dev only):** plain `http://` is blocked by default on Android/iOS release. For local testing over `http`, add `android:usesCleartextTraffic="true"` to `AndroidManifest.xml` `<application>` (and an ATS exception on iOS). Remove for production — production must be `https://`.
10. **Verify end-to-end:** register → returns tokens → `GET /users/me` works → browse `/products` → add to cart → refresh flow (let the access token expire or delete it and confirm the interceptor silently refreshes).

### Gotchas that will bite you if ignored

- **Never** trust or send a client-chosen `role`/`id`; the server derives them from the token. `role` only changes via the partner-approval + refresh flow.
- **`verified` producer filter** must be the string `'true'`/`'false'` in the query, not a bool — the service above already converts it.
- **Favorites delete** uses the *favorite's* `id`, not the target's id.
- **Refresh returns no `user`** — don't try to read `data.user` there.
- **Optional/absent fields** are omitted, not `null`. Always null-check (`j['phone']` may be missing).
- **Rate limits:** auth endpoints are throttled (default 10/15min) and login has account lockout after repeated failures — don't hammer them in retry loops. General endpoints: 100/15min per IP.
- **Body size limit** is 10 KB — fine for JSON; upload images to storage elsewhere and send URLs in `photos[]`.
```
