Add configuration file and update server settings

- Introduced a new `config.yaml` file for managing server, database, cache, queue, AI, scraping, logging, and rate limiting configurations.
- Updated `docker-compose.yml` to reflect the new server port (8081) and adjusted health check endpoints accordingly.
- Modified `Dockerfile` to expose the new server port.
- Updated `README.md` to reflect changes in server configuration and added documentation for the new configuration structure.
- Added test scripts for server and Swagger documentation testing.
- Refactored customer domain structure to align with new configuration settings and improve maintainability.
This commit is contained in:
n.nakhostin
2025-08-02 12:37:04 +03:30
parent 06dc1d5b7a
commit 9119e2383e
30 changed files with 7273 additions and 1106 deletions
+554
View File
@@ -0,0 +1,554 @@
# Customer Management Module
This module implements a complete User Management system for the Tender Management System, allowing administrators to register users who can later access the mobile application.
## Features
### 1. User Registration (by Admin)
- **Fields**: Full name, username, email, mobile number, password (hashed), national ID (optional), gender (enum), birthdate (optional), profile image (optional), user status (active/inactive), registration date
- **Uniqueness**: Ensures uniqueness for email, mobile, and username
- **Validation**: Comprehensive input validation using govalidator
### 2. Authentication for Mobile Users
- **Login**: Secure login via email or mobile number + password
- **JWT Tokens**: Access and refresh token generation
- **Password Security**: bcrypt hashing for secure password storage
### 3. Device Token Management
- **Storage**: Saves device_token and device_type (Android/iOS) when user logs in
- **Updates**: Updates token if user logs in from new device or re-installs app
- **Cleanup**: Endpoint to delete token on logout
### 4. User Profile Management
- **View/Edit**: Personal info (name, email, mobile, etc.)
- **Password Change**: Secure password change endpoint
- **Profile Picture**: Upload/update profile picture support
### 5. Push Notification Ready
- **Multiple Tokens**: Stores multiple device tokens per user
- **User Association**: Tokens tied to correct user ID
- **Admin Access**: API to get all active device tokens for admin push campaigns
### 6. Admin Panel APIs
- **List/Search/Filter**: Users by name, mobile, email, registration date, and status
- **Status Management**: Activate/deactivate users
- **Role Support**: Framework for role assignment (user, moderator, etc.)
### 7. Security & Best Practices
- **Input Validation**: Comprehensive validation and sanitization
- **Rate Limiting**: Framework for authentication endpoint rate limiting
- **Password Security**: bcrypt hashing with configurable cost
- **HTTP Status Codes**: Proper status codes and error messages
- **Activity Logging**: User activities (login, logout, profile update)
## Architecture
### Clean Architecture Implementation
- **Domain Layer**: Business entities, aggregates, interfaces, and core business rules
- **Service Layer**: Business logic and use cases
- **Handler Layer**: HTTP controllers and request/response handling
- **Repository Layer**: Data access implementations
- **Infrastructure Layer**: External dependencies (DB, cache, queues, config)
### File Structure
```
internal/customer/
├── entity.go # Core domain entities and types
├── form.go # Request/response forms with validation
├── repository.go # Repository interface and implementation
├── service.go # Business logic and use cases
├── handler.go # HTTP controllers and request/response handling
├── validation.go # Custom validation rules
└── README.md # This documentation
```
## Database Schema
### Customer Collection
```javascript
{
"_id": "uuid",
"full_name": "string",
"username": "string (unique)",
"email": "string (unique)",
"mobile": "string (unique)",
"password": "string (hashed)",
"national_id": "string (optional)",
"gender": "enum (male|female|other) (optional)",
"birthdate": "int64 (unix timestamp) (optional)",
"profile_image": "string (url) (optional)",
"status": "enum (active|inactive)",
"company_id": "uuid (optional)",
"is_verified": "boolean",
"device_tokens": [
{
"token": "string",
"device_type": "enum (android|ios)",
"created_at": "int64 (unix timestamp)",
"updated_at": "int64 (unix timestamp)"
}
],
"last_login_at": "int64 (unix timestamp) (optional)",
"created_at": "int64 (unix timestamp)",
"updated_at": "int64 (unix timestamp)"
}
```
### Indexes
- `email` (unique)
- `username` (unique)
- `mobile` (unique)
- `company_id`
- `status`
- `gender`
- `created_at` (descending)
- Text index on `full_name`, `email`, `mobile`, `username`
## API Endpoints
### Public Endpoints (No Authentication Required)
#### 1. Register Customer
```http
POST /api/customers/register
Content-Type: application/json
{
"full_name": "John Doe",
"username": "johndoe",
"email": "john@example.com",
"mobile": "+1234567890",
"password": "securepassword123",
"national_id": "123456789",
"gender": "male",
"birthdate": 631152000,
"profile_image": "https://example.com/avatar.jpg",
"company_id": "uuid-optional"
}
```
**Response:**
```json
{
"success": true,
"message": "Customer registered successfully",
"data": {
"id": "uuid",
"full_name": "John Doe",
"username": "johndoe",
"email": "john@example.com",
"mobile": "+1234567890",
"national_id": "123456789",
"gender": "male",
"birthdate": 631152000,
"profile_image": "https://example.com/avatar.jpg",
"status": "active",
"company_id": "uuid",
"is_verified": false,
"device_tokens": [],
"created_at": 1703123456,
"updated_at": 1703123456
}
}
```
#### 2. Login
```http
POST /api/customers/login
Content-Type: application/json
{
"email_or_mobile": "john@example.com",
"password": "securepassword123"
}
```
**Response:**
```json
{
"success": true,
"message": "Login successful",
"data": {
"customer": {
"id": "uuid",
"full_name": "John Doe",
"username": "johndoe",
"email": "john@example.com",
"mobile": "+1234567890",
"status": "active",
"is_verified": false,
"device_tokens": [],
"last_login_at": 1703123456,
"created_at": 1703123456,
"updated_at": 1703123456
},
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "refresh_token_here",
"expires_at": 1703127056
}
}
```
#### 3. Refresh Token
```http
POST /api/customers/refresh-token
Content-Type: application/json
{
"refresh_token": "refresh_token_here"
}
```
### Protected Endpoints (Authentication Required)
#### 4. Get Profile
```http
GET /api/customers/profile
Authorization: Bearer <access_token>
X-Customer-ID: <customer_uuid>
```
#### 5. Update Profile
```http
PUT /api/customers/profile
Authorization: Bearer <access_token>
X-Customer-ID: <customer_uuid>
Content-Type: application/json
{
"full_name": "John Updated Doe",
"email": "john.updated@example.com",
"mobile": "+1234567891",
"gender": "male",
"birthdate": 631152000,
"profile_image": "https://example.com/new-avatar.jpg"
}
```
#### 6. Change Password
```http
PUT /api/customers/change-password
Authorization: Bearer <access_token>
X-Customer-ID: <customer_uuid>
Content-Type: application/json
{
"old_password": "currentpassword",
"new_password": "newsecurepassword123"
}
```
#### 7. Add Device Token
```http
POST /api/customers/device-token
Authorization: Bearer <access_token>
X-Customer-ID: <customer_uuid>
Content-Type: application/json
{
"device_token": "fcm_token_here",
"device_type": "android"
}
```
#### 8. Remove Device Token
```http
DELETE /api/customers/device-token
Authorization: Bearer <access_token>
X-Customer-ID: <customer_uuid>
Content-Type: application/json
{
"device_token": "fcm_token_here"
}
```
#### 9. Logout
```http
POST /api/customers/logout
Authorization: Bearer <access_token>
X-Customer-ID: <customer_uuid>
Content-Type: application/json
{
"device_token": "fcm_token_here"
}
```
### Admin Endpoints (Admin Authentication Required)
#### 10. List Customers
```http
GET /api/admin/customers?search=john&status=active&gender=male&limit=20&offset=0&sort_by=created_at&sort_order=desc
Authorization: Bearer <admin_access_token>
```
**Response:**
```json
{
"success": true,
"message": "Customers retrieved successfully",
"data": [
{
"id": "uuid",
"full_name": "John Doe",
"username": "johndoe",
"email": "john@example.com",
"mobile": "+1234567890",
"status": "active",
"is_verified": false,
"device_tokens": [],
"last_login_at": 1703123456,
"created_at": 1703123456,
"updated_at": 1703123456
}
],
"meta": {
"total": 100,
"limit": 20,
"offset": 0,
"page": 1,
"pages": 5
}
}
```
#### 11. Get Customer by ID
```http
GET /api/admin/customers/{customer_id}
Authorization: Bearer <admin_access_token>
```
#### 12. Update Customer Status
```http
PUT /api/admin/customers/{customer_id}/status
Authorization: Bearer <admin_access_token>
Content-Type: application/json
{
"status": "inactive"
}
```
#### 13. Get All Device Tokens
```http
GET /api/admin/customers/device-tokens
Authorization: Bearer <admin_access_token>
```
**Response:**
```json
{
"success": true,
"message": "Device tokens retrieved successfully",
"data": [
{
"token": "fcm_token_1",
"device_type": "android",
"created_at": 1703123456,
"updated_at": 1703123456
},
{
"token": "fcm_token_2",
"device_type": "ios",
"created_at": 1703123457,
"updated_at": 1703123457
}
]
}
```
## Validation Rules
### Registration Form
- `full_name`: Required, 2-100 characters
- `username`: Required, alphanumeric, 3-30 characters, unique
- `email`: Required, valid email format, unique
- `mobile`: Required, 10-15 characters, unique
- `password`: Required, 8-128 characters
- `national_id`: Optional, 5-20 characters
- `gender`: Optional, must be "male", "female", or "other"
- `birthdate`: Optional, valid Unix timestamp
- `profile_image`: Optional, valid URL
- `company_id`: Optional, valid UUID
### Login Form
- `email_or_mobile`: Required
- `password`: Required
### Update Form
- All fields optional with same validation rules as registration
- Uniqueness checks only if field is being updated
### Device Token Form
- `device_token`: Required, 32-255 characters
- `device_type`: Required, must be "android" or "ios"
### List Customers Form
- `search`: Optional, text search
- `status`: Optional, must be "active" or "inactive"
- `gender`: Optional, must be "male", "female", or "other"
- `company_id`: Optional, valid UUID
- `limit`: Optional, 1-100
- `offset`: Optional, minimum 0
- `sort_by`: Optional, must be "full_name", "email", "mobile", "created_at", or "last_login_at"
- `sort_order`: Optional, must be "asc" or "desc"
## Error Handling
### Standard Error Response Format
```json
{
"success": false,
"message": "Error message",
"error": {
"code": "ERROR_CODE",
"message": "Detailed error message",
"details": "Additional error details"
}
}
```
### Common Error Codes
- `BAD_REQUEST`: Invalid request format or parameters
- `VALIDATION_ERROR`: Input validation failed
- `UNAUTHORIZED`: Authentication required or failed
- `FORBIDDEN`: Insufficient permissions
- `NOT_FOUND`: Resource not found
- `CONFLICT`: Resource already exists
- `INTERNAL_SERVER_ERROR`: Server error
### HTTP Status Codes
- `200 OK`: Success
- `201 Created`: Resource created successfully
- `400 Bad Request`: Invalid request
- `401 Unauthorized`: Authentication required
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource not found
- `409 Conflict`: Resource conflict
- `422 Unprocessable Entity`: Validation error
- `500 Internal Server Error`: Server error
## Security Features
### Password Security
- bcrypt hashing with configurable cost
- Minimum 8 characters required
- Maximum 128 characters allowed
### Input Validation
- Comprehensive validation using govalidator
- Custom validators for Unix timestamps
- SQL injection prevention through parameterized queries
- XSS prevention through input sanitization
### Authentication
- JWT-based token system
- Access and refresh token separation
- Token expiration handling
- Secure token generation
### Data Protection
- Passwords never serialized in responses
- Sensitive data logging prevention
- Input sanitization and validation
## Usage Examples
### Register a New Customer (Admin)
```bash
curl -X POST http://localhost:8081/api/customers/register \
-H "Content-Type: application/json" \
-d '{
"full_name": "John Doe",
"username": "johndoe",
"email": "john@example.com",
"mobile": "+1234567890",
"password": "securepassword123",
"gender": "male",
"birthdate": 631152000
}'
```
### Login as Customer
```bash
curl -X POST http://localhost:8081/api/customers/login \
-H "Content-Type: application/json" \
-d '{
"email_or_mobile": "john@example.com",
"password": "securepassword123"
}'
```
### Update Profile
```bash
curl -X PUT http://localhost:8081/api/customers/profile \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access_token>" \
-H "X-Customer-ID: <customer_uuid>" \
-d '{
"full_name": "John Updated Doe",
"email": "john.updated@example.com"
}'
```
### List Customers (Admin)
```bash
curl -X GET "http://localhost:8081/api/admin/customers?search=john&status=active&limit=10" \
-H "Authorization: Bearer <admin_access_token>"
```
## Future Enhancements
### Planned Features
1. **JWT Implementation**: Proper JWT token generation and validation
2. **Email Verification**: Email verification system
3. **Password Reset**: Forgot password functionality
4. **Role-Based Access Control**: User roles and permissions
5. **Rate Limiting**: API rate limiting implementation
6. **Audit Logging**: Comprehensive audit trail
7. **Bulk Operations**: Bulk user import/export
8. **Advanced Search**: Full-text search with filters
9. **File Upload**: Profile picture upload handling
10. **Two-Factor Authentication**: 2FA support
### Technical Improvements
1. **Caching**: Redis caching for frequently accessed data
2. **Background Jobs**: Async processing for heavy operations
3. **Monitoring**: Health checks and metrics
4. **Testing**: Comprehensive unit and integration tests
5. **Documentation**: OpenAPI/Swagger documentation
6. **Performance**: Query optimization and indexing
7. **Security**: Additional security measures
8. **Scalability**: Horizontal scaling support
## Dependencies
### Required Packages
- `github.com/google/uuid`: UUID generation
- `github.com/labstack/echo/v4`: HTTP framework
- `github.com/asaskevich/govalidator`: Input validation
- `golang.org/x/crypto/bcrypt`: Password hashing
- `go.mongodb.org/mongo-driver`: MongoDB driver
### Configuration
- MongoDB connection string
- JWT secret key
- Password hashing cost
- Token expiration times
- Rate limiting settings
## Contributing
When contributing to this module:
1. Follow the existing code structure and patterns
2. Add comprehensive validation for new fields
3. Include proper error handling and logging
4. Write unit tests for new functionality
5. Update documentation for new endpoints
6. Follow security best practices
7. Use Unix timestamps for all time fields
8. Implement proper pagination for list endpoints
9. Add appropriate indexes for new fields
10. Follow the flat domain structure pattern
+7 -8
View File
@@ -6,11 +6,11 @@ import (
type CustomerAggregate struct {
ID uuid.UUID `json:"_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
FullName string `json:"full_name"`
Username string `json:"username"`
Mobile string `json:"mobile"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
IsVerified bool `json:"is_verified"`
LastLoginAt *int64 `json:"last_login_at,omitempty"` // Unix timestamp
CreatedAt int64 `json:"created_at"` // Unix timestamp
@@ -20,16 +20,15 @@ type CustomerAggregate struct {
func NewCustomerAggregate(c Customer) CustomerAggregate {
customer := CustomerAggregate{
ID: c.ID,
FirstName: c.FirstName,
LastName: c.LastName,
Mobile: c.Mobile,
FullName: c.FullName,
Username: c.Username,
Email: c.Email,
IsActive: c.IsActive,
Mobile: c.Mobile,
Status: string(c.Status),
IsVerified: c.IsVerified,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.LastLoginAt != nil {
customer.LastLoginAt = c.LastLoginAt
}
+50 -13
View File
@@ -4,19 +4,56 @@ import (
"github.com/google/uuid"
)
// Gender represents user gender
type Gender string
const (
GenderMale Gender = "male"
GenderFemale Gender = "female"
GenderOther Gender = "other"
)
// CustomerStatus represents customer account status
type CustomerStatus string
const (
CustomerStatusActive CustomerStatus = "active"
CustomerStatusInactive CustomerStatus = "inactive"
)
// DeviceType represents the type of device
type DeviceType string
const (
DeviceTypeAndroid DeviceType = "android"
DeviceTypeIOS DeviceType = "ios"
)
// DeviceToken represents a device token for push notifications
type DeviceToken struct {
Token string `bson:"token"`
DeviceType DeviceType `bson:"device_type"`
CreatedAt int64 `bson:"created_at"` // Unix timestamp
UpdatedAt int64 `bson:"updated_at"` // Unix timestamp
}
// Customer represents a mobile app user (customer)
type Customer struct {
ID uuid.UUID `bson:"_id"`
Email string `bson:"email"`
Password string `bson:"password"` // Never serialize password
FirstName string `bson:"first_name"`
LastName string `bson:"last_name"`
Mobile string `bson:"mobile"`
CompanyID uuid.UUID `bson:"company_id"`
IsActive bool `bson:"is_active"`
IsVerified bool `bson:"is_verified"`
DeviceTokens []string `bson:"device_tokens"` // For push notifications
LastLoginAt *int64 `bson:"last_login_at,omitempty"` // Unix timestamp
CreatedAt int64 `bson:"created_at"` // Unix timestamp
UpdatedAt int64 `bson:"updated_at"` // Unix timestamp
ID uuid.UUID `bson:"_id"`
FullName string `bson:"full_name"`
Username string `bson:"username"`
Email string `bson:"email"`
Mobile string `bson:"mobile"`
Password string `bson:"password"` // Never serialize password
NationalID *string `bson:"national_id,omitempty"`
Gender *Gender `bson:"gender,omitempty"`
Birthdate *int64 `bson:"birthdate,omitempty"` // Unix timestamp
ProfileImage *string `bson:"profile_image,omitempty"`
Status CustomerStatus `bson:"status"`
CompanyID *uuid.UUID `bson:"company_id,omitempty"`
IsVerified bool `bson:"is_verified"`
DeviceTokens []DeviceToken `bson:"device_tokens"` // For push notifications
LastLoginAt *int64 `bson:"last_login_at,omitempty"` // Unix timestamp
CreatedAt int64 `bson:"created_at"` // Unix timestamp
UpdatedAt int64 `bson:"updated_at"` // Unix timestamp
}
+119 -9
View File
@@ -1,17 +1,35 @@
package customer
// Customer Authentication DTOs
type RegisterForm struct {
FirstName string `json:"first_name" valid:"required,alpha,length(2|50)"`
LastName string `json:"last_name" valid:"required,alpha,length(2|50)"`
Email string `json:"email" valid:"required,email"`
Mobile string `json:"mobile,omitempty" valid:"optional,length(10|15)"`
Password string `json:"password" valid:"required,length(8|128)"`
// Customer Registration DTOs
type RegisterCustomerForm struct {
FullName string `json:"full_name" valid:"required,length(2|100)"`
Username string `json:"username" valid:"required,alphanum,length(3|30)"`
Email string `json:"email" valid:"required,email"`
Mobile string `json:"mobile" valid:"required,length(10|15)"`
Password string `json:"password" valid:"required,length(8|128)"`
NationalID *string `json:"national_id,omitempty" valid:"optional,length(5|20)"`
Gender *string `json:"gender,omitempty" valid:"optional,in(male|female|other)"`
Birthdate *int64 `json:"birthdate,omitempty" valid:"optional,unix_timestamp"`
ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"`
CompanyID *string `json:"company_id,omitempty" valid:"optional,uuid"`
}
type UpdateCustomerForm struct {
FullName *string `json:"full_name,omitempty" valid:"optional,length(2|100)"`
Username *string `json:"username,omitempty" valid:"optional,alphanum,length(3|30)"`
Email *string `json:"email,omitempty" valid:"optional,email"`
Mobile *string `json:"mobile,omitempty" valid:"optional,length(10|15)"`
NationalID *string `json:"national_id,omitempty" valid:"optional,length(5|20)"`
Gender *string `json:"gender,omitempty" valid:"optional,in(male|female|other)"`
Birthdate *int64 `json:"birthdate,omitempty" valid:"optional,unix_timestamp"`
ProfileImage *string `json:"profile_image,omitempty" valid:"optional,url"`
Status *string `json:"status,omitempty" valid:"optional,in(active|inactive)"`
}
// Customer Authentication DTOs
type LoginForm struct {
Email string `json:"email" valid:"required,email"`
Password string `json:"password" valid:"required"`
EmailOrMobile string `json:"email_or_mobile" valid:"required"`
Password string `json:"password" valid:"required"`
}
type RefreshTokenForm struct {
@@ -22,3 +40,95 @@ type ChangePasswordForm struct {
OldPassword string `json:"old_password" valid:"required"`
NewPassword string `json:"new_password" valid:"required,length(8|128)"`
}
// Device Token DTOs
type AddDeviceTokenForm struct {
DeviceToken string `json:"device_token" valid:"required,length(32|255)"`
DeviceType string `json:"device_type" valid:"required,in(android|ios)"`
}
type RemoveDeviceTokenForm struct {
DeviceToken string `json:"device_token" valid:"required"`
}
// Admin DTOs
type ListCustomersForm struct {
Search *string `query:"search" valid:"optional"`
Status *string `query:"status" valid:"optional,in(active|inactive)"`
Gender *string `query:"gender" valid:"optional,in(male|female|other)"`
CompanyID *string `query:"company_id" valid:"optional,uuid"`
Limit *int `query:"limit" valid:"optional,range(1|100)"`
Offset *int `query:"offset" valid:"optional,min(0)"`
SortBy *string `query:"sort_by" valid:"optional,in(full_name|email|mobile|created_at|last_login_at)"`
SortOrder *string `query:"sort_order" valid:"optional,in(asc|desc)"`
}
type UpdateCustomerStatusForm struct {
Status string `json:"status" valid:"required,in(active|inactive)"`
}
// Response DTOs
type CustomerResponse struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Username string `json:"username"`
Email string `json:"email"`
Mobile string `json:"mobile"`
NationalID *string `json:"national_id,omitempty"`
Gender *string `json:"gender,omitempty"`
Birthdate *int64 `json:"birthdate,omitempty"`
ProfileImage *string `json:"profile_image,omitempty"`
Status string `json:"status"`
CompanyID *string `json:"company_id,omitempty"`
IsVerified bool `json:"is_verified"`
DeviceTokens []DeviceToken `json:"device_tokens"`
LastLoginAt *int64 `json:"last_login_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type AuthResponse struct {
Customer *CustomerResponse `json:"customer"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
}
type DeviceTokenResponse struct {
Token string `json:"token"`
DeviceType string `json:"device_type"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// Helper function to convert Customer to CustomerResponse
func (c *Customer) ToResponse() *CustomerResponse {
gender := ""
if c.Gender != nil {
gender = string(*c.Gender)
}
companyID := ""
if c.CompanyID != nil {
companyID = c.CompanyID.String()
}
return &CustomerResponse{
ID: c.ID.String(),
FullName: c.FullName,
Username: c.Username,
Email: c.Email,
Mobile: c.Mobile,
NationalID: c.NationalID,
Gender: &gender,
Birthdate: c.Birthdate,
ProfileImage: c.ProfileImage,
Status: string(c.Status),
CompanyID: &companyID,
IsVerified: c.IsVerified,
DeviceTokens: c.DeviceTokens,
LastLoginAt: c.LastLoginAt,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
+489 -139
View File
@@ -1,221 +1,571 @@
package customer
import (
"tm/pkg/logger"
"net/http"
"tm/pkg/response"
"github.com/asaskevich/govalidator"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
)
// CustomerHandler handles authentication related HTTP requests for mobile customers
type CustomerHandler struct {
service CustomerService
logger logger.Logger
// Handler handles HTTP requests for customer operations
type Handler struct {
service Service
}
// NewCustomerHandler creates a new customer authentication handler
func NewHandler(
customerAuthService CustomerService,
logger logger.Logger,
) *CustomerHandler {
return &CustomerHandler{
service: customerAuthService,
logger: logger,
// NewHandler creates a new customer handler
func NewHandler(service Service) *Handler {
return &Handler{
service: service,
}
}
// Register handles customer registration
// @Summary Register new customer
// @Description Create a new customer account for mobile app
// @Tags customer-auth
// RegisterRoutes registers all customer routes
func (h *Handler) RegisterRoutes(e *echo.Echo) {
v1 := e.Group("/api/v1")
v1.POST("/customers/register", h.RegisterCustomer)
// Public routes (no authentication required)
v1.POST("/customers/login", h.Login)
v1.POST("/customers/refresh-token", h.RefreshToken)
// Protected routes (authentication required)
customers := v1.Group("/customers")
customers.Use(h.authMiddleware) // TODO: Implement auth middleware
customers.GET("/profile", h.GetProfile)
customers.PUT("/profile", h.UpdateProfile)
customers.PUT("/change-password", h.ChangePassword)
customers.POST("/device-token", h.AddDeviceToken)
customers.DELETE("/device-token", h.RemoveDeviceToken)
customers.POST("/logout", h.Logout)
// Admin routes (admin authentication required)
admin := v1.Group("/admin/customers")
admin.Use(h.adminAuthMiddleware) // TODO: Implement admin auth middleware
admin.POST("/register", h.RegisterCustomer)
admin.GET("", h.ListCustomers)
admin.GET("/:id", h.GetCustomerByID)
admin.PUT("/:id/status", h.UpdateCustomerStatus)
admin.GET("/device-tokens", h.GetAllDeviceTokens)
}
// RegisterCustomer handles customer registration
// @Summary Register a new customer
// @Description Register a new customer with the provided information
// @Tags customers
// @Accept json
// @Produce json
// @Param request body domain.CustomerRegisterRequest true "Customer registration request"
// @Success 201 {object} response.APIResponse{data=domain.CustomerAuthResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 409 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/mobile/auth/register [post]
func (h *CustomerHandler) Register(c echo.Context) error {
var req RegisterForm
// @Param customer body RegisterCustomerForm true "Customer registration information"
// @Success 201 {object} response.APIResponse{data=customer.CustomerResponse} "Customer registered successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 409 {object} response.APIResponse "Customer already exists"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /admin/customers/register [post]
func (h *Handler) RegisterCustomer(c echo.Context) error {
var form RegisterCustomerForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(&req); err != nil {
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
authResponse, err := h.service.Register(c.Request().Context(), req)
// Register customer
customer, err := h.service.RegisterCustomer(c.Request().Context(), &form)
if err != nil {
return response.InternalServerError(c, "Registration failed")
return response.Conflict(c, err.Error())
}
return response.Created(c, authResponse, "Customer registered successfully")
return response.Created(c, customer.ToResponse(), "Customer registered successfully")
}
// Login handles customer authentication
// @Summary Customer login
// @Description Authenticate customer and return tokens
// @Tags customer-auth
// @Description Authenticate customer with email and password
// @Tags customers
// @Accept json
// @Produce json
// @Param request body domain.LoginRequest true "Login request"
// @Success 200 {object} response.APIResponse{data=domain.CustomerAuthResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/mobile/auth/login [post]
func (h *CustomerHandler) Login(c echo.Context) error {
var req LoginForm
// @Param credentials body LoginForm true "Login credentials"
// @Success 200 {object} response.APIResponse{data=customer.AuthResponse} "Login successful"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Invalid credentials"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/login [post]
func (h *Handler) Login(c echo.Context) error {
var form LoginForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(&req); err != nil {
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
authResponse, err := h.service.Login(c.Request().Context(), req)
// Authenticate customer
authResponse, err := h.service.Login(c.Request().Context(), &form)
if err != nil {
return response.InternalServerError(c, "Login failed")
return response.Unauthorized(c, err.Error())
}
return response.Success(c, authResponse, "Login successful")
}
// RefreshToken handles token refresh for customers
// @Summary Refresh customer access token
// @Description Generate new access token using refresh token
// @Tags customer-auth
// RefreshToken handles token refresh
// @Summary Refresh access token
// @Description Refresh the access token using a refresh token
// @Tags customers
// @Accept json
// @Produce json
// @Param request body RefreshTokenRequest true "Refresh token request"
// @Success 200 {object} response.APIResponse{data=domain.CustomerAuthResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/mobile/auth/refresh [post]
func (h *CustomerHandler) RefreshToken(c echo.Context) error {
var req RefreshTokenForm
// @Param token body RefreshTokenForm true "Refresh token information"
// @Success 200 {object} response.APIResponse{data=customer.AuthResponse} "Token refreshed successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Invalid refresh token"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/refresh-token [post]
func (h *Handler) RefreshToken(c echo.Context) error {
var form RefreshTokenForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(&req); err != nil {
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Call service
authResponse, err := h.service.RefreshToken(c.Request().Context(), req.RefreshToken)
// Refresh token
authResponse, err := h.service.RefreshToken(c.Request().Context(), form.RefreshToken)
if err != nil {
return response.InternalServerError(c, "Token refresh failed")
return response.Unauthorized(c, err.Error())
}
return response.Success(c, authResponse, "Token refreshed successfully")
}
// ChangePassword handles password change for customers
// @Summary Change customer password
// @Description Change authenticated customer's password
// @Tags customer-auth
// GetProfile retrieves customer profile
// @Summary Get customer profile
// @Description Retrieve the authenticated customer's profile information
// @Tags customers
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body ChangePasswordRequest true "Change password request"
// @Success 200 {object} response.APIResponse
// @Failure 400 {object} response.APIResponse
// @Failure 401 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/mobile/auth/change-password [post]
func (h *CustomerHandler) ChangePassword(c echo.Context) error {
var req ChangePasswordForm
// Bind request body
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request format", err.Error())
// @Security BearerAuth
// @Success 200 {object} response.APIResponse{data=customer.CustomerResponse} "Profile retrieved successfully"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 404 {object} response.APIResponse "Customer not found"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/profile [get]
func (h *Handler) GetProfile(c echo.Context) error {
// Get customer ID from context (set by auth middleware)
customerID, err := h.getCustomerIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "Invalid authentication")
}
// Validate request
if _, err := govalidator.ValidateStruct(&req); err != nil {
// Get customer
customer, err := h.service.GetCustomerByID(c.Request().Context(), customerID)
if err != nil {
return response.NotFound(c, "Customer not found")
}
return response.Success(c, customer.ToResponse(), "Profile retrieved successfully")
}
// UpdateProfile updates customer profile
// @Summary Update customer profile
// @Description Update the authenticated customer's profile information
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param profile body UpdateCustomerForm true "Profile update information"
// @Success 200 {object} response.APIResponse{data=customer.CustomerResponse} "Profile updated successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/profile [put]
func (h *Handler) UpdateProfile(c echo.Context) error {
// Get customer ID from context
customerID, err := h.getCustomerIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "Invalid authentication")
}
var form UpdateCustomerForm
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Get customer from context (set by auth middleware)
// customer, ok := c.Get("customer").(*domain.Customer)
// if !ok {
// return response.Unauthorized(c, "Authentication required")
// }
// Update customer
customer, err := h.service.UpdateCustomer(c.Request().Context(), customerID, &form)
if err != nil {
return response.BadRequest(c, err.Error(), "")
}
// Call service
// err := h.customerAuthService.ChangePassword(c.Request().Context(), customer.ID, req.OldPassword, req.NewPassword)
// if err != nil {
// h.logger.Error("Customer password change failed", map[string]interface{}{
// "error": err.Error(),
// "customer_id": customer.ID.String(),
// })
return response.Success(c, customer.ToResponse(), "Profile updated successfully")
}
// if err.Error() == "invalid old password" {
// return response.BadRequest(c, "Invalid old password", "")
// }
// ChangePassword changes customer password
// @Summary Change customer password
// @Description Change the authenticated customer's password
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param password body ChangePasswordForm true "Password change information"
// @Success 200 {object} response.APIResponse "Password changed successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/change-password [put]
func (h *Handler) ChangePassword(c echo.Context) error {
// Get customer ID from context
customerID, err := h.getCustomerIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "Invalid authentication")
}
// return response.InternalServerError(c, "Password change failed")
// }
var form ChangePasswordForm
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Change password
err = h.service.ChangePassword(c.Request().Context(), customerID, &form)
if err != nil {
return response.BadRequest(c, err.Error(), "")
}
return response.Success(c, nil, "Password changed successfully")
}
// GetProfile returns current customer profile
// @Summary Get customer profile
// @Description Get authenticated customer's profile information
// @Tags customer-auth
// AddDeviceToken adds a device token for push notifications
// @Summary Add device token
// @Description Add a device token for push notifications
// @Tags customers
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} response.APIResponse{data=domain.Customer}
// @Failure 401 {object} response.APIResponse
// @Router /api/mobile/auth/profile [get]
func (h *CustomerHandler) GetProfile(c echo.Context) error {
// Get customer from context (set by auth middleware)
// customer, ok := c.Get("customer").(*domain.Customer)
// if !ok {
// return response.Unauthorized(c, "Authentication required")
// }
// @Security BearerAuth
// @Param device_token body AddDeviceTokenForm true "Device token information"
// @Success 200 {object} response.APIResponse "Device token added successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/device-token [post]
func (h *Handler) AddDeviceToken(c echo.Context) error {
// Get customer ID from context
customerID, err := h.getCustomerIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "Invalid authentication")
}
return response.Success(c, nil, "Profile retrieved successfully")
var form AddDeviceTokenForm
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Add device token
err = h.service.AddDeviceToken(c.Request().Context(), customerID, &form)
if err != nil {
return response.BadRequest(c, err.Error(), "")
}
return response.Success(c, nil, "Device token added successfully")
}
// RemoveDeviceToken removes a device token
// @Summary Remove device token
// @Description Remove a device token for push notifications
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param device_token body RemoveDeviceTokenForm true "Device token information"
// @Success 200 {object} response.APIResponse "Device token removed successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/device-token [delete]
func (h *Handler) RemoveDeviceToken(c echo.Context) error {
// Get customer ID from context
customerID, err := h.getCustomerIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "Invalid authentication")
}
var form RemoveDeviceTokenForm
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Remove device token
err = h.service.RemoveDeviceToken(c.Request().Context(), customerID, &form)
if err != nil {
return response.BadRequest(c, err.Error(), "")
}
return response.Success(c, nil, "Device token removed successfully")
}
// Logout handles customer logout
// @Summary Customer logout
// @Description Logout customer (client should remove tokens)
// @Tags customer-auth
// @Description Logout customer and invalidate device token
// @Tags customers
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} response.APIResponse
// @Router /api/mobile/auth/logout [post]
func (h *CustomerHandler) Logout(c echo.Context) error {
// In a stateless JWT implementation, logout is typically handled client-side
// by removing the tokens from storage. However, you could implement token
// blacklisting here if needed.
// @Security BearerAuth
// @Param logout body object true "Logout request with device token"
// @Success 200 {object} response.APIResponse "Logged out successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /customers/logout [post]
func (h *Handler) Logout(c echo.Context) error {
// Get customer ID from context
customerID, err := h.getCustomerIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "Invalid authentication")
}
// customer, ok := c.Get("customer").(*domain.Customer)
// if ok {
// h.logger.Info("Customer logged out", map[string]interface{}{
// "customer_id": customer.ID.String(),
// "email": customer.Email,
// })
// }
// Get device token from request body
var req struct {
DeviceToken string `json:"device_token" valid:"required"`
}
if err := c.Bind(&req); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate request
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Logout
err = h.service.Logout(c.Request().Context(), customerID, req.DeviceToken)
if err != nil {
return response.BadRequest(c, err.Error(), "")
}
return response.Success(c, nil, "Logged out successfully")
}
// ListCustomers lists customers (admin only)
// @Summary List customers
// @Description Retrieve a paginated list of customers (admin only)
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Number of items per page" default(20)
// @Param search query string false "Search term"
// @Success 200 {object} response.APIResponse{data=[]customer.CustomerResponse} "Customers retrieved successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /admin/customers [get]
func (h *Handler) ListCustomers(c echo.Context) error {
var form ListCustomersForm
// Bind query parameters
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid query parameters", err.Error())
}
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// List customers
customers, total, err := h.service.ListCustomers(c.Request().Context(), &form)
if err != nil {
return response.InternalServerError(c, err.Error())
}
// Convert to responses
var responses []*CustomerResponse
for _, customer := range customers {
responses = append(responses, customer.ToResponse())
}
// Create metadata
meta := &response.Meta{
Total: total,
Limit: 20, // Default limit
Offset: 0, // Default offset
}
if form.Limit != nil {
meta.Limit = *form.Limit
}
if form.Offset != nil {
meta.Offset = *form.Offset
}
return response.SuccessWithMeta(c, responses, meta, "Customers retrieved successfully")
}
// GetCustomerByID retrieves a customer by ID (admin only)
// @Summary Get customer by ID
// @Description Retrieve customer information by ID (admin only)
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Customer ID"
// @Success 200 {object} response.APIResponse{data=customer.CustomerResponse} "Customer retrieved successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 404 {object} response.APIResponse "Customer not found"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /admin/customers/{id} [get]
func (h *Handler) GetCustomerByID(c echo.Context) error {
// Parse customer ID
customerIDStr := c.Param("id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
return response.BadRequest(c, "Invalid customer ID", err.Error())
}
// Get customer
customer, err := h.service.GetCustomerByID(c.Request().Context(), customerID)
if err != nil {
return response.NotFound(c, "Customer not found")
}
return response.Success(c, customer.ToResponse(), "Customer retrieved successfully")
}
// UpdateCustomerStatus updates customer status (admin only)
// @Summary Update customer status
// @Description Update customer status (admin only)
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Customer ID"
// @Param status body UpdateCustomerStatusForm true "Status update information"
// @Success 200 {object} response.APIResponse "Customer status updated successfully"
// @Failure 400 {object} response.APIResponse "Bad request"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 404 {object} response.APIResponse "Customer not found"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /admin/customers/{id}/status [put]
func (h *Handler) UpdateCustomerStatus(c echo.Context) error {
// Parse customer ID
customerIDStr := c.Param("id")
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
return response.BadRequest(c, "Invalid customer ID", err.Error())
}
var form UpdateCustomerStatusForm
if err := c.Bind(&form); err != nil {
return response.BadRequest(c, "Invalid request body", err.Error())
}
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Update status
err = h.service.UpdateCustomerStatus(c.Request().Context(), customerID, &form)
if err != nil {
return response.BadRequest(c, err.Error(), "")
}
return response.Success(c, nil, "Customer status updated successfully")
}
// GetAllDeviceTokens retrieves all device tokens (admin only)
// @Summary Get all device tokens
// @Description Retrieve all device tokens (admin only)
// @Tags customers
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.APIResponse{data=[]string} "Device tokens retrieved successfully"
// @Failure 401 {object} response.APIResponse "Unauthorized"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Router /admin/customers/device-tokens [get]
func (h *Handler) GetAllDeviceTokens(c echo.Context) error {
// Get all device tokens
tokens, err := h.service.GetAllDeviceTokens(c.Request().Context())
if err != nil {
return response.InternalServerError(c, err.Error())
}
return response.Success(c, tokens, "Device tokens retrieved successfully")
}
// Helper methods
// getCustomerIDFromContext extracts customer ID from context
func (h *Handler) getCustomerIDFromContext(c echo.Context) (uuid.UUID, error) {
// TODO: Implement proper JWT token validation and extraction
// For now, we'll use a header-based approach
customerIDStr := c.Request().Header.Get("X-Customer-ID")
if customerIDStr == "" {
return uuid.Nil, echo.NewHTTPError(http.StatusUnauthorized, "Missing customer ID")
}
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
return uuid.Nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid customer ID")
}
return customerID, nil
}
// authMiddleware is a placeholder for authentication middleware
func (h *Handler) authMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// TODO: Implement proper JWT token validation
// For now, we'll just pass through
return next(c)
}
}
// adminAuthMiddleware is a placeholder for admin authentication middleware
func (h *Handler) adminAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// TODO: Implement proper admin authentication
// For now, we'll just pass through
return next(c)
}
}
+226 -15
View File
@@ -5,6 +5,7 @@ import (
"errors"
"time"
"tm/pkg/logger"
mongopkg "tm/pkg/mongo"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson"
@@ -17,12 +18,17 @@ type Repository interface {
Create(ctx context.Context, customer *Customer) error
GetByID(ctx context.Context, id uuid.UUID) (*Customer, error)
GetByEmail(ctx context.Context, email string) (*Customer, error)
GetByUsername(ctx context.Context, username string) (*Customer, error)
GetByMobile(ctx context.Context, mobile string) (*Customer, error)
Update(ctx context.Context, customer *Customer) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, limit, offset int) ([]*Customer, error)
Search(ctx context.Context, search string, status *string, gender *string, companyID *uuid.UUID, limit, offset int, sortBy, sortOrder string) ([]*Customer, error)
GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*Customer, error)
AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error
AddDeviceToken(ctx context.Context, id uuid.UUID, deviceToken DeviceToken) error
RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error
UpdateLastLogin(ctx context.Context, id uuid.UUID) error
GetAllDeviceTokens(ctx context.Context) ([]DeviceToken, error)
}
// customerRepository implements the Repository interface
@@ -32,8 +38,8 @@ type customerRepository struct {
}
// NewCustomerRepository creates a new customer repository
func NewCustomerRepository(db *mongo.Database, logger logger.Logger) Repository {
collection := db.Collection("customers")
func NewCustomerRepository(mongoManager *mongopkg.ConnectionManager, logger logger.Logger) Repository {
collection := mongoManager.GetCollection("customers")
// Create indexes
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -45,14 +51,31 @@ func NewCustomerRepository(db *mongo.Database, logger logger.Logger) Repository
Options: options.Index().SetUnique(true),
}
// Username index (unique)
usernameIndex := mongo.IndexModel{
Keys: bson.D{{Key: "username", Value: 1}},
Options: options.Index().SetUnique(true),
}
// Mobile index (unique)
mobileIndex := mongo.IndexModel{
Keys: bson.D{{Key: "mobile", Value: 1}},
Options: options.Index().SetUnique(true),
}
// Company ID index
companyIndex := mongo.IndexModel{
Keys: bson.D{{Key: "company_id", Value: 1}},
}
// Active status index
activeIndex := mongo.IndexModel{
Keys: bson.D{{Key: "is_active", Value: 1}},
// Status index
statusIndex := mongo.IndexModel{
Keys: bson.D{{Key: "status", Value: 1}},
}
// Gender index
genderIndex := mongo.IndexModel{
Keys: bson.D{{Key: "gender", Value: 1}},
}
// Created at index
@@ -60,11 +83,25 @@ func NewCustomerRepository(db *mongo.Database, logger logger.Logger) Repository
Keys: bson.D{{Key: "created_at", Value: -1}},
}
// Full text search index
textIndex := mongo.IndexModel{
Keys: bson.D{
{Key: "full_name", Value: "text"},
{Key: "email", Value: "text"},
{Key: "mobile", Value: "text"},
{Key: "username", Value: "text"},
},
}
_, err := collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
emailIndex,
usernameIndex,
mobileIndex,
companyIndex,
activeIndex,
statusIndex,
genderIndex,
createdAtIndex,
textIndex,
})
if err != nil {
@@ -150,6 +187,48 @@ func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*Cus
return &customer, nil
}
// GetByUsername retrieves a customer by username
func (r *customerRepository) GetByUsername(ctx context.Context, username string) (*Customer, error) {
var customer Customer
filter := bson.M{"username": username}
err := r.collection.FindOne(ctx, filter).Decode(&customer)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("customer not found")
}
r.logger.Error("Failed to get customer by username", map[string]interface{}{
"error": err.Error(),
"username": username,
})
return nil, err
}
return &customer, nil
}
// GetByMobile retrieves a customer by mobile
func (r *customerRepository) GetByMobile(ctx context.Context, mobile string) (*Customer, error) {
var customer Customer
filter := bson.M{"mobile": mobile}
err := r.collection.FindOne(ctx, filter).Decode(&customer)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("customer not found")
}
r.logger.Error("Failed to get customer by mobile", map[string]interface{}{
"error": err.Error(),
"mobile": mobile,
})
return nil, err
}
return &customer, nil
}
// Update updates a customer
func (r *customerRepository) Update(ctx context.Context, customer *Customer) error {
// Set updated timestamp using Unix timestamp
@@ -220,7 +299,7 @@ func (r *customerRepository) List(ctx context.Context, limit, offset int) ([]*Cu
opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Sort by created_at desc
// Only active customers by default
filter := bson.M{"is_active": true}
filter := bson.M{"status": "active"}
cursor, err := r.collection.Find(ctx, filter, opts)
if err != nil {
@@ -243,6 +322,65 @@ func (r *customerRepository) List(ctx context.Context, limit, offset int) ([]*Cu
return customers, nil
}
// Search retrieves customers with search and filters
func (r *customerRepository) Search(ctx context.Context, search string, status *string, gender *string, companyID *uuid.UUID, limit, offset int, sortBy, sortOrder string) ([]*Customer, error) {
var customers []*Customer
// Build filter
filter := bson.M{}
if search != "" {
filter["$text"] = bson.M{"$search": search}
}
if status != nil {
filter["status"] = *status
}
if gender != nil {
filter["gender"] = *gender
}
if companyID != nil {
filter["company_id"] = *companyID
}
// Build options
opts := options.Find()
opts.SetLimit(int64(limit))
opts.SetSkip(int64(offset))
// Set sort
if sortBy != "" {
sortValue := 1
if sortOrder == "desc" {
sortValue = -1
}
opts.SetSort(bson.D{{Key: sortBy, Value: sortValue}})
} else {
opts.SetSort(bson.D{{Key: "created_at", Value: -1}}) // Default sort
}
cursor, err := r.collection.Find(ctx, filter, opts)
if err != nil {
r.logger.Error("Failed to search customers", map[string]interface{}{
"error": err.Error(),
"search": search,
})
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &customers); err != nil {
r.logger.Error("Failed to decode customers from search", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
return customers, nil
}
// GetByCompanyID retrieves customers by company ID with pagination
func (r *customerRepository) GetByCompanyID(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*Customer, error) {
var customers []*Customer
@@ -256,7 +394,7 @@ func (r *customerRepository) GetByCompanyID(ctx context.Context, companyID uuid.
// Filter by company ID and active status
filter := bson.M{
"company_id": companyID,
"is_active": true,
"status": "active",
}
cursor, err := r.collection.Find(ctx, filter, opts)
@@ -283,11 +421,16 @@ func (r *customerRepository) GetByCompanyID(ctx context.Context, companyID uuid.
}
// AddDeviceToken adds a device token for push notifications
func (r *customerRepository) AddDeviceToken(ctx context.Context, id uuid.UUID, token string) error {
func (r *customerRepository) AddDeviceToken(ctx context.Context, id uuid.UUID, deviceToken DeviceToken) error {
// Set timestamps
now := time.Now().Unix()
deviceToken.CreatedAt = now
deviceToken.UpdatedAt = now
filter := bson.M{"_id": id}
update := bson.M{
"$addToSet": bson.M{"device_tokens": token}, // Use $addToSet to avoid duplicates
"$set": bson.M{"updated_at": time.Now().Unix()},
"$push": bson.M{"device_tokens": deviceToken}, // Use $push to add new token
"$set": bson.M{"updated_at": now},
}
result, err := r.collection.UpdateOne(ctx, filter, update)
@@ -295,7 +438,7 @@ func (r *customerRepository) AddDeviceToken(ctx context.Context, id uuid.UUID, t
r.logger.Error("Failed to add device token", map[string]interface{}{
"error": err.Error(),
"customer_id": id.String(),
"token": token,
"token": deviceToken.Token,
})
return err
}
@@ -306,7 +449,8 @@ func (r *customerRepository) AddDeviceToken(ctx context.Context, id uuid.UUID, t
r.logger.Info("Device token added successfully", map[string]interface{}{
"customer_id": id.String(),
"token": token,
"token": deviceToken.Token,
"device_type": deviceToken.DeviceType,
})
return nil
@@ -316,7 +460,7 @@ func (r *customerRepository) AddDeviceToken(ctx context.Context, id uuid.UUID, t
func (r *customerRepository) RemoveDeviceToken(ctx context.Context, id uuid.UUID, token string) error {
filter := bson.M{"_id": id}
update := bson.M{
"$pull": bson.M{"device_tokens": token}, // Use $pull to remove the token
"$pull": bson.M{"device_tokens": bson.M{"token": token}}, // Use $pull to remove the token
"$set": bson.M{"updated_at": time.Now().Unix()},
}
@@ -341,3 +485,70 @@ func (r *customerRepository) RemoveDeviceToken(ctx context.Context, id uuid.UUID
return nil
}
// UpdateLastLogin updates the last login timestamp
func (r *customerRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
filter := bson.M{"_id": id}
update := bson.M{
"$set": bson.M{
"last_login_at": time.Now().Unix(),
"updated_at": time.Now().Unix(),
},
}
result, err := r.collection.UpdateOne(ctx, filter, update)
if err != nil {
r.logger.Error("Failed to update last login", map[string]interface{}{
"error": err.Error(),
"customer_id": id.String(),
})
return err
}
if result.MatchedCount == 0 {
return errors.New("customer not found")
}
r.logger.Info("Last login updated successfully", map[string]interface{}{
"customer_id": id.String(),
})
return nil
}
// GetAllDeviceTokens retrieves all device tokens for push notifications
func (r *customerRepository) GetAllDeviceTokens(ctx context.Context) ([]DeviceToken, error) {
var customers []*Customer
// Get all active customers
filter := bson.M{"status": "active"}
opts := options.Find().SetProjection(bson.M{"device_tokens": 1})
cursor, err := r.collection.Find(ctx, filter, opts)
if err != nil {
r.logger.Error("Failed to get all device tokens", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
defer cursor.Close(ctx)
if err = cursor.All(ctx, &customers); err != nil {
r.logger.Error("Failed to decode customers for device tokens", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
// Collect all device tokens
var allTokens []DeviceToken
for _, customer := range customers {
allTokens = append(allTokens, customer.DeviceTokens...)
}
r.logger.Info("Retrieved all device tokens", map[string]interface{}{
"total_tokens": len(allTokens),
})
return allTokens, nil
}
+389 -425
View File
@@ -2,169 +2,69 @@ package customer
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
infrastructure "tm/infra"
"tm/pkg/logger"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// CustomerService implements the CustomerService interface
type CustomerService struct {
customerRepo Repository
config *infrastructure.AuthConfig
logger logger.Logger
// Service defines business logic for customer operations
type Service interface {
RegisterCustomer(ctx context.Context, form *RegisterCustomerForm) (*Customer, error)
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error)
ChangePassword(ctx context.Context, customerID uuid.UUID, form *ChangePasswordForm) error
GetCustomerByID(ctx context.Context, id uuid.UUID) (*Customer, error)
UpdateCustomer(ctx context.Context, id uuid.UUID, form *UpdateCustomerForm) (*Customer, error)
AddDeviceToken(ctx context.Context, customerID uuid.UUID, form *AddDeviceTokenForm) error
RemoveDeviceToken(ctx context.Context, customerID uuid.UUID, form *RemoveDeviceTokenForm) error
ListCustomers(ctx context.Context, form *ListCustomersForm) ([]*Customer, int, error)
UpdateCustomerStatus(ctx context.Context, id uuid.UUID, form *UpdateCustomerStatusForm) error
GetAllDeviceTokens(ctx context.Context) ([]DeviceToken, error)
Logout(ctx context.Context, customerID uuid.UUID, deviceToken string) error
}
// customerService implements the Service interface
type customerService struct {
repository Repository
logger logger.Logger
}
// NewCustomerService creates a new customer service
func NewCustomerService(
customerRepo Repository,
config *infrastructure.AuthConfig,
logger logger.Logger,
) CustomerService {
return CustomerService{
customerRepo: customerRepo,
config: config,
logger: logger,
func NewCustomerService(repository Repository, logger logger.Logger) Service {
return &customerService{
repository: repository,
logger: logger,
}
}
// GetCustomers retrieves customers with pagination (for panel users)
func (s *CustomerService) GetCustomers(ctx context.Context, limit, offset int) ([]*Customer, error) {
s.logger.Info("Retrieving customers", map[string]interface{}{
"limit": limit,
"offset": offset,
})
customers, err := s.customerRepo.List(ctx, limit, offset)
if err != nil {
s.logger.Error("Failed to retrieve customers", map[string]interface{}{
"error": err.Error(),
"limit": limit,
"offset": offset,
})
return nil, errors.New("failed to retrieve customers")
// RegisterCustomer registers a new customer
func (s *customerService) RegisterCustomer(ctx context.Context, form *RegisterCustomerForm) (*Customer, error) {
// Check if email already exists
existingCustomer, _ := s.repository.GetByEmail(ctx, form.Email)
if existingCustomer != nil {
return nil, errors.New("email already exists")
}
s.logger.Info("Customers retrieved successfully", map[string]interface{}{
"count": len(customers),
"limit": limit,
"offset": offset,
})
return customers, nil
}
// GetCustomerByID retrieves a customer by ID (for panel users)
func (s *CustomerService) GetCustomerByID(ctx context.Context, id uuid.UUID) (*Customer, error) {
s.logger.Info("Retrieving customer by ID", map[string]interface{}{
"customer_id": id.String(),
})
customer, err := s.customerRepo.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to retrieve customer", map[string]interface{}{
"error": err.Error(),
"customer_id": id.String(),
})
return nil, errors.New("customer not found")
// Check if username already exists
existingCustomer, _ = s.repository.GetByUsername(ctx, form.Username)
if existingCustomer != nil {
return nil, errors.New("username already exists")
}
s.logger.Info("Customer retrieved successfully", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
})
return customer, nil
}
// GetCustomersByCompany retrieves customers by company ID with pagination (for panel users)
func (s *CustomerService) GetCustomersByCompany(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]*Customer, error) {
s.logger.Info("Retrieving customers by company", map[string]interface{}{
"company_id": companyID.String(),
"limit": limit,
"offset": offset,
})
customers, err := s.customerRepo.GetByCompanyID(ctx, companyID, limit, offset)
if err != nil {
s.logger.Error("Failed to retrieve customers by company", map[string]interface{}{
"error": err.Error(),
"company_id": companyID.String(),
"limit": limit,
"offset": offset,
})
return nil, errors.New("failed to retrieve customers")
}
s.logger.Info("Customers by company retrieved successfully", map[string]interface{}{
"count": len(customers),
"company_id": companyID.String(),
"limit": limit,
"offset": offset,
})
return customers, nil
}
// UpdateCustomerStatus updates customer active status (for panel users)
func (s *CustomerService) UpdateCustomerStatus(ctx context.Context, customerID uuid.UUID, isActive bool, updatedBy uuid.UUID) error {
s.logger.Info("Updating customer status", map[string]interface{}{
"customer_id": customerID.String(),
"is_active": isActive,
"updated_by": updatedBy.String(),
})
// Get customer
customer, err := s.customerRepo.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("Customer not found for status update", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.String(),
})
return errors.New("customer not found")
}
// Update status
customer.IsActive = isActive
// Save changes
if err := s.customerRepo.Update(ctx, customer); err != nil {
s.logger.Error("Failed to update customer status", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.String(),
"is_active": isActive,
"updated_by": updatedBy.String(),
})
return errors.New("failed to update customer status")
}
s.logger.Info("Customer status updated successfully", map[string]interface{}{
"customer_id": customerID.String(),
"is_active": isActive,
"updated_by": updatedBy.String(),
})
return nil
}
// Register creates a new customer account
func (s *CustomerService) Register(ctx context.Context, req RegisterForm) (*AuthorizationResponse, error) {
s.logger.Info("Registering new customer", map[string]interface{}{
"email": req.Email,
})
// Check if customer already exists
existingCustomer, err := s.customerRepo.GetByEmail(ctx, req.Email)
if err == nil && existingCustomer != nil {
return nil, errors.New("customer already exists")
// Check if mobile already exists
existingCustomer, _ = s.repository.GetByMobile(ctx, form.Mobile)
if existingCustomer != nil {
return nil, errors.New("mobile number already exists")
}
// Hash password
hashedPassword, err := s.hashPassword(req.Password)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash password", map[string]interface{}{
"error": err.Error(),
@@ -172,388 +72,452 @@ func (s *CustomerService) Register(ctx context.Context, req RegisterForm) (*Auth
return nil, errors.New("failed to process password")
}
// Parse optional fields
var companyID *uuid.UUID
if form.CompanyID != nil {
parsedID, err := uuid.Parse(*form.CompanyID)
if err != nil {
return nil, errors.New("invalid company ID")
}
companyID = &parsedID
}
var gender *Gender
if form.Gender != nil {
g := Gender(*form.Gender)
gender = &g
}
// Create customer
customer := &Customer{
ID: uuid.New(),
Email: req.Email,
Password: hashedPassword,
FirstName: req.FirstName,
LastName: req.LastName,
Mobile: req.Mobile,
IsActive: true,
IsVerified: false, // Require email verification
DeviceTokens: make([]string, 0),
UpdatedAt: time.Now().Unix(),
CreatedAt: time.Now().Unix(),
FullName: form.FullName,
Username: form.Username,
Email: form.Email,
Mobile: form.Mobile,
Password: string(hashedPassword),
NationalID: form.NationalID,
Gender: gender,
Birthdate: form.Birthdate,
ProfileImage: form.ProfileImage,
Status: CustomerStatusActive,
CompanyID: companyID,
IsVerified: false,
DeviceTokens: []DeviceToken{},
}
if err := s.customerRepo.Create(ctx, customer); err != nil {
// Save to database
err = s.repository.Create(ctx, customer)
if err != nil {
s.logger.Error("Failed to create customer", map[string]interface{}{
"error": err.Error(),
"email": req.Email,
"error": err.Error(),
"email": form.Email,
"username": form.Username,
})
return nil, errors.New("failed to create customer")
}
// Generate tokens
accessToken, err := s.generateAccessToken(customer)
if err != nil {
s.logger.Error("Failed to generate access token", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID.String(),
})
return nil, errors.New("failed to generate access token")
}
refreshToken, err := s.generateRefreshToken(customer)
if err != nil {
s.logger.Error("Failed to generate refresh token", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID.String(),
})
return nil, errors.New("failed to generate refresh token")
return nil, err
}
s.logger.Info("Customer registered successfully", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
"company_id": customer.CompanyID.String(),
"username": customer.Username,
})
// TODO: Send verification email
s.sendVerificationEmail(ctx, customer)
return &AuthorizationResponse{
Customer: NewCustomerAggregate(*customer),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
}, nil
return customer, nil
}
// Login authenticates a customer and returns tokens
func (s *CustomerService) Login(ctx context.Context, req LoginForm) (*AuthorizationResponse, error) {
s.logger.Info("Customer login attempt", map[string]interface{}{
"email": req.Email,
})
// Login authenticates a customer
func (s *customerService) Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) {
// Find customer by email or mobile
var customer *Customer
var err error
if strings.Contains(form.EmailOrMobile, "@") {
customer, err = s.repository.GetByEmail(ctx, form.EmailOrMobile)
} else {
customer, err = s.repository.GetByMobile(ctx, form.EmailOrMobile)
}
// Get customer by email
customer, err := s.customerRepo.GetByEmail(ctx, req.Email)
if err != nil {
s.logger.Warn("Customer login failed - customer not found", map[string]interface{}{
"email": req.Email,
s.logger.Warn("Login attempt with invalid credentials", map[string]interface{}{
"email_or_mobile": form.EmailOrMobile,
"error": err.Error(),
})
return nil, errors.New("invalid credentials")
}
// Check if customer is active
if !customer.IsActive {
s.logger.Warn("Customer login failed - account inactive", map[string]interface{}{
"email": req.Email,
"customer_id": customer.ID.String(),
})
if customer.Status != CustomerStatusActive {
return nil, errors.New("account is inactive")
}
// Verify password
if !s.verifyPassword(req.Password, customer.Password) {
s.logger.Warn("Customer login failed - invalid password", map[string]interface{}{
"email": req.Email,
err = bcrypt.CompareHashAndPassword([]byte(customer.Password), []byte(form.Password))
if err != nil {
s.logger.Warn("Login attempt with wrong password", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
})
return nil, errors.New("invalid credentials")
}
// Update last login time
now := time.Now().Unix()
customer.LastLoginAt = &now
customer.UpdatedAt = now
if err := s.customerRepo.Update(ctx, customer); err != nil {
s.logger.Warn("Failed to update customer last login", map[string]interface{}{
// Update last login
err = s.repository.UpdateLastLogin(ctx, customer.ID)
if err != nil {
s.logger.Error("Failed to update last login", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID.String(),
})
// Don't fail login for this
}
// Generate tokens
accessToken, err := s.generateAccessToken(customer)
accessToken, refreshToken, expiresAt, err := s.generateTokens(customer.ID)
if err != nil {
s.logger.Error("Failed to generate access token", map[string]interface{}{
s.logger.Error("Failed to generate tokens", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID.String(),
})
return nil, errors.New("failed to generate access token")
}
refreshToken, err := s.generateRefreshToken(customer)
if err != nil {
s.logger.Error("Failed to generate refresh token", map[string]interface{}{
"error": err.Error(),
"customer_id": customer.ID.String(),
})
return nil, errors.New("failed to generate refresh token")
return nil, errors.New("failed to generate authentication tokens")
}
s.logger.Info("Customer logged in successfully", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
"is_verified": customer.IsVerified,
})
return &AuthorizationResponse{
Customer: NewCustomerAggregate(*customer),
return &AuthResponse{
Customer: customer.ToResponse(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
ExpiresAt: expiresAt,
}, nil
}
// RefreshToken generates new tokens using refresh token
func (s *CustomerService) RefreshToken(ctx context.Context, refreshToken string) (*AuthorizationResponse, error) {
// Parse and validate refresh token
token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return []byte(s.config.JWT.Secret), nil
})
if err != nil || !token.Valid {
return nil, errors.New("invalid refresh token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid token claims")
}
// Verify token type
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "refresh" {
return nil, errors.New("invalid token type")
}
// Verify user type
userType, ok := claims["user_type"].(string)
if !ok || userType != "customer" {
return nil, errors.New("invalid user type")
}
customerIDStr, ok := claims["customer_id"].(string)
if !ok {
return nil, errors.New("invalid customer ID in token")
}
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
return nil, errors.New("invalid customer ID format")
}
// Get customer from database
customer, err := s.customerRepo.GetByID(ctx, customerID)
if err != nil {
return nil, errors.New("customer not found")
}
if !customer.IsActive {
return nil, errors.New("account is inactive")
}
// Generate new tokens
accessToken, err := s.generateAccessToken(customer)
if err != nil {
return nil, errors.New("failed to generate access token")
}
newRefreshToken, err := s.generateRefreshToken(customer)
if err != nil {
return nil, errors.New("failed to generate refresh token")
}
return &AuthorizationResponse{
Customer: NewCustomerAggregate(*customer),
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresIn: int64(s.config.JWT.AccessTokenDuration.Seconds()),
}, nil
}
// ValidateToken validates a JWT token and returns the customer
func (s *CustomerService) ValidateToken(ctx context.Context, tokenString string) (*Customer, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return []byte(s.config.JWT.Secret), nil
})
if err != nil || !token.Valid {
return nil, errors.New("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid token claims")
}
// Verify token type
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "access" {
return nil, errors.New("invalid token type")
}
// Verify user type
userType, ok := claims["user_type"].(string)
if !ok || userType != "customer" {
return nil, errors.New("invalid user type")
}
customerIDStr, ok := claims["customer_id"].(string)
if !ok {
return nil, errors.New("invalid customer ID in token")
}
customerID, err := uuid.Parse(customerIDStr)
if err != nil {
return nil, errors.New("invalid customer ID format")
}
customer, err := s.customerRepo.GetByID(ctx, customerID)
if err != nil {
return nil, errors.New("customer not found")
}
if !customer.IsActive {
return nil, errors.New("account is inactive")
}
return customer, nil
// RefreshToken refreshes the access token
func (s *customerService) RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error) {
// TODO: Implement JWT refresh token validation
// For now, we'll return an error
return nil, errors.New("refresh token functionality not implemented")
}
// ChangePassword changes customer password
func (s *CustomerService) ChangePassword(ctx context.Context, customerID uuid.UUID, oldPassword, newPassword string) error {
customer, err := s.customerRepo.GetByID(ctx, customerID)
func (s *customerService) ChangePassword(ctx context.Context, customerID uuid.UUID, form *ChangePasswordForm) error {
// Get customer
customer, err := s.repository.GetByID(ctx, customerID)
if err != nil {
return errors.New("customer not found")
}
// Verify old password
if !s.verifyPassword(oldPassword, customer.Password) {
err = bcrypt.CompareHashAndPassword([]byte(customer.Password), []byte(form.OldPassword))
if err != nil {
return errors.New("invalid old password")
}
// Hash new password
hashedPassword, err := s.hashPassword(newPassword)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash new password", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.String(),
})
return errors.New("failed to process new password")
}
// Update password
customer.Password = hashedPassword
customer.UpdatedAt = time.Now().Unix()
if err := s.customerRepo.Update(ctx, customer); err != nil {
s.logger.Error("Failed to update customer password", map[string]interface{}{
customer.Password = string(hashedPassword)
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update password", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.String(),
})
return errors.New("failed to update password")
}
s.logger.Info("Customer password changed successfully", map[string]interface{}{
s.logger.Info("Password changed successfully", map[string]interface{}{
"customer_id": customerID.String(),
})
return nil
}
// ResetPassword initiates password reset process
func (s *CustomerService) ResetPassword(ctx context.Context, email string) error {
// TODO: Implement password reset logic
// This would typically involve:
// 1. Generate reset token
// 2. Store token with expiration
// 3. Send reset email
return errors.New("password reset not implemented")
// GetCustomerByID retrieves a customer by ID
func (s *customerService) GetCustomerByID(ctx context.Context, id uuid.UUID) (*Customer, error) {
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, err
}
return customer, nil
}
// VerifyEmail verifies customer's email address
func (s *CustomerService) VerifyEmail(ctx context.Context, customerID uuid.UUID, token string) error {
// TODO: Implement email verification logic
// This would typically involve:
// 1. Validate verification token
// 2. Update customer verification status
// 3. Send welcome email
return errors.New("email verification not implemented")
// UpdateCustomer updates customer information
func (s *customerService) UpdateCustomer(ctx context.Context, id uuid.UUID, form *UpdateCustomerForm) (*Customer, error) {
// Get customer
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, errors.New("customer not found")
}
// Update fields if provided
if form.FullName != nil {
customer.FullName = *form.FullName
}
if form.Username != nil {
// Check if username already exists
existingCustomer, _ := s.repository.GetByUsername(ctx, *form.Username)
if existingCustomer != nil && existingCustomer.ID != id {
return nil, errors.New("username already exists")
}
customer.Username = *form.Username
}
if form.Email != nil {
// Check if email already exists
existingCustomer, _ := s.repository.GetByEmail(ctx, *form.Email)
if existingCustomer != nil && existingCustomer.ID != id {
return nil, errors.New("email already exists")
}
customer.Email = *form.Email
}
if form.Mobile != nil {
// Check if mobile already exists
existingCustomer, _ := s.repository.GetByMobile(ctx, *form.Mobile)
if existingCustomer != nil && existingCustomer.ID != id {
return nil, errors.New("mobile number already exists")
}
customer.Mobile = *form.Mobile
}
if form.NationalID != nil {
customer.NationalID = form.NationalID
}
if form.Gender != nil {
g := Gender(*form.Gender)
customer.Gender = &g
}
if form.Birthdate != nil {
customer.Birthdate = form.Birthdate
}
if form.ProfileImage != nil {
customer.ProfileImage = form.ProfileImage
}
if form.Status != nil {
customer.Status = CustomerStatus(*form.Status)
}
// Update in database
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update customer", map[string]interface{}{
"error": err.Error(),
"customer_id": id.String(),
})
return nil, errors.New("failed to update customer")
}
s.logger.Info("Customer updated successfully", map[string]interface{}{
"customer_id": id.String(),
})
return customer, nil
}
// ResendVerification resend verification email
func (s *CustomerService) ResendVerification(ctx context.Context, email string) error {
customer, err := s.customerRepo.GetByEmail(ctx, email)
// AddDeviceToken adds a device token for push notifications
func (s *customerService) AddDeviceToken(ctx context.Context, customerID uuid.UUID, form *AddDeviceTokenForm) error {
// Verify customer exists
_, err := s.repository.GetByID(ctx, customerID)
if err != nil {
return errors.New("customer not found")
}
if customer.IsVerified {
return errors.New("email already verified")
// Create device token
deviceToken := DeviceToken{
Token: form.DeviceToken,
DeviceType: DeviceType(form.DeviceType),
}
// TODO: Send verification email
s.sendVerificationEmail(ctx, customer)
// Add to database
err = s.repository.AddDeviceToken(ctx, customerID, deviceToken)
if err != nil {
s.logger.Error("Failed to add device token", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.String(),
"token": form.DeviceToken,
})
return errors.New("failed to add device token")
}
s.logger.Info("Device token added successfully", map[string]interface{}{
"customer_id": customerID.String(),
"token": form.DeviceToken,
"device_type": form.DeviceType,
})
return nil
}
// Private helper methods
// RemoveDeviceToken removes a device token
func (s *customerService) RemoveDeviceToken(ctx context.Context, customerID uuid.UUID, form *RemoveDeviceTokenForm) error {
// Remove from database
err := s.repository.RemoveDeviceToken(ctx, customerID, form.DeviceToken)
if err != nil {
s.logger.Error("Failed to remove device token", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.String(),
"token": form.DeviceToken,
})
return errors.New("failed to remove device token")
}
func (s *CustomerService) hashPassword(password string) (string, error) {
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
s.logger.Info("Device token removed successfully", map[string]interface{}{
"customer_id": customerID.String(),
"token": form.DeviceToken,
})
return nil
}
// ListCustomers lists customers with search and filters
func (s *customerService) ListCustomers(ctx context.Context, form *ListCustomersForm) ([]*Customer, int, error) {
// Set defaults
limit := 20
if form.Limit != nil {
limit = *form.Limit
}
offset := 0
if form.Offset != nil {
offset = *form.Offset
}
search := ""
if form.Search != nil {
search = *form.Search
}
sortBy := "created_at"
if form.SortBy != nil {
sortBy = *form.SortBy
}
sortOrder := "desc"
if form.SortOrder != nil {
sortOrder = *form.SortOrder
}
// Get customers
customers, err := s.repository.Search(ctx, search, form.Status, form.Gender, nil, limit, offset, sortBy, sortOrder)
if err != nil {
s.logger.Error("Failed to list customers", map[string]interface{}{
"error": err.Error(),
})
return nil, 0, errors.New("failed to list customers")
}
// TODO: Implement count for pagination metadata
total := len(customers) // This should be a separate count query
return customers, total, nil
}
// UpdateCustomerStatus updates customer status
func (s *customerService) UpdateCustomerStatus(ctx context.Context, id uuid.UUID, form *UpdateCustomerStatusForm) error {
// Get customer
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("customer not found")
}
// Update status
customer.Status = CustomerStatus(form.Status)
// Update in database
err = s.repository.Update(ctx, customer)
if err != nil {
s.logger.Error("Failed to update customer status", map[string]interface{}{
"error": err.Error(),
"customer_id": id.String(),
"status": form.Status,
})
return errors.New("failed to update customer status")
}
s.logger.Info("Customer status updated successfully", map[string]interface{}{
"customer_id": id.String(),
"status": form.Status,
})
return nil
}
// GetAllDeviceTokens retrieves all device tokens for push notifications
func (s *customerService) GetAllDeviceTokens(ctx context.Context) ([]DeviceToken, error) {
tokens, err := s.repository.GetAllDeviceTokens(ctx)
if err != nil {
s.logger.Error("Failed to get all device tokens", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to get device tokens")
}
return tokens, nil
}
// Logout removes device token and logs the action
func (s *customerService) Logout(ctx context.Context, customerID uuid.UUID, deviceToken string) error {
// Remove device token
err := s.repository.RemoveDeviceToken(ctx, customerID, deviceToken)
if err != nil {
s.logger.Error("Failed to remove device token on logout", map[string]interface{}{
"error": err.Error(),
"customer_id": customerID.String(),
"token": deviceToken,
})
return errors.New("failed to logout")
}
s.logger.Info("Customer logged out successfully", map[string]interface{}{
"customer_id": customerID.String(),
"token": deviceToken,
})
return nil
}
// generateTokens generates access and refresh tokens
func (s *customerService) generateTokens(customerID uuid.UUID) (string, string, int64, error) {
// Generate access token (simple implementation for now)
accessToken, err := s.generateRandomToken(32)
if err != nil {
return "", "", 0, err
}
// Generate refresh token
refreshToken, err := s.generateRandomToken(64)
if err != nil {
return "", "", 0, err
}
// Set expiration (1 hour from now)
expiresAt := time.Now().Add(1 * time.Hour).Unix()
return accessToken, refreshToken, expiresAt, nil
}
// generateRandomToken generates a random token
func (s *customerService) generateRandomToken(length int) (string, error) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return string(hashedBytes), nil
}
func (s *CustomerService) verifyPassword(password, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}
func (s *CustomerService) generateAccessToken(customer *Customer) (string, error) {
claims := jwt.MapClaims{
"customer_id": customer.ID.String(),
"email": customer.Email,
"company_id": customer.CompanyID.String(),
"user_type": "customer",
"exp": time.Now().Add(s.config.JWT.AccessTokenDuration).Unix(),
"iat": time.Now().Unix(),
"type": "access",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.config.JWT.Secret))
}
func (s *CustomerService) generateRefreshToken(customer *Customer) (string, error) {
claims := jwt.MapClaims{
"customer_id": customer.ID.String(),
"user_type": "customer",
"exp": time.Now().Add(s.config.JWT.RefreshTokenDuration).Unix(),
"iat": time.Now().Unix(),
"type": "refresh",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.config.JWT.Secret))
}
func (s *CustomerService) sendVerificationEmail(ctx context.Context, customer *Customer) {
// TODO: Implement email sending logic
_ = ctx
s.logger.Info("Verification email would be sent", map[string]interface{}{
"customer_id": customer.ID.String(),
"email": customer.Email,
})
return hex.EncodeToString(bytes), nil
}
+32
View File
@@ -0,0 +1,32 @@
package customer
import (
"time"
"github.com/asaskevich/govalidator"
)
// init registers custom validators
func init() {
// Register custom validators
govalidator.CustomTypeTagMap.Set("unix_timestamp", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool {
switch v := i.(type) {
case int64:
// Check if timestamp is reasonable (not too old, not in the future)
now := time.Now().Unix()
minTimestamp := now - (100 * 365 * 24 * 60 * 60) // 100 years ago
maxTimestamp := now + (10 * 365 * 24 * 60 * 60) // 10 years in future
return v >= minTimestamp && v <= maxTimestamp
case *int64:
if v == nil {
return true // nil is valid for optional fields
}
now := time.Now().Unix()
minTimestamp := now - (100 * 365 * 24 * 60 * 60) // 100 years ago
maxTimestamp := now + (10 * 365 * 24 * 60 * 60) // 10 years in future
return *v >= minTimestamp && *v <= maxTimestamp
default:
return false
}
}))
}