diff --git a/docs/api-error-codes.md b/docs/api-error-codes.md new file mode 100644 index 0000000..5f08e43 --- /dev/null +++ b/docs/api-error-codes.md @@ -0,0 +1,441 @@ +# API Error Codes Reference + +All GoodGo Platform API errors follow a structured JSON format. This document lists every `errorCode` value, its HTTP status, description, and usage examples. + +## Error Response Format + +Every error response from the API has this shape: + +```json +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Human-readable error description", + "details": { }, + "correlationId": "optional-uuid", + "timestamp": "2026-04-10T12:00:00.000Z" +} +``` + +| Field | Type | Description | +|-----------------|----------|-------------| +| `statusCode` | `number` | HTTP status code (e.g. `400`, `401`, `404`). | +| `errorCode` | `string` | Machine-readable error identifier (see tables below). | +| `message` | `string` | Human-readable explanation (Vietnamese for business rules, English for system errors). | +| `details` | `object` | Optional structured metadata (e.g. validation field names, current/target status). | +| `correlationId` | `string` | Echoed from the `X-Correlation-Id` request header when present. | +| `timestamp` | `string` | ISO 8601 timestamp of the error occurrence. | + +--- + +## General Error Codes + +These codes apply across all modules. They are also used as fallback codes when a plain `HttpException` (without a domain error code) is thrown. + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `INTERNAL_ERROR` | `500` | Unexpected server error. | Unhandled exceptions, infrastructure failures. | +| `VALIDATION_FAILED` | `400` | Request data failed validation. | Invalid input fields, business rule violations (e.g. invalid phone format, bad status transitions). | +| `NOT_FOUND` | `404` | Requested resource does not exist. | Entity lookup by ID returns no result. | +| `CONFLICT` | `409` | Resource state conflict. | Duplicate creation, conflicting state transition. | +| `UNAUTHORIZED` | `401` | Authentication required or failed. | Missing/invalid/expired auth token, bad credentials. | +| `FORBIDDEN` | `403` | Insufficient permissions. | Role-based access denial, CSRF token failure, resource ownership mismatch. | +| `BAD_REQUEST` | `400` | Malformed request. | Invalid file upload, unsupported content type, bad query parameters. | +| `TOO_MANY_REQUESTS` | `429` | Rate limit exceeded. | Too many API requests in a short period. | + +--- + +## Auth Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `AUTH_INVALID_CREDENTIALS` | `401` | Login credentials are incorrect. | Phone number or password mismatch during local login. | +| `AUTH_TOKEN_EXPIRED` | `401` | JWT token has expired. | Access token or refresh token past expiration. | +| `AUTH_TOKEN_INVALID` | `401` | JWT token is malformed or tampered with. | Token signature verification failure. | +| `AUTH_INSUFFICIENT_PERMISSIONS` | `403` | Authenticated user lacks required role/permission. | Non-admin accessing admin endpoints, role mismatch. | + +### Auth Module Usage Examples + +```json +// POST /auth/login — wrong password +{ + "statusCode": 401, + "errorCode": "UNAUTHORIZED", + "message": "Số điện thoại hoặc mật khẩu không đúng" +} + +// POST /auth/refresh — expired refresh token +{ + "statusCode": 401, + "errorCode": "UNAUTHORIZED", + "message": "Refresh token không hợp lệ hoặc đã hết hạn" +} + +// POST /auth/register — phone already registered +{ + "statusCode": 409, + "errorCode": "CONFLICT", + "message": "Số điện thoại đã được đăng ký" +} + +// POST /auth/register — invalid phone format +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Số điện thoại không hợp lệ. Yêu cầu 10 chữ số bắt đầu bằng 0" +} +``` + +--- + +## User Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `USER_NOT_FOUND` | `404` | User does not exist. | Profile lookup, admin user management. | +| `USER_ALREADY_EXISTS` | `409` | User with given identifier already exists. | Duplicate registration (phone or email). | +| `USER_INVALID_PHONE` | `400` | Phone number format is invalid. | Registration or profile update with bad phone format. | + +### User Module Usage Examples + +```json +// GET /auth/profile — deleted or nonexistent user +{ + "statusCode": 404, + "errorCode": "NOT_FOUND", + "message": "Người dùng with id '...' not found" +} + +// DELETE /auth/account — account already deleted +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Tài khoản đã bị xóa" +} +``` + +--- + +## Listing Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `LISTING_NOT_FOUND` | `404` | Listing does not exist. | Lookup by ID for viewing, moderation, or status change. | +| `LISTING_INVALID_STATUS_TRANSITION` | `400` | Status transition is not allowed. | Attempting an invalid workflow step (e.g. `EXPIRED` → `ACTIVE`). | +| `LISTING_ALREADY_ACTIVE` | `409` | Listing is already in active state. | Re-activating a listing that is already published. | +| `LISTING_EXPIRED` | `400` | Listing has expired. | Attempting operations on an expired listing. | + +### Listing Module Usage Examples + +```json +// PATCH /listings/:id/status — invalid transition +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Không thể chuyển trạng thái từ EXPIRED sang ACTIVE", + "details": { + "currentStatus": "EXPIRED", + "targetStatus": "ACTIVE" + } +} + +// POST /admin/listings/:id/approve — already approved +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Listing này không ở trạng thái chờ duyệt" +} + +// POST /listings — invalid address +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Địa chỉ không hợp lệ" +} +``` + +--- + +## Property Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `PROPERTY_NOT_FOUND` | `404` | Property does not exist. | Property lookup for media upload or listing creation. | + +### Property Module Usage Examples + +```json +// POST /properties/:id/media — property not found +{ + "statusCode": 404, + "errorCode": "NOT_FOUND", + "message": "Property with id '...' not found" +} + +// POST /properties/:id/media — too many files +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Tối đa 30 ảnh/video cho mỗi bất động sản" +} +``` + +--- + +## Media Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `MEDIA_UPLOAD_FAILED` | `500` | File upload to storage provider failed. | S3/cloud storage write failure. | +| `MEDIA_LIMIT_EXCEEDED` | `400` | Maximum number of media files reached. | Exceeding per-property media limit. | + +--- + +## Payment Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `PAYMENT_FAILED` | `500` | Payment processing failed. | Gateway error, provider timeout, payment link creation failure. | +| `PAYMENT_ALREADY_PROCESSED` | `409` | Payment has already been completed, failed, or refunded. | Duplicate callback, retry on non-pending payment, refunding a non-completed payment. | +| `PAYMENT_INVALID_AMOUNT` | `400` | Payment amount is invalid. | Zero/negative amount, amount below minimum threshold. | + +### Payment Module Usage Examples + +```json +// POST /payments — duplicate idempotency key +{ + "statusCode": 409, + "errorCode": "CONFLICT", + "message": "Thanh toán với idempotency key này đã tồn tại" +} + +// POST /payments/callback — invalid signature +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Chữ ký callback không hợp lệ" +} + +// POST /payments/:id/refund — payment not completed +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất" +} + +// Entity-level: marking already-processed payment as completed +{ + "statusCode": 409, + "errorCode": "PAYMENT_ALREADY_PROCESSED", + "message": "Cannot complete payment in status COMPLETED" +} +``` + +--- + +## Subscription Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `SUBSCRIPTION_NOT_FOUND` | `404` | Subscription does not exist. | Lookup for cancellation, upgrade, or usage metering. | +| `SUBSCRIPTION_ALREADY_ACTIVE` | `409` | User already has an active subscription. | Attempting to create a second active subscription. | +| `SUBSCRIPTION_ALREADY_CANCELLED` | `409` | Subscription has already been cancelled. | Re-cancelling a subscription. | +| `SUBSCRIPTION_INACTIVE` | `409` | Subscription is not in an active state. | Upgrading, expiring, or marking past-due on a non-active subscription. | +| `QUOTA_EXCEEDED` | `403` | Usage quota for the current plan has been exceeded. | Exceeding listing, lead, or search quota limits. | + +### Subscription Module Usage Examples + +```json +// POST /subscriptions — already has active subscription +{ + "statusCode": 409, + "errorCode": "CONFLICT", + "message": "Người dùng đã có subscription đang hoạt động" +} + +// POST /subscriptions/cancel — already cancelled +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Subscription đã bị hủy trước đó" +} + +// POST /subscriptions/upgrade — subscription not active +{ + "statusCode": 400, + "errorCode": "VALIDATION_FAILED", + "message": "Subscription không ở trạng thái hoạt động" +} + +// Entity-level: upgrade on inactive subscription +{ + "statusCode": 409, + "errorCode": "SUBSCRIPTION_INACTIVE", + "message": "Không thể nâng cấp subscription ở trạng thái CANCELLED" +} + +// Quota guard — plan limit exceeded +{ + "statusCode": 403, + "errorCode": "FORBIDDEN", + "message": "Bạn đã đạt giới hạn listings. Vui lòng nâng cấp gói để tiếp tục." +} +``` + +--- + +## Course Error Codes + +| Error Code | HTTP Status | Description | When It Occurs | +|---|---|---|---| +| `COURSE_NOT_FOUND` | `404` | Course does not exist. | Course lookup by ID. | +| `COURSE_ALREADY_PUBLISHED` | `409` | Course is already in published state. | Re-publishing an active course. | +| `COURSE_ENROLLMENT_CLOSED` | `400` | Course enrollment period has ended. | Attempting to enroll after the cutoff date. | + +--- + +## Error Code Quick-Reference (Sorted Alphabetically) + +| Error Code | HTTP Status | Module | +|---|---|---| +| `AUTH_INSUFFICIENT_PERMISSIONS` | `403` | Auth | +| `AUTH_INVALID_CREDENTIALS` | `401` | Auth | +| `AUTH_TOKEN_EXPIRED` | `401` | Auth | +| `AUTH_TOKEN_INVALID` | `401` | Auth | +| `BAD_REQUEST` | `400` | General | +| `CONFLICT` | `409` | General | +| `COURSE_ALREADY_PUBLISHED` | `409` | Course | +| `COURSE_ENROLLMENT_CLOSED` | `400` | Course | +| `COURSE_NOT_FOUND` | `404` | Course | +| `FORBIDDEN` | `403` | General | +| `INTERNAL_ERROR` | `500` | General | +| `LISTING_ALREADY_ACTIVE` | `409` | Listing | +| `LISTING_EXPIRED` | `400` | Listing | +| `LISTING_INVALID_STATUS_TRANSITION` | `400` | Listing | +| `LISTING_NOT_FOUND` | `404` | Listing | +| `MEDIA_LIMIT_EXCEEDED` | `400` | Media | +| `MEDIA_UPLOAD_FAILED` | `500` | Media | +| `NOT_FOUND` | `404` | General | +| `PAYMENT_ALREADY_PROCESSED` | `409` | Payment | +| `PAYMENT_FAILED` | `500` | Payment | +| `PAYMENT_INVALID_AMOUNT` | `400` | Payment | +| `PROPERTY_NOT_FOUND` | `404` | Property | +| `QUOTA_EXCEEDED` | `403` | Subscription | +| `SUBSCRIPTION_ALREADY_ACTIVE` | `409` | Subscription | +| `SUBSCRIPTION_ALREADY_CANCELLED` | `409` | Subscription | +| `SUBSCRIPTION_INACTIVE` | `409` | Subscription | +| `SUBSCRIPTION_NOT_FOUND` | `404` | Subscription | +| `TOO_MANY_REQUESTS` | `429` | General | +| `UNAUTHORIZED` | `401` | General | +| `USER_ALREADY_EXISTS` | `409` | User | +| `USER_INVALID_PHONE` | `400` | User | +| `USER_NOT_FOUND` | `404` | User | + +--- + +## Frontend Integration Guide + +### TypeScript Error Type + +```typescript +interface ApiError { + statusCode: number; + errorCode: string; + message: string; + details?: Record; + correlationId?: string; + timestamp: string; +} +``` + +### Handling Errors by Code + +```typescript +import axios, { AxiosError } from 'axios'; + +async function handleApiCall() { + try { + const res = await axios.post('/api/listings', payload); + return res.data; + } catch (err) { + if (axios.isAxiosError(err)) { + const apiError = err.response?.data as ApiError; + + switch (apiError.errorCode) { + case 'VALIDATION_FAILED': + // Show field-level errors from apiError.details + showValidationErrors(apiError.details); + break; + case 'UNAUTHORIZED': + case 'AUTH_TOKEN_EXPIRED': + // Redirect to login or refresh token + await refreshSession(); + break; + case 'QUOTA_EXCEEDED': + // Show upgrade prompt + showUpgradeDialog(apiError.message); + break; + case 'CONFLICT': + // Inform user of duplicate action + showToast(apiError.message, 'warning'); + break; + case 'NOT_FOUND': + // Navigate to 404 page or show missing resource message + router.push('/404'); + break; + default: + showToast('Đã xảy ra lỗi, vui lòng thử lại', 'error'); + } + } + } +} +``` + +### Error Code Constants (Optional) + +For type safety, maintain error codes as a union type on the frontend: + +```typescript +type ErrorCode = + | 'INTERNAL_ERROR' + | 'VALIDATION_FAILED' + | 'NOT_FOUND' + | 'CONFLICT' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'BAD_REQUEST' + | 'TOO_MANY_REQUESTS' + | 'AUTH_INVALID_CREDENTIALS' + | 'AUTH_TOKEN_EXPIRED' + | 'AUTH_TOKEN_INVALID' + | 'AUTH_INSUFFICIENT_PERMISSIONS' + | 'USER_NOT_FOUND' + | 'USER_ALREADY_EXISTS' + | 'USER_INVALID_PHONE' + | 'COURSE_NOT_FOUND' + | 'COURSE_ALREADY_PUBLISHED' + | 'COURSE_ENROLLMENT_CLOSED' + | 'LISTING_NOT_FOUND' + | 'LISTING_INVALID_STATUS_TRANSITION' + | 'LISTING_ALREADY_ACTIVE' + | 'LISTING_EXPIRED' + | 'PROPERTY_NOT_FOUND' + | 'MEDIA_UPLOAD_FAILED' + | 'MEDIA_LIMIT_EXCEEDED' + | 'PAYMENT_FAILED' + | 'PAYMENT_ALREADY_PROCESSED' + | 'PAYMENT_INVALID_AMOUNT' + | 'SUBSCRIPTION_NOT_FOUND' + | 'SUBSCRIPTION_ALREADY_ACTIVE' + | 'SUBSCRIPTION_ALREADY_CANCELLED' + | 'SUBSCRIPTION_INACTIVE' + | 'QUOTA_EXCEEDED'; +``` + +--- + +## Source Files + +- **Error code enum**: `apps/api/src/modules/shared/domain/error-codes.ts` +- **Domain exceptions**: `apps/api/src/modules/shared/domain/domain-exception.ts` +- **Global exception filter**: `apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts`