diff --git a/docs/docs-json.json b/docs/docs-json.json index 2f2d019..0be1b70 100644 --- a/docs/docs-json.json +++ b/docs/docs-json.json @@ -1 +1 @@ -{"openapi":"3.0.0","paths":{"/api":{"get":{"operationId":"AppController_getHello","parameters":[],"responses":{"200":{"description":"API is running"}},"summary":"Health check endpoint","tags":["Health"]}},"/api/health":{"get":{"operationId":"AppController_getHealth","parameters":[],"responses":{"200":{"description":"API health status"}},"summary":"Health check endpoint","tags":["Health"]}},"/api/auth/register":{"post":{"operationId":"AuthController_register","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterDto"}}}},"responses":{"201":{"description":"User successfully registered","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"400":{"description":"Bad request - validation failed"},"409":{"description":"Conflict - email already registered"}},"summary":"Register a new user","tags":["Authentication"]}},"/api/auth/login":{"post":{"operationId":"AuthController_login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginDto"}}}},"responses":{"200":{"description":"User successfully logged in","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"401":{"description":"Unauthorized - invalid credentials"}},"summary":"Login user","tags":["Authentication"]}},"/api/auth/profile":{"get":{"operationId":"AuthController_getProfile","parameters":[],"responses":{"200":{"description":"User profile retrieved successfully"},"401":{"description":"Unauthorized - invalid or missing token"}},"security":[{"bearer":[]}],"summary":"Get current user profile","tags":["Authentication"]}},"/api/auth/refresh":{"post":{"operationId":"AuthController_refreshToken","parameters":[],"responses":{"200":{"description":"Token refreshed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"401":{"description":"Unauthorized - invalid or missing token"}},"security":[{"bearer":[]}],"summary":"Refresh access token","tags":["Authentication"]}},"/api/users":{"post":{"operationId":"UsersController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"User successfully created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"409":{"description":"Email already exists"}},"security":[{"bearer":[]}],"summary":"Create new user (Admin only)","tags":["Users"]},"get":{"operationId":"UsersController_findAll","parameters":[],"responses":{"200":{"description":"List of users","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Insufficient permissions"}},"security":[{"bearer":[]}],"summary":"Get all users (Admin/Manager only)","tags":["Users"]}},"/api/users/{id}":{"get":{"operationId":"UsersController_findOne","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"User found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Insufficient permissions"},"404":{"description":"User not found"}},"security":[{"bearer":[]}],"summary":"Get user by ID (Admin/Manager only)","tags":["Users"]},"patch":{"operationId":"UsersController_update","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"User successfully updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"404":{"description":"User not found"},"409":{"description":"Email already exists"}},"security":[{"bearer":[]}],"summary":"Update user (Admin only)","tags":["Users"]},"delete":{"operationId":"UsersController_remove","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"User successfully deleted"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"404":{"description":"User not found"}},"security":[{"bearer":[]}],"summary":"Delete user (Admin only)","tags":["Users"]}},"/api/products":{"get":{"operationId":"ProductsController_findAll","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"type":"number"}},{"name":"categoryId","required":false,"in":"query","description":"Filter by category ID","schema":{"example":"123e4567-e89b-12d3-a456-426614174000","type":"string"}},{"name":"search","required":false,"in":"query","description":"Search query for product name or description","schema":{"example":"laptop","type":"string"}},{"name":"minPrice","required":false,"in":"query","description":"Minimum price filter","schema":{"minimum":0,"example":100,"type":"number"}},{"name":"maxPrice","required":false,"in":"query","description":"Maximum price filter","schema":{"minimum":0,"example":1000,"type":"number"}},{"name":"isAvailable","required":false,"in":"query","description":"Filter by availability status","schema":{"example":true,"type":"boolean"}}],"responses":{"200":{"description":"Products retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"500":{"description":"Internal server error"}},"summary":"Get all products with pagination and filters","tags":["products"]},"post":{"description":"Creates a new product and updates category product count","operationId":"ProductsController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductDto"}}}},"responses":{"201":{"description":"Product created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"400":{"description":"Invalid input data"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - insufficient role"},"404":{"description":"Category not found"},"500":{"description":"Internal server error"}},"security":[{"bearer":[]}],"summary":"Create new product (Admin/Manager only)","tags":["products"]}},"/api/products/search":{"get":{"operationId":"ProductsController_search","parameters":[{"name":"q","required":true,"in":"query","schema":{"type":"string"}},{"name":"page","required":true,"in":"query","schema":{"type":"number"}},{"name":"limit","required":true,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"Products found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"400":{"description":"Invalid search query"},"500":{"description":"Internal server error"}},"summary":"Search products by name or description","tags":["products"]}},"/api/products/category/{categoryId}":{"get":{"operationId":"ProductsController_findByCategory","parameters":[{"name":"categoryId","required":true,"in":"path","description":"Category UUID","schema":{"type":"string"}},{"name":"page","required":true,"in":"query","schema":{"type":"number"}},{"name":"limit","required":true,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"Products retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"404":{"description":"Category not found"},"500":{"description":"Internal server error"}},"summary":"Get products by category","tags":["products"]}},"/api/products/{id}":{"get":{"operationId":"ProductsController_findOne","parameters":[{"name":"id","required":true,"in":"path","description":"Product UUID","schema":{"type":"string"}}],"responses":{"200":{"description":"Product found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"404":{"description":"Product not found"},"500":{"description":"Internal server error"}},"summary":"Get single product by ID","tags":["products"]},"put":{"description":"Updates product details and handles category count if category changes","operationId":"ProductsController_update","parameters":[{"name":"id","required":true,"in":"path","description":"Product UUID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProductDto"}}}},"responses":{"200":{"description":"Product updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"400":{"description":"Invalid input data"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - insufficient role"},"404":{"description":"Product or category not found"},"500":{"description":"Internal server error"}},"security":[{"bearer":[]}],"summary":"Update product (Admin/Manager only)","tags":["products"]},"delete":{"description":"Deletes product if not used in transactions, updates category count","operationId":"ProductsController_remove","parameters":[{"name":"id","required":true,"in":"path","description":"Product UUID","schema":{"type":"string"}}],"responses":{"204":{"description":"Product deleted successfully"},"400":{"description":"Cannot delete product used in transactions"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin only"},"404":{"description":"Product not found"},"500":{"description":"Internal server error"}},"security":[{"bearer":[]}],"summary":"Delete product (Admin only)","tags":["products"]}},"/api/categories":{"post":{"operationId":"CategoriesController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCategoryDto"}}}},"responses":{"201":{"description":"Category successfully created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategoryResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin or Manager role required"},"409":{"description":"Category name already exists"}},"security":[{"bearer":[]}],"summary":"Create new category (Admin/Manager only)","tags":["categories"]},"get":{"operationId":"CategoriesController_findAll","parameters":[],"responses":{"200":{"description":"List of all categories","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CategoryResponseDto"}}}}}},"summary":"Get all categories (Public)","tags":["categories"]}},"/api/categories/{id}":{"get":{"operationId":"CategoriesController_findOne","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Category found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategoryResponseDto"}}}},"404":{"description":"Category not found"}},"summary":"Get single category by ID (Public)","tags":["categories"]},"put":{"operationId":"CategoriesController_update","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCategoryDto"}}}},"responses":{"200":{"description":"Category successfully updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategoryResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin or Manager role required"},"404":{"description":"Category not found"},"409":{"description":"Category name already exists"}},"security":[{"bearer":[]}],"summary":"Update category (Admin/Manager only)","tags":["categories"]},"delete":{"description":"Delete category. Fails if category has products.","operationId":"CategoriesController_remove","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Category successfully deleted"},"400":{"description":"Cannot delete category with products"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"404":{"description":"Category not found"}},"security":[{"bearer":[]}],"summary":"Delete category (Admin only)","tags":["categories"]}},"/api/categories/{id}/products":{"get":{"description":"Returns category details along with associated products. Supports pagination.","operationId":"CategoriesController_findWithProducts","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"type":"number"}}],"responses":{"200":{"description":"Category with products"},"404":{"description":"Category not found"}},"summary":"Get category with its products (Public)","tags":["categories"]}}},"info":{"title":"Retail POS API","description":"RESTful API for Retail POS Flutter Application - Product Management, Transactions, and User Authentication","version":"1.0","contact":{"name":"API Support","url":"https://github.com/yourusername/retail-pos","email":"support@retailpos.com"}},"tags":[{"name":"Authentication","description":"User authentication and authorization"},{"name":"Users","description":"User management endpoints"},{"name":"Products","description":"Product management endpoints"},{"name":"Categories","description":"Category management endpoints"},{"name":"Transactions","description":"Transaction processing endpoints"},{"name":"Sync","description":"Offline sync management"}],"servers":[{"url":"http://localhost:3000","description":"Development"}],"components":{"securitySchemes":{"JWT":{"scheme":"bearer","bearerFormat":"JWT","type":"http","description":"Enter JWT token","name":"Authorization","in":"header"}},"schemas":{"RegisterDto":{"type":"object","properties":{"name":{"type":"string","example":"John Doe","description":"User full name","maxLength":255},"email":{"type":"string","example":"user@retailpos.com","description":"User email address (must be unique)"},"password":{"type":"string","example":"Password123!","description":"Password (min 8 chars, must contain uppercase, lowercase, and number)","minLength":8},"roles":{"type":"array","example":["user"],"description":"User roles","default":["user"],"items":{"type":"string","enum":["admin","manager","cashier","user"]}}},"required":["name","email","password"]},"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"roles":{"type":"array","items":{"type":"string","enum":["admin","manager","cashier","user"]}},"isActive":{"type":"boolean"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","name","email","roles","isActive","createdAt","updatedAt"]},"AuthResponseDto":{"type":"object","properties":{"access_token":{"type":"string","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","description":"JWT access token"},"user":{"$ref":"#/components/schemas/UserResponseDto"}},"required":["access_token","user"]},"LoginDto":{"type":"object","properties":{"email":{"type":"string","example":"admin@retailpos.com","description":"User email address"},"password":{"type":"string","example":"Admin123!","description":"User password (min 8 characters)","minLength":8}},"required":["email","password"]},"CreateUserDto":{"type":"object","properties":{"name":{"type":"string","example":"John Doe","description":"User full name"},"email":{"type":"string","example":"user@retailpos.com","description":"User email address"},"password":{"type":"string","example":"Password123!","description":"User password"},"roles":{"type":"array","example":["user"],"items":{"type":"string","enum":["admin","manager","cashier","user"]}},"isActive":{"type":"boolean","example":true,"description":"User active status"}},"required":["name","email","password"]},"UpdateUserDto":{"type":"object","properties":{"name":{"type":"string","example":"John Doe","description":"User full name"},"email":{"type":"string","example":"user@retailpos.com","description":"User email address"},"roles":{"type":"array","example":["user"],"items":{"type":"string","enum":["admin","manager","cashier","user"]}},"isActive":{"type":"boolean","example":true,"description":"User active status"}}},"PaginationMetaDto":{"type":"object","properties":{"page":{"type":"number","description":"Current page number"},"limit":{"type":"number","description":"Number of items per page"},"total":{"type":"number","description":"Total number of items"},"totalPages":{"type":"number","description":"Total number of pages"},"hasPreviousPage":{"type":"boolean","description":"Has previous page"},"hasNextPage":{"type":"boolean","description":"Has next page"}},"required":["page","limit","total","totalPages","hasPreviousPage","hasNextPage"]},"ApiResponseDto":{"type":"object","properties":{"success":{"type":"boolean","description":"Success status"},"data":{"type":"object","description":"Response data"},"message":{"type":"string","description":"Response message"},"meta":{"$ref":"#/components/schemas/PaginationMetaDto"}},"required":["success","data"]},"CreateProductDto":{"type":"object","properties":{"name":{"type":"string","description":"Product name","example":"Gaming Laptop","minLength":1,"maxLength":255},"description":{"type":"string","description":"Product description","example":"High-performance gaming laptop with RTX 4060","maxLength":1000},"price":{"type":"number","description":"Product price in USD","example":999.99,"minimum":0},"imageUrl":{"type":"string","description":"Product image URL","example":"https://example.com/images/laptop.jpg"},"categoryId":{"type":"string","description":"Category ID","example":"123e4567-e89b-12d3-a456-426614174000"},"stockQuantity":{"type":"number","description":"Stock quantity","example":50,"minimum":0,"default":0},"isAvailable":{"type":"boolean","description":"Product availability status","example":true,"default":true}},"required":["name","price","categoryId"]},"UpdateProductDto":{"type":"object","properties":{"name":{"type":"string","description":"Product name","example":"Gaming Laptop","minLength":1,"maxLength":255},"description":{"type":"string","description":"Product description","example":"High-performance gaming laptop with RTX 4060","maxLength":1000},"price":{"type":"number","description":"Product price in USD","example":999.99,"minimum":0},"imageUrl":{"type":"string","description":"Product image URL","example":"https://example.com/images/laptop.jpg"},"categoryId":{"type":"string","description":"Category ID","example":"123e4567-e89b-12d3-a456-426614174000"},"stockQuantity":{"type":"number","description":"Stock quantity","example":50,"minimum":0,"default":0},"isAvailable":{"type":"boolean","description":"Product availability status","example":true,"default":true}}},"CreateCategoryDto":{"type":"object","properties":{"name":{"type":"string","description":"Category name","example":"Electronics","minLength":1,"maxLength":255},"description":{"type":"string","description":"Category description","example":"Electronic devices and accessories","maxLength":500},"iconPath":{"type":"string","description":"Icon path or name","example":"/icons/electronics.png"},"color":{"type":"string","description":"Category color in hex format","example":"#FF5722"}},"required":["name"]},"CategoryResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"Category ID"},"name":{"type":"string","description":"Category name","example":"Electronics"},"description":{"type":"string","description":"Category description","example":"Electronic devices and accessories"},"iconPath":{"type":"string","description":"Icon path or name","example":"/icons/electronics.png"},"color":{"type":"string","description":"Category color in hex format","example":"#FF5722"},"productCount":{"type":"number","description":"Number of products in this category","example":150},"createdAt":{"format":"date-time","type":"string","description":"Category creation date"},"updatedAt":{"format":"date-time","type":"string","description":"Category last update date"}},"required":["id","name","productCount","createdAt","updatedAt"]},"UpdateCategoryDto":{"type":"object","properties":{"name":{"type":"string","description":"Category name","example":"Electronics","minLength":1,"maxLength":255},"description":{"type":"string","description":"Category description","example":"Electronic devices and accessories","maxLength":500},"iconPath":{"type":"string","description":"Icon path or name","example":"/icons/electronics.png"},"color":{"type":"string","description":"Category color in hex format","example":"#FF5722"}}}}}} \ No newline at end of file +{"openapi":"3.0.0","paths":{"/api":{"get":{"operationId":"AppController_getHello","parameters":[],"responses":{"200":{"description":"API is running"}},"summary":"Health check endpoint","tags":["Health"]}},"/api/health":{"get":{"operationId":"AppController_getHealth","parameters":[],"responses":{"200":{"description":"API health status"}},"summary":"Health check endpoint","tags":["Health"]}},"/api/auth/register":{"post":{"operationId":"AuthController_register","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterDto"}}}},"responses":{"201":{"description":"User successfully registered","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"400":{"description":"Bad request - validation failed"},"409":{"description":"Conflict - email already registered"}},"summary":"Register a new user","tags":["Authentication"]}},"/api/auth/login":{"post":{"operationId":"AuthController_login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginDto"}}}},"responses":{"200":{"description":"User successfully logged in","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"401":{"description":"Unauthorized - invalid credentials"}},"summary":"Login user","tags":["Authentication"]}},"/api/auth/profile":{"get":{"operationId":"AuthController_getProfile","parameters":[],"responses":{"200":{"description":"User profile retrieved successfully"},"401":{"description":"Unauthorized - invalid or missing token"}},"security":[{"bearer":[]}],"summary":"Get current user profile","tags":["Authentication"]}},"/api/auth/refresh":{"post":{"description":"Exchanges a valid refresh token for a new access token and refresh token. Implements token rotation - the old refresh token will be invalidated.","operationId":"AuthController_refreshToken","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenDto"}}}},"responses":{"200":{"description":"Token refreshed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponseDto"}}}},"401":{"description":"Unauthorized - invalid, expired, or revoked refresh token"}},"summary":"Refresh access token using refresh token","tags":["Authentication"]}},"/api/auth/logout":{"post":{"description":"Revokes the refresh token to prevent future token refreshes","operationId":"AuthController_logout","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenDto"}}}},"responses":{"200":{"description":"Logged out successfully"},"400":{"description":"Bad request - refresh token is required"}},"summary":"Logout user","tags":["Authentication"]}},"/api/auth/revoke-all":{"post":{"description":"Revokes all refresh tokens for the authenticated user. Useful for security purposes or when logging out from all devices.","operationId":"AuthController_revokeAllTokens","parameters":[],"responses":{"200":{"description":"All tokens revoked successfully"},"401":{"description":"Unauthorized - invalid or missing access token"}},"security":[{"bearer":[]}],"summary":"Revoke all refresh tokens for current user","tags":["Authentication"]}},"/api/users":{"post":{"operationId":"UsersController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"User successfully created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"409":{"description":"Email already exists"}},"security":[{"bearer":[]}],"summary":"Create new user (Admin only)","tags":["Users"]},"get":{"operationId":"UsersController_findAll","parameters":[],"responses":{"200":{"description":"List of users","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Insufficient permissions"}},"security":[{"bearer":[]}],"summary":"Get all users (Admin/Manager only)","tags":["Users"]}},"/api/users/{id}":{"get":{"operationId":"UsersController_findOne","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"User found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Insufficient permissions"},"404":{"description":"User not found"}},"security":[{"bearer":[]}],"summary":"Get user by ID (Admin/Manager only)","tags":["Users"]},"patch":{"operationId":"UsersController_update","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"User successfully updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"404":{"description":"User not found"},"409":{"description":"Email already exists"}},"security":[{"bearer":[]}],"summary":"Update user (Admin only)","tags":["Users"]},"delete":{"operationId":"UsersController_remove","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"User successfully deleted"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"404":{"description":"User not found"}},"security":[{"bearer":[]}],"summary":"Delete user (Admin only)","tags":["Users"]}},"/api/products":{"get":{"operationId":"ProductsController_findAll","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"type":"number"}},{"name":"categoryId","required":false,"in":"query","description":"Filter by category ID","schema":{"example":"123e4567-e89b-12d3-a456-426614174000","type":"string"}},{"name":"search","required":false,"in":"query","description":"Search query for product name or description","schema":{"example":"laptop","type":"string"}},{"name":"minPrice","required":false,"in":"query","description":"Minimum price filter","schema":{"minimum":0,"example":100,"type":"number"}},{"name":"maxPrice","required":false,"in":"query","description":"Maximum price filter","schema":{"minimum":0,"example":1000,"type":"number"}},{"name":"isAvailable","required":false,"in":"query","description":"Filter by availability status","schema":{"example":true,"type":"boolean"}}],"responses":{"200":{"description":"Products retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"500":{"description":"Internal server error"}},"summary":"Get all products with pagination and filters","tags":["products"]},"post":{"description":"Creates a new product and updates category product count","operationId":"ProductsController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductDto"}}}},"responses":{"201":{"description":"Product created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"400":{"description":"Invalid input data"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - insufficient role"},"404":{"description":"Category not found"},"500":{"description":"Internal server error"}},"security":[{"bearer":[]}],"summary":"Create new product (Admin/Manager only)","tags":["products"]}},"/api/products/search":{"get":{"operationId":"ProductsController_search","parameters":[{"name":"q","required":true,"in":"query","schema":{"type":"string"}},{"name":"page","required":true,"in":"query","schema":{"type":"number"}},{"name":"limit","required":true,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"Products found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"400":{"description":"Invalid search query"},"500":{"description":"Internal server error"}},"summary":"Search products by name or description","tags":["products"]}},"/api/products/category/{categoryId}":{"get":{"operationId":"ProductsController_findByCategory","parameters":[{"name":"categoryId","required":true,"in":"path","description":"Category UUID","schema":{"type":"string"}},{"name":"page","required":true,"in":"query","schema":{"type":"number"}},{"name":"limit","required":true,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":"Products retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"404":{"description":"Category not found"},"500":{"description":"Internal server error"}},"summary":"Get products by category","tags":["products"]}},"/api/products/{id}":{"get":{"operationId":"ProductsController_findOne","parameters":[{"name":"id","required":true,"in":"path","description":"Product UUID","schema":{"type":"string"}}],"responses":{"200":{"description":"Product found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"404":{"description":"Product not found"},"500":{"description":"Internal server error"}},"summary":"Get single product by ID","tags":["products"]},"put":{"description":"Updates product details and handles category count if category changes","operationId":"ProductsController_update","parameters":[{"name":"id","required":true,"in":"path","description":"Product UUID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProductDto"}}}},"responses":{"200":{"description":"Product updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseDto"}}}},"400":{"description":"Invalid input data"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - insufficient role"},"404":{"description":"Product or category not found"},"500":{"description":"Internal server error"}},"security":[{"bearer":[]}],"summary":"Update product (Admin/Manager only)","tags":["products"]},"delete":{"description":"Deletes product if not used in transactions, updates category count","operationId":"ProductsController_remove","parameters":[{"name":"id","required":true,"in":"path","description":"Product UUID","schema":{"type":"string"}}],"responses":{"204":{"description":"Product deleted successfully"},"400":{"description":"Cannot delete product used in transactions"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin only"},"404":{"description":"Product not found"},"500":{"description":"Internal server error"}},"security":[{"bearer":[]}],"summary":"Delete product (Admin only)","tags":["products"]}},"/api/categories":{"post":{"operationId":"CategoriesController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCategoryDto"}}}},"responses":{"201":{"description":"Category successfully created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategoryResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin or Manager role required"},"409":{"description":"Category name already exists"}},"security":[{"bearer":[]}],"summary":"Create new category (Admin/Manager only)","tags":["categories"]},"get":{"operationId":"CategoriesController_findAll","parameters":[],"responses":{"200":{"description":"List of all categories","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CategoryResponseDto"}}}}}},"summary":"Get all categories (Public)","tags":["categories"]}},"/api/categories/{id}":{"get":{"operationId":"CategoriesController_findOne","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Category found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategoryResponseDto"}}}},"404":{"description":"Category not found"}},"summary":"Get single category by ID (Public)","tags":["categories"]},"put":{"operationId":"CategoriesController_update","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCategoryDto"}}}},"responses":{"200":{"description":"Category successfully updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategoryResponseDto"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin or Manager role required"},"404":{"description":"Category not found"},"409":{"description":"Category name already exists"}},"security":[{"bearer":[]}],"summary":"Update category (Admin/Manager only)","tags":["categories"]},"delete":{"description":"Delete category. Fails if category has products.","operationId":"CategoriesController_remove","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"204":{"description":"Category successfully deleted"},"400":{"description":"Cannot delete category with products"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin role required"},"404":{"description":"Category not found"}},"security":[{"bearer":[]}],"summary":"Delete category (Admin only)","tags":["categories"]}},"/api/categories/{id}/products":{"get":{"description":"Returns category details along with associated products. Supports pagination.","operationId":"CategoriesController_findWithProducts","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of items per page","schema":{"minimum":1,"maximum":100,"default":20,"type":"number"}}],"responses":{"200":{"description":"Category with products"},"404":{"description":"Category not found"}},"summary":"Get category with its products (Public)","tags":["categories"]}}},"info":{"title":"Retail POS API","description":"RESTful API for Retail POS Flutter Application - Product Management, Transactions, and User Authentication","version":"1.0","contact":{"name":"API Support","url":"https://github.com/yourusername/retail-pos","email":"support@retailpos.com"}},"tags":[{"name":"Authentication","description":"User authentication and authorization"},{"name":"Users","description":"User management endpoints"},{"name":"Products","description":"Product management endpoints"},{"name":"Categories","description":"Category management endpoints"},{"name":"Transactions","description":"Transaction processing endpoints"},{"name":"Sync","description":"Offline sync management"}],"servers":[{"url":"http://localhost:3000","description":"Development"}],"components":{"securitySchemes":{"JWT":{"scheme":"bearer","bearerFormat":"JWT","type":"http","description":"Enter JWT token","name":"Authorization","in":"header"}},"schemas":{"RegisterDto":{"type":"object","properties":{"name":{"type":"string","example":"John Doe","description":"User full name","maxLength":255},"email":{"type":"string","example":"user@retailpos.com","description":"User email address (must be unique)"},"password":{"type":"string","example":"Password123!","description":"Password (min 8 chars, must contain uppercase, lowercase, and number)","minLength":8},"roles":{"type":"array","example":["user"],"description":"User roles","default":["user"],"items":{"type":"string","enum":["admin","manager","cashier","user"]}}},"required":["name","email","password"]},"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"roles":{"type":"array","items":{"type":"string","enum":["admin","manager","cashier","user"]}},"isActive":{"type":"boolean"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","name","email","roles","isActive","createdAt","updatedAt"]},"AuthResponseDto":{"type":"object","properties":{"access_token":{"type":"string","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","description":"JWT access token"},"refresh_token":{"type":"string","example":"a1b2c3d4e5f6...","description":"Refresh token for obtaining new access tokens"},"user":{"$ref":"#/components/schemas/UserResponseDto"}},"required":["access_token","refresh_token","user"]},"LoginDto":{"type":"object","properties":{"email":{"type":"string","example":"admin@retailpos.com","description":"User email address"},"password":{"type":"string","example":"Admin123!","description":"User password (min 8 characters)","minLength":8}},"required":["email","password"]},"RefreshTokenDto":{"type":"object","properties":{"refreshToken":{"type":"string","example":"a1b2c3d4e5f6...","description":"Refresh token received during login"}},"required":["refreshToken"]},"CreateUserDto":{"type":"object","properties":{"name":{"type":"string","example":"John Doe","description":"User full name"},"email":{"type":"string","example":"user@retailpos.com","description":"User email address"},"password":{"type":"string","example":"Password123!","description":"User password"},"roles":{"type":"array","example":["user"],"items":{"type":"string","enum":["admin","manager","cashier","user"]}},"isActive":{"type":"boolean","example":true,"description":"User active status"}},"required":["name","email","password"]},"UpdateUserDto":{"type":"object","properties":{"name":{"type":"string","example":"John Doe","description":"User full name"},"email":{"type":"string","example":"user@retailpos.com","description":"User email address"},"roles":{"type":"array","example":["user"],"items":{"type":"string","enum":["admin","manager","cashier","user"]}},"isActive":{"type":"boolean","example":true,"description":"User active status"}}},"PaginationMetaDto":{"type":"object","properties":{"page":{"type":"number","description":"Current page number"},"limit":{"type":"number","description":"Number of items per page"},"total":{"type":"number","description":"Total number of items"},"totalPages":{"type":"number","description":"Total number of pages"},"hasPreviousPage":{"type":"boolean","description":"Has previous page"},"hasNextPage":{"type":"boolean","description":"Has next page"}},"required":["page","limit","total","totalPages","hasPreviousPage","hasNextPage"]},"ApiResponseDto":{"type":"object","properties":{"success":{"type":"boolean","description":"Success status"},"data":{"type":"object","description":"Response data"},"message":{"type":"string","description":"Response message"},"meta":{"$ref":"#/components/schemas/PaginationMetaDto"}},"required":["success","data"]},"CreateProductDto":{"type":"object","properties":{"name":{"type":"string","description":"Product name","example":"Gaming Laptop","minLength":1,"maxLength":255},"description":{"type":"string","description":"Product description","example":"High-performance gaming laptop with RTX 4060","maxLength":1000},"price":{"type":"number","description":"Product price in USD","example":999.99,"minimum":0},"imageUrl":{"type":"string","description":"Product image URL","example":"https://example.com/images/laptop.jpg"},"categoryId":{"type":"string","description":"Category ID","example":"123e4567-e89b-12d3-a456-426614174000"},"stockQuantity":{"type":"number","description":"Stock quantity","example":50,"minimum":0,"default":0},"isAvailable":{"type":"boolean","description":"Product availability status","example":true,"default":true}},"required":["name","price","categoryId"]},"UpdateProductDto":{"type":"object","properties":{"name":{"type":"string","description":"Product name","example":"Gaming Laptop","minLength":1,"maxLength":255},"description":{"type":"string","description":"Product description","example":"High-performance gaming laptop with RTX 4060","maxLength":1000},"price":{"type":"number","description":"Product price in USD","example":999.99,"minimum":0},"imageUrl":{"type":"string","description":"Product image URL","example":"https://example.com/images/laptop.jpg"},"categoryId":{"type":"string","description":"Category ID","example":"123e4567-e89b-12d3-a456-426614174000"},"stockQuantity":{"type":"number","description":"Stock quantity","example":50,"minimum":0,"default":0},"isAvailable":{"type":"boolean","description":"Product availability status","example":true,"default":true}}},"CreateCategoryDto":{"type":"object","properties":{"name":{"type":"string","description":"Category name","example":"Electronics","minLength":1,"maxLength":255},"description":{"type":"string","description":"Category description","example":"Electronic devices and accessories","maxLength":500},"iconPath":{"type":"string","description":"Icon path or name","example":"/icons/electronics.png"},"color":{"type":"string","description":"Category color in hex format","example":"#FF5722"}},"required":["name"]},"CategoryResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"Category ID"},"name":{"type":"string","description":"Category name","example":"Electronics"},"description":{"type":"string","description":"Category description","example":"Electronic devices and accessories"},"iconPath":{"type":"string","description":"Icon path or name","example":"/icons/electronics.png"},"color":{"type":"string","description":"Category color in hex format","example":"#FF5722"},"productCount":{"type":"number","description":"Number of products in this category","example":150},"createdAt":{"format":"date-time","type":"string","description":"Category creation date"},"updatedAt":{"format":"date-time","type":"string","description":"Category last update date"}},"required":["id","name","productCount","createdAt","updatedAt"]},"UpdateCategoryDto":{"type":"object","properties":{"name":{"type":"string","description":"Category name","example":"Electronics","minLength":1,"maxLength":255},"description":{"type":"string","description":"Category description","example":"Electronic devices and accessories","maxLength":500},"iconPath":{"type":"string","description":"Icon path or name","example":"/icons/electronics.png"},"color":{"type":"string","description":"Category color in hex format","example":"#FF5722"}}}}}} \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c2dd560..9676cd1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -31,11 +31,11 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: - connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index b98b30e..2c5e6c0 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -1,13 +1,16 @@ import 'package:dio/dio.dart'; import '../constants/api_constants.dart'; +import '../storage/secure_storage.dart'; import 'api_interceptor.dart'; +import 'refresh_token_interceptor.dart'; /// Dio HTTP client configuration class DioClient { late final Dio _dio; String? _authToken; + final SecureStorage? secureStorage; - DioClient() { + DioClient({this.secureStorage}) { _dio = Dio( BaseOptions( baseUrl: ApiConstants.fullBaseUrl, @@ -34,6 +37,17 @@ class DioClient { }, ), ); + + // Add refresh token interceptor (if secureStorage is provided) + if (secureStorage != null) { + _dio.interceptors.add( + RefreshTokenInterceptor( + dio: _dio, + secureStorage: secureStorage!, + ), + ); + print('🔧 DioClient: Refresh token interceptor added'); + } } Dio get dio => _dio; diff --git a/lib/core/network/refresh_token_interceptor.dart b/lib/core/network/refresh_token_interceptor.dart new file mode 100644 index 0000000..8dd9076 --- /dev/null +++ b/lib/core/network/refresh_token_interceptor.dart @@ -0,0 +1,104 @@ +import 'package:dio/dio.dart'; +import '../constants/api_constants.dart'; +import '../storage/secure_storage.dart'; + +/// Interceptor to handle automatic token refresh on 401 errors +class RefreshTokenInterceptor extends Interceptor { + final Dio dio; + final SecureStorage secureStorage; + + // To prevent infinite loop of refresh attempts + bool _isRefreshing = false; + + RefreshTokenInterceptor({ + required this.dio, + required this.secureStorage, + }); + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + // Check if error is 401 Unauthorized + if (err.response?.statusCode == 401) { + print('🔄 Interceptor: Got 401 error, attempting token refresh...'); + + // Avoid infinite refresh loop + if (_isRefreshing) { + print('❌ Interceptor: Already refreshing, skip'); + return handler.next(err); + } + + // Check if this is NOT the refresh token endpoint itself + final requestPath = err.requestOptions.path; + if (requestPath.contains('refresh')) { + print('❌ Interceptor: 401 on refresh endpoint, cannot retry'); + // Clear tokens as refresh token is invalid + await secureStorage.deleteAllTokens(); + return handler.next(err); + } + + try { + _isRefreshing = true; + + // Get refresh token from storage + final refreshToken = await secureStorage.getRefreshToken(); + if (refreshToken == null) { + print('❌ Interceptor: No refresh token available'); + await secureStorage.deleteAllTokens(); + return handler.next(err); + } + + print('🔄 Interceptor: Calling refresh token API...'); + + // Call refresh token API + final response = await dio.post( + ApiConstants.refreshToken, + data: {'refreshToken': refreshToken}, + options: Options( + headers: { + // Don't include auth header for refresh request + ApiConstants.authorization: null, + }, + ), + ); + + if (response.statusCode == 200) { + // Extract new tokens from response + final responseData = response.data['data'] as Map; + final newAccessToken = responseData['access_token'] as String; + final newRefreshToken = responseData['refresh_token'] as String; + + print('✅ Interceptor: Got new tokens, saving...'); + + // Save new tokens + await secureStorage.saveAccessToken(newAccessToken); + await secureStorage.saveRefreshToken(newRefreshToken); + + // Update the failed request with new token + err.requestOptions.headers[ApiConstants.authorization] = 'Bearer $newAccessToken'; + + print('🔄 Interceptor: Retrying original request...'); + + // Retry the original request + final retryResponse = await dio.fetch(err.requestOptions); + + print('✅ Interceptor: Original request succeeded after refresh'); + _isRefreshing = false; + return handler.resolve(retryResponse); + } else { + print('❌ Interceptor: Refresh token API returned ${response.statusCode}'); + await secureStorage.deleteAllTokens(); + _isRefreshing = false; + return handler.next(err); + } + } catch (e) { + print('❌ Interceptor: Error during token refresh: $e'); + await secureStorage.deleteAllTokens(); + _isRefreshing = false; + return handler.next(err); + } + } + + // Not a 401 error, pass through + return handler.next(err); + } +} diff --git a/lib/core/providers/core_providers.dart b/lib/core/providers/core_providers.dart index 30cf47d..c59caff 100644 --- a/lib/core/providers/core_providers.dart +++ b/lib/core/providers/core_providers.dart @@ -7,10 +7,12 @@ part 'core_providers.g.dart'; /// Provider for DioClient (singleton) /// /// This is the global HTTP client used across the entire app. -/// It's configured with interceptors, timeout settings, and auth token injection. +/// It's configured with interceptors, timeout settings, auth token injection, +/// and automatic token refresh on 401 errors. @Riverpod(keepAlive: true) DioClient dioClient(Ref ref) { - return DioClient(); + final storage = ref.watch(secureStorageProvider); + return DioClient(secureStorage: storage); } /// Provider for SecureStorage (singleton) diff --git a/lib/core/providers/core_providers.g.dart b/lib/core/providers/core_providers.g.dart index 773592e..7c327b3 100644 --- a/lib/core/providers/core_providers.g.dart +++ b/lib/core/providers/core_providers.g.dart @@ -11,7 +11,8 @@ part of 'core_providers.dart'; /// Provider for DioClient (singleton) /// /// This is the global HTTP client used across the entire app. -/// It's configured with interceptors, timeout settings, and auth token injection. +/// It's configured with interceptors, timeout settings, auth token injection, +/// and automatic token refresh on 401 errors. @ProviderFor(dioClient) const dioClientProvider = DioClientProvider._(); @@ -19,7 +20,8 @@ const dioClientProvider = DioClientProvider._(); /// Provider for DioClient (singleton) /// /// This is the global HTTP client used across the entire app. -/// It's configured with interceptors, timeout settings, and auth token injection. +/// It's configured with interceptors, timeout settings, auth token injection, +/// and automatic token refresh on 401 errors. final class DioClientProvider extends $FunctionalProvider @@ -27,7 +29,8 @@ final class DioClientProvider /// Provider for DioClient (singleton) /// /// This is the global HTTP client used across the entire app. - /// It's configured with interceptors, timeout settings, and auth token injection. + /// It's configured with interceptors, timeout settings, auth token injection, + /// and automatic token refresh on 401 errors. const DioClientProvider._() : super( from: null, @@ -61,7 +64,7 @@ final class DioClientProvider } } -String _$dioClientHash() => r'895f0dc2f8d5eab562ad65390e5c6d4a1f722b0d'; +String _$dioClientHash() => r'a9edc35e0e918bfa8e6c4e3ecd72412fba383cb2'; /// Provider for SecureStorage (singleton) /// diff --git a/lib/features/auth/data/datasources/auth_remote_datasource.dart b/lib/features/auth/data/datasources/auth_remote_datasource.dart index 2d51314..3dee2da 100644 --- a/lib/features/auth/data/datasources/auth_remote_datasource.dart +++ b/lib/features/auth/data/datasources/auth_remote_datasource.dart @@ -18,8 +18,8 @@ abstract class AuthRemoteDataSource { /// Get current user profile Future getProfile(); - /// Refresh access token - Future refreshToken(); + /// Refresh access token using refresh token + Future refreshToken(String refreshToken); } /// Implementation of AuthRemoteDataSource @@ -119,21 +119,28 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { } @override - Future refreshToken() async { + Future refreshToken(String refreshToken) async { try { - final response = await dioClient.post(ApiConstants.refreshToken); + print('📡 DataSource: Calling refresh token API...'); + final response = await dioClient.post( + ApiConstants.refreshToken, + data: {'refreshToken': refreshToken}, + ); if (response.statusCode == ApiConstants.statusOk) { - // API returns nested structure: {success, data: {access_token, user}, message} + // API returns nested structure: {success, data: {access_token, refresh_token, user}, message} // Extract the 'data' object final responseData = response.data['data'] as Map; + print('📡 DataSource: Token refreshed successfully'); return AuthResponseModel.fromJson(responseData); } else { throw ServerException('Token refresh failed with status: ${response.statusCode}'); } } on DioException catch (e) { + print('❌ DataSource: Refresh token failed - ${e.message}'); throw _handleDioError(e); } catch (e) { + print('❌ DataSource: Unexpected error refreshing token: $e'); throw ServerException('Unexpected error refreshing token: $e'); } } diff --git a/lib/features/auth/data/models/auth_response_model.dart b/lib/features/auth/data/models/auth_response_model.dart index adbf284..421e837 100644 --- a/lib/features/auth/data/models/auth_response_model.dart +++ b/lib/features/auth/data/models/auth_response_model.dart @@ -5,6 +5,7 @@ import 'user_model.dart'; class AuthResponseModel extends AuthResponse { const AuthResponseModel({ required super.accessToken, + required super.refreshToken, required super.user, }); @@ -12,6 +13,7 @@ class AuthResponseModel extends AuthResponse { factory AuthResponseModel.fromJson(Map json) { return AuthResponseModel( accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, user: UserModel.fromJson(json['user'] as Map), ); } @@ -20,6 +22,7 @@ class AuthResponseModel extends AuthResponse { Map toJson() { return { 'access_token': accessToken, + 'refresh_token': refreshToken, 'user': (user as UserModel).toJson(), }; } @@ -28,6 +31,7 @@ class AuthResponseModel extends AuthResponse { factory AuthResponseModel.fromEntity(AuthResponse authResponse) { return AuthResponseModel( accessToken: authResponse.accessToken, + refreshToken: authResponse.refreshToken, user: authResponse.user, ); } @@ -36,6 +40,7 @@ class AuthResponseModel extends AuthResponse { AuthResponse toEntity() { return AuthResponse( accessToken: accessToken, + refreshToken: refreshToken, user: user, ); } diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index 87dd6c9..c6f9991 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -35,12 +35,13 @@ class AuthRepositoryImpl implements AuthRepository { print('🔐 Repository: Got response, token length=${authResponse.accessToken.length}'); - // Save token to secure storage only if rememberMe is true + // Save tokens to secure storage only if rememberMe is true if (rememberMe) { await secureStorage.saveAccessToken(authResponse.accessToken); - print('🔐 Repository: Token saved to secure storage (persistent)'); + await secureStorage.saveRefreshToken(authResponse.refreshToken); + print('🔐 Repository: Access token and refresh token saved to secure storage (persistent)'); } else { - print('🔐 Repository: Token NOT saved (session only - rememberMe is false)'); + print('🔐 Repository: Tokens NOT saved (session only - rememberMe is false)'); } // Set token in Dio client for subsequent requests (always for current session) @@ -86,8 +87,9 @@ class AuthRepositoryImpl implements AuthRepository { ); final authResponse = await remoteDataSource.register(registerDto); - // Save token to secure storage + // Save both tokens to secure storage await secureStorage.saveAccessToken(authResponse.accessToken); + await secureStorage.saveRefreshToken(authResponse.refreshToken); // Set token in Dio client for subsequent requests dioClient.setAuthToken(authResponse.accessToken); @@ -127,24 +129,44 @@ class AuthRepositoryImpl implements AuthRepository { @override Future> refreshToken() async { try { - final authResponse = await remoteDataSource.refreshToken(); + print('🔄 Repository: Starting token refresh...'); - // Update token in secure storage + // Get refresh token from storage + final storedRefreshToken = await secureStorage.getRefreshToken(); + if (storedRefreshToken == null) { + print('❌ Repository: No refresh token found in storage'); + return const Left(UnauthorizedFailure('No refresh token available')); + } + + print('🔄 Repository: Calling datasource with refresh token...'); + final authResponse = await remoteDataSource.refreshToken(storedRefreshToken); + + // Update both tokens in secure storage (token rotation) await secureStorage.saveAccessToken(authResponse.accessToken); + await secureStorage.saveRefreshToken(authResponse.refreshToken); + print('🔄 Repository: New tokens saved to secure storage'); // Update token in Dio client dioClient.setAuthToken(authResponse.accessToken); + print('🔄 Repository: New access token set in DioClient'); return Right(authResponse); } on UnauthorizedException catch (e) { + print('❌ Repository: Unauthorized during refresh - ${e.message}'); + // Clear invalid tokens + await secureStorage.deleteAllTokens(); return Left(UnauthorizedFailure(e.message)); } on TokenExpiredException catch (e) { + print('❌ Repository: Token expired during refresh - ${e.message}'); + // Clear expired tokens + await secureStorage.deleteAllTokens(); return Left(TokenExpiredFailure(e.message)); } on NetworkException catch (e) { return Left(NetworkFailure(e.message)); } on ServerException catch (e) { return Left(ServerFailure(e.message)); } catch (e) { + print('❌ Repository: Unexpected error during refresh: $e'); return Left(ServerFailure('Unexpected error: $e')); } } diff --git a/lib/features/auth/domain/entities/auth_response.dart b/lib/features/auth/domain/entities/auth_response.dart index a96765e..77bbf25 100644 --- a/lib/features/auth/domain/entities/auth_response.dart +++ b/lib/features/auth/domain/entities/auth_response.dart @@ -4,13 +4,15 @@ import 'user.dart'; /// Authentication response entity class AuthResponse extends Equatable { final String accessToken; + final String refreshToken; final User user; const AuthResponse({ required this.accessToken, + required this.refreshToken, required this.user, }); @override - List get props => [accessToken, user]; + List get props => [accessToken, refreshToken, user]; } diff --git a/lib/features/categories/data/models/category_model.dart b/lib/features/categories/data/models/category_model.dart index ac9ba69..ab0e9d2 100644 --- a/lib/features/categories/data/models/category_model.dart +++ b/lib/features/categories/data/models/category_model.dart @@ -25,10 +25,10 @@ class CategoryModel extends HiveObject { final int productCount; @HiveField(6) - final DateTime createdAt; + final DateTime? createdAt; @HiveField(7) - final DateTime updatedAt; + final DateTime? updatedAt; CategoryModel({ required this.id, @@ -37,8 +37,8 @@ class CategoryModel extends HiveObject { this.iconPath, this.color, required this.productCount, - required this.createdAt, - required this.updatedAt, + this.createdAt, + this.updatedAt, }); /// Convert to domain entity @@ -78,8 +78,12 @@ class CategoryModel extends HiveObject { iconPath: json['iconPath'] as String?, color: json['color'] as String?, productCount: json['productCount'] as int? ?? 0, - createdAt: DateTime.parse(json['createdAt'] as String), - updatedAt: DateTime.parse(json['updatedAt'] as String), + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, ); } @@ -92,8 +96,8 @@ class CategoryModel extends HiveObject { 'iconPath': iconPath, 'color': color, 'productCount': productCount, - 'createdAt': createdAt.toIso8601String(), - 'updatedAt': updatedAt.toIso8601String(), + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), }; } diff --git a/lib/features/categories/data/models/category_model.g.dart b/lib/features/categories/data/models/category_model.g.dart index 0590a73..8f187ea 100644 --- a/lib/features/categories/data/models/category_model.g.dart +++ b/lib/features/categories/data/models/category_model.g.dart @@ -23,8 +23,8 @@ class CategoryModelAdapter extends TypeAdapter { iconPath: fields[3] as String?, color: fields[4] as String?, productCount: (fields[5] as num).toInt(), - createdAt: fields[6] as DateTime, - updatedAt: fields[7] as DateTime, + createdAt: fields[6] as DateTime?, + updatedAt: fields[7] as DateTime?, ); } diff --git a/lib/features/categories/domain/entities/category.dart b/lib/features/categories/domain/entities/category.dart index 9854a59..5771c8c 100644 --- a/lib/features/categories/domain/entities/category.dart +++ b/lib/features/categories/domain/entities/category.dart @@ -8,8 +8,8 @@ class Category extends Equatable { final String? iconPath; final String? color; final int productCount; - final DateTime createdAt; - final DateTime updatedAt; + final DateTime? createdAt; + final DateTime? updatedAt; const Category({ required this.id, @@ -18,8 +18,8 @@ class Category extends Equatable { this.iconPath, this.color, required this.productCount, - required this.createdAt, - required this.updatedAt, + this.createdAt, + this.updatedAt, }); @override diff --git a/lib/features/products/data/models/product_model.dart b/lib/features/products/data/models/product_model.dart index 508d6c5..143b606 100644 --- a/lib/features/products/data/models/product_model.dart +++ b/lib/features/products/data/models/product_model.dart @@ -31,10 +31,10 @@ class ProductModel extends HiveObject { final bool isAvailable; @HiveField(8) - final DateTime createdAt; + final DateTime? createdAt; @HiveField(9) - final DateTime updatedAt; + final DateTime? updatedAt; ProductModel({ required this.id, @@ -45,8 +45,8 @@ class ProductModel extends HiveObject { required this.categoryId, required this.stockQuantity, required this.isAvailable, - required this.createdAt, - required this.updatedAt, + this.createdAt, + this.updatedAt, }); /// Convert to domain entity @@ -92,8 +92,12 @@ class ProductModel extends HiveObject { categoryId: json['categoryId'] as String, stockQuantity: json['stockQuantity'] as int? ?? 0, isAvailable: json['isAvailable'] as bool? ?? true, - createdAt: DateTime.parse(json['createdAt'] as String), - updatedAt: DateTime.parse(json['updatedAt'] as String), + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : null, ); } @@ -108,8 +112,8 @@ class ProductModel extends HiveObject { 'categoryId': categoryId, 'stockQuantity': stockQuantity, 'isAvailable': isAvailable, - 'createdAt': createdAt.toIso8601String(), - 'updatedAt': updatedAt.toIso8601String(), + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), }; } } diff --git a/lib/features/products/data/models/product_model.g.dart b/lib/features/products/data/models/product_model.g.dart index 8dfb252..987efa7 100644 --- a/lib/features/products/data/models/product_model.g.dart +++ b/lib/features/products/data/models/product_model.g.dart @@ -25,8 +25,8 @@ class ProductModelAdapter extends TypeAdapter { categoryId: fields[5] as String, stockQuantity: (fields[6] as num).toInt(), isAvailable: fields[7] as bool, - createdAt: fields[8] as DateTime, - updatedAt: fields[9] as DateTime, + createdAt: fields[8] as DateTime?, + updatedAt: fields[9] as DateTime?, ); } diff --git a/lib/features/products/domain/entities/product.dart b/lib/features/products/domain/entities/product.dart index bf880d5..ff0f1f7 100644 --- a/lib/features/products/domain/entities/product.dart +++ b/lib/features/products/domain/entities/product.dart @@ -10,8 +10,8 @@ class Product extends Equatable { final String categoryId; final int stockQuantity; final bool isAvailable; - final DateTime createdAt; - final DateTime updatedAt; + final DateTime? createdAt; + final DateTime? updatedAt; const Product({ required this.id, @@ -22,8 +22,8 @@ class Product extends Equatable { required this.categoryId, required this.stockQuantity, required this.isAvailable, - required this.createdAt, - required this.updatedAt, + this.createdAt, + this.updatedAt, }); @override diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index 9d5fa70..54338f5 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -296,14 +296,18 @@ class ProductDetailPage extends ConsumerWidget { context, icon: Icons.calendar_today, label: 'Created', - value: dateFormat.format(product.createdAt), + value: product.createdAt != null + ? dateFormat.format(product.createdAt!) + : 'N/A', ), const Divider(height: 24), _buildInfoRow( context, icon: Icons.update, label: 'Last Updated', - value: dateFormat.format(product.updatedAt), + value: product.updatedAt != null + ? dateFormat.format(product.updatedAt!) + : 'N/A', ), ], ), diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index 3daaf98..4aa362d 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -493,10 +493,18 @@ class _ProductsPageState extends ConsumerState { sorted.sort((a, b) => b.price.compareTo(a.price)); break; case ProductSortOption.newest: - sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + sorted.sort((a, b) { + final aDate = a.createdAt ?? DateTime(2000); + final bDate = b.createdAt ?? DateTime(2000); + return bDate.compareTo(aDate); + }); break; case ProductSortOption.oldest: - sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + sorted.sort((a, b) { + final aDate = a.createdAt ?? DateTime(2000); + final bDate = b.createdAt ?? DateTime(2000); + return aDate.compareTo(bDate); + }); break; } diff --git a/lib/features/products/presentation/providers/filtered_products_provider.dart b/lib/features/products/presentation/providers/filtered_products_provider.dart index 4c2b113..f4a43af 100644 --- a/lib/features/products/presentation/providers/filtered_products_provider.dart +++ b/lib/features/products/presentation/providers/filtered_products_provider.dart @@ -91,10 +91,18 @@ class SortedProducts extends _$SortedProducts { sorted.sort((a, b) => b.price.compareTo(a.price)); break; case ProductSortOption.newest: - sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + sorted.sort((a, b) { + final aDate = a.createdAt ?? DateTime(2000); + final bDate = b.createdAt ?? DateTime(2000); + return bDate.compareTo(aDate); + }); break; case ProductSortOption.oldest: - sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + sorted.sort((a, b) { + final aDate = a.createdAt ?? DateTime(2000); + final bDate = b.createdAt ?? DateTime(2000); + return aDate.compareTo(bDate); + }); break; } diff --git a/lib/features/products/presentation/providers/filtered_products_provider.g.dart b/lib/features/products/presentation/providers/filtered_products_provider.g.dart index a5ce7b9..b913789 100644 --- a/lib/features/products/presentation/providers/filtered_products_provider.g.dart +++ b/lib/features/products/presentation/providers/filtered_products_provider.g.dart @@ -131,7 +131,7 @@ final class SortedProductsProvider } } -String _$sortedProductsHash() => r'653f1e9af8c188631dadbfe9ed7d944c6876d2d3'; +String _$sortedProductsHash() => r'8a526ae12a15ca7decc8880ebbd083df455875a8'; /// Provider for sorted products /// Adds sorting capability on top of filtered products diff --git a/pubspec.lock b/pubspec.lock index a307beb..d6def18 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -472,14 +472,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - get_it: - dependency: "direct main" - description: - name: get_it - sha256: a4292e7cf67193f8e7c1258203104eb2a51ec8b3a04baa14695f4064c144297b - url: "https://pub.dev" - source: hosted - version: "8.2.0" glob: dependency: transitive description: