diff --git a/.dockerignore b/.dockerignore index 72e1421..fcdace2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,11 +4,14 @@ bin/ docs/ web/ -.env.example -.env.test +.env +.env.* +.gitignore +commercify.db cookies.txt +docker-compose.local.yml docker-compose.yml readme.md SECURITY.md -todo.txt +Makefile tygo.yaml \ No newline at end of file diff --git a/.env.example b/.env.example index ba7eddf..5bb6f02 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,13 @@ EMAIL_SMTP_HOST= EMAIL_SMTP_PORT=587 EMAIL_SMTP_USERNAME= EMAIL_SMTP_PASSWORD= +# EMAIL_FROM_ADDRESS, in most cases should be the same as EMAIL_SMTP_USERNAME EMAIL_FROM_ADDRESS= EMAIL_FROM_NAME=Commercify +# The email the should receive notification when new orders are made. EMAIL_ADMIN_ADDRESS=dev@zenfulcode.com +# Contact email address for support, used in emails +EMAIL_CONTACT_ADDRESS=dev@commercify.app STRIPE_ENABLED=true STRIPE_SECRET_KEY=sk_test_your_key @@ -42,4 +46,7 @@ MOBILEPAY_WEBHOOK_URL=https://your-site.com/api/webhooks/mobilepay MOBILEPAY_PAYMENT_DESCRIPTION=Commercify Store Purchase RETURN_URL=https://your-site.com/payment/complete -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 \ No newline at end of file +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Used in emails and UI +STORE_NAME=Commercify Store \ No newline at end of file diff --git a/config/config.go b/config/config.go index 63342fc..4388373 100644 --- a/config/config.go +++ b/config/config.go @@ -59,6 +59,8 @@ type EmailConfig struct { FromEmail string FromName string AdminEmail string + ContactEmail string // Contact email for customer support + StoreName string // Store name used in emails Enabled bool } @@ -142,7 +144,7 @@ func LoadConfig() (*Config, error) { enabledProviders = append(enabledProviders, "mobilepay") } - return &Config{ + config := Config{ Server: ServerConfig{ Port: getEnv("SERVER_PORT", "6091"), ReadTimeout: readTimeout, @@ -173,6 +175,7 @@ func LoadConfig() (*Config, error) { FromEmail: getEnv("EMAIL_FROM_ADDRESS", "noreply@example.com"), FromName: getEnv("EMAIL_FROM_NAME", "Commercify Store"), AdminEmail: getEnv("EMAIL_ADMIN_ADDRESS", "admin@example.com"), + StoreName: getEnv("STORE_NAME", "Commercify Store"), Enabled: emailEnabled, }, Stripe: StripeConfig{ @@ -199,7 +202,11 @@ func LoadConfig() (*Config, error) { AllowAllOrigins: true, }, DefaultCurrency: getEnv("DEFAULT_CURRENCY", "USD"), - }, nil + } + + config.Email.ContactEmail = getEnv("EMAIL_CONTACT_ADDRESS", config.Email.FromEmail) + + return &config, nil } // getEnv gets an environment variable or returns a default value diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..c1282b5 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,86 @@ +package config + +import ( + "os" + "testing" +) + +func TestEmailConfigDefaults(t *testing.T) { + // Clear any existing environment variables + envVars := []string{ + "EMAIL_CONTACT_ADDRESS", + "STORE_NAME", + "EMAIL_FROM_ADDRESS", + "EMAIL_FROM_NAME", + } + + originalValues := make(map[string]string) + for _, envVar := range envVars { + originalValues[envVar] = os.Getenv(envVar) + os.Unsetenv(envVar) + } + + // Restore environment variables after test + defer func() { + for envVar, value := range originalValues { + if value != "" { + os.Setenv(envVar, value) + } + } + }() + + config, err := LoadConfig() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Test that ContactEmail falls back to FromEmail when not set + expectedContactEmail := "noreply@example.com" // This is the default FromEmail + if config.Email.ContactEmail != expectedContactEmail { + t.Errorf("Expected ContactEmail to be %s, got %s", expectedContactEmail, config.Email.ContactEmail) + } + + // Test that StoreName falls back to FromName when not set + expectedStoreName := "Commercify Store" // This is the default FromName + if config.Email.StoreName != expectedStoreName { + t.Errorf("Expected StoreName to be %s, got %s", expectedStoreName, config.Email.StoreName) + } +} + +func TestEmailConfigCustomValues(t *testing.T) { + // Set custom environment variables + os.Setenv("EMAIL_CONTACT_ADDRESS", "support@custom.com") + os.Setenv("STORE_NAME", "Custom Store") + os.Setenv("EMAIL_FROM_ADDRESS", "from@custom.com") + os.Setenv("EMAIL_FROM_NAME", "Custom From Name") + + // Clean up after test + defer func() { + os.Unsetenv("EMAIL_CONTACT_ADDRESS") + os.Unsetenv("STORE_NAME") + os.Unsetenv("EMAIL_FROM_ADDRESS") + os.Unsetenv("EMAIL_FROM_NAME") + }() + + config, err := LoadConfig() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Test that custom values are used + if config.Email.ContactEmail != "support@custom.com" { + t.Errorf("Expected ContactEmail to be support@custom.com, got %s", config.Email.ContactEmail) + } + + if config.Email.StoreName != "Custom Store" { + t.Errorf("Expected StoreName to be Custom Store, got %s", config.Email.StoreName) + } + + if config.Email.FromEmail != "from@custom.com" { + t.Errorf("Expected FromEmail to be from@custom.com, got %s", config.Email.FromEmail) + } + + if config.Email.FromName != "Custom From Name" { + t.Errorf("Expected FromName to be Custom From Name, got %s", config.Email.FromName) + } +} diff --git a/docs/RESTAPI.md b/docs/RESTAPI.md index 6bbae16..080152f 100644 --- a/docs/RESTAPI.md +++ b/docs/RESTAPI.md @@ -641,6 +641,90 @@ PUT /api/admin/orders/{orderId}/status Update order status (admin only). +**Request Body:** + +```json +{ + "status": "shipped" +} +``` + +**Response Body:** + +```json +{ + "id": 1, + "order_number": "ORD-001", + "currency": "USD", + "user_id": 1, + "total_amount": 9999, + "status": "shipped", + "payment_status": "captured", + "created_at": "2025-07-10T10:00:00Z", + "updated_at": "2025-07-10T11:00:00Z" +} +``` + +**Status Codes:** + +- `200 OK`: Order status updated successfully +- `400 Bad Request`: Invalid request body or status +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `404 Not Found`: Order not found + +**Valid Order Statuses:** + +- `pending`: Order is pending +- `paid`: Order has been paid +- `shipped`: Order has been shipped (triggers shipment email) +- `cancelled`: Order has been cancelled +- `completed`: Order is completed + +#### Update Order Status with Tracking + +```plaintext +PUT /api/admin/orders/{orderId}/status-with-tracking +``` + +Update order status with optional tracking information (admin only). When updating status to "shipped" with tracking information, an enhanced shipment email will be sent to the customer. + +**Request Body:** + +```json +{ + "status": "shipped", + "tracking_number": "1Z999AA1234567890", + "tracking_url": "https://www.ups.com/track?loc=en_US&tracknum=1Z999AA1234567890" +} +``` + +**Response Body:** + +```json +{ + "id": 1, + "order_number": "ORD-001", + "currency": "USD", + "user_id": 1, + "total_amount": 9999, + "status": "shipped", + "payment_status": "captured", + "created_at": "2025-07-10T10:00:00Z", + "updated_at": "2025-07-10T11:00:00Z" +} +``` + +**Status Codes:** + +- `200 OK`: Order status updated successfully +- `400 Bad Request`: Invalid request body or status +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `404 Not Found`: Order not found + +**Note:** When an order status is changed to "shipped", an email notification is automatically sent to the customer. If tracking information is provided, it will be included in the email with a tracking link. + ### Checkout Management #### List Checkouts diff --git a/docs/order_api_examples.md b/docs/order_api_examples.md index 812f1f9..1f69330 100644 --- a/docs/order_api_examples.md +++ b/docs/order_api_examples.md @@ -465,3 +465,103 @@ Example response: - `403 Forbidden`: User not authorized (not an admin) - `404 Not Found`: Order not found - `500 Internal Server Error`: Failed to update order status + +## Order Shipment Email Notification Examples + +### Update Order Status with Tracking Information + +```plaintext +PUT /api/admin/orders/{orderId}/status-with-tracking +``` + +Update order status with optional tracking information. When an order status is changed to "shipped", an email notification is automatically sent to the customer. + +**Path Parameters:** + +- `orderId` (required): Order ID + +**Request Body for Basic Shipment:** + +```json +{ + "status": "shipped" +} +``` + +**Request Body with Tracking Information:** + +```json +{ + "status": "shipped", + "tracking_number": "1Z999AA1234567890", + "tracking_url": "https://www.ups.com/track?loc=en_US&tracknum=1Z999AA1234567890" +} +``` + +**Response Body:** + +```json +{ + "success": true, + "data": { + "id": 123, + "order_number": "ORD-20250707-123", + "user_id": 1, + "status": "shipped", + "payment_status": "captured", + "currency": "USD", + "total_amount": 7498, + "shipping_cost": 1499, + "tax_amount": 0, + "discount_amount": 0, + "created_at": "2024-03-20T11:00:00Z", + "updated_at": "2024-03-20T14:30:00Z" + } +} +``` + +**Email Notification Details:** + +When the order status is updated to "shipped": + +1. **Customer Email**: An automated email is sent to the customer's email address containing: + + - Order details and summary + - Shipping address + - Expected delivery information + - Tracking number and link (if provided) + - Professional styling with shipping-themed design + +2. **Email Template**: Uses `templates/emails/order_shipped.html` + +3. **Email Subject**: "Your Order #[OrderID] Has Been Shipped! 📦" + +4. **Tracking Integration**: If tracking number and URL are provided, the email includes: + + - Clickable tracking link + - Visual tracking information section + - Instructions for tracking the package + +5. **Guest Order Support**: Works for both registered users and guest orders using customer details + +**Example Email Content Features:** + +- Green header indicating successful shipment +- Shipping status badge +- Tracking number in code format for easy copying +- Professional tracking button (if URL provided) +- Complete order summary +- Shipping address confirmation +- Expected delivery timeline +- Contact information for support + +**Status Codes:** + +- `200 OK`: Order status updated successfully and email sent +- `400 Bad Request`: Invalid order status or tracking information +- `401 Unauthorized`: User not authenticated +- `403 Forbidden`: User not authorized (not an admin) +- `404 Not Found`: Order not found +- `500 Internal Server Error`: Failed to update order status or send email + +**Note**: Email sending failures are logged but do not prevent the order status update from succeeding. The system ensures order status changes are persisted even if email delivery fails. diff --git a/internal/application/usecase/order_usecase.go b/internal/application/usecase/order_usecase.go index 77e6bbc..7b585d6 100644 --- a/internal/application/usecase/order_usecase.go +++ b/internal/application/usecase/order_usecase.go @@ -64,6 +64,14 @@ type UpdateOrderStatusInput struct { Status entity.OrderStatus `json:"status"` } +// UpdateOrderStatusWithTrackingInput contains the data needed to update an order status with tracking information +type UpdateOrderStatusWithTrackingInput struct { + OrderID uint `json:"order_id"` + Status entity.OrderStatus `json:"status"` + TrackingNumber string `json:"tracking_number,omitempty"` + TrackingURL string `json:"tracking_url,omitempty"` +} + // UpdateOrderStatus updates the status of an order func (uc *OrderUseCase) UpdateOrderStatus(input UpdateOrderStatusInput) (*entity.Order, error) { // Get order @@ -72,6 +80,9 @@ func (uc *OrderUseCase) UpdateOrderStatus(input UpdateOrderStatusInput) (*entity return nil, errors.New("order not found") } + // Store the old status to check if we need to send shipped email + oldStatus := order.Status + // Update status if err := order.UpdateStatus(input.Status); err != nil { return nil, err @@ -82,6 +93,42 @@ func (uc *OrderUseCase) UpdateOrderStatus(input UpdateOrderStatusInput) (*entity return nil, err } + // Handle emails for order status changes + if err := uc.handleEmailsForOrderStatusChange(order, oldStatus, input.Status, "", ""); err != nil { + // Log the error but don't fail the status update since the order status change was successful + log.Printf("Warning: Failed to send emails for order %d: %v", order.ID, err) + } + + return order, nil +} + +// UpdateOrderStatusWithTracking updates the status of an order with optional tracking information +func (uc *OrderUseCase) UpdateOrderStatusWithTracking(input UpdateOrderStatusWithTrackingInput) (*entity.Order, error) { + // Get order + order, err := uc.orderRepo.GetByID(input.OrderID) + if err != nil { + return nil, errors.New("order not found") + } + + // Store the old status to check if we need to send shipped email + oldStatus := order.Status + + // Update status + if err := order.UpdateStatus(input.Status); err != nil { + return nil, err + } + + // Update order in repository + if err := uc.orderRepo.Update(order); err != nil { + return nil, err + } + + // Handle emails for order status changes + if err := uc.handleEmailsForOrderStatusChange(order, oldStatus, input.Status, input.TrackingNumber, input.TrackingURL); err != nil { + // Log the error but don't fail the status update since the order status change was successful + log.Printf("Warning: Failed to send emails for order %d: %v", order.ID, err) + } + return order, nil } @@ -126,8 +173,6 @@ func (uc *OrderUseCase) GetOrderByExternalID(externalID string) (*entity.Order, return nil, fmt.Errorf("invalid reference format in MobilePay webhook event: %s", externalID) } - fmt.Printf("Extracted order ID from external ID: %d\n", orderID) - // Delegate to the order repository which has this functionality order, err := uc.orderRepo.GetByID(orderID) if err != nil { @@ -735,24 +780,10 @@ func (uc *OrderUseCase) handleEmailsForPaymentStatusChange(order *entity.Order, return nil } - // Create user object for email sending - var user *entity.User - if order.IsGuestOrder || order.UserID == nil { - // Guest order - create a temporary user object with customer details - if order.CustomerDetails == nil { - return fmt.Errorf("guest order missing customer details") - } - user = &entity.User{ - Email: order.CustomerDetails.Email, - FirstName: order.CustomerDetails.FullName, // Use FullName as FirstName for guest orders - } - } else { - // Registered user - get from repository - var err error - user, err = uc.userRepo.GetByID(*order.UserID) - if err != nil { - return fmt.Errorf("failed to get user %d: %w", *order.UserID, err) - } + // Get user object for email sending (handles both registered and guest orders) + user, err := uc.getUserForEmail(order) + if err != nil { + return fmt.Errorf("failed to get user for payment status email: %w", err) } // Send order confirmation email to customer @@ -768,3 +799,45 @@ func (uc *OrderUseCase) handleEmailsForPaymentStatusChange(order *entity.Order, log.Printf("Sent order confirmation and notification emails for order %d (status: %s)", order.ID, newStatus) return nil } + +// handleEmailsForOrderStatusChange sends appropriate emails when order status changes +func (uc *OrderUseCase) handleEmailsForOrderStatusChange(order *entity.Order, previousStatus, newStatus entity.OrderStatus, trackingNumber, trackingURL string) error { + // Only send emails when order status changes to shipped + if previousStatus != entity.OrderStatusShipped && newStatus == entity.OrderStatusShipped { + // Get user object for email sending (handles both registered and guest orders) + user, err := uc.getUserForEmail(order) + if err != nil { + return fmt.Errorf("failed to get user for shipped email: %w", err) + } + + // Send order shipped email with optional tracking information + if err := uc.emailSvc.SendOrderShipped(order, user, trackingNumber, trackingURL); err != nil { + return fmt.Errorf("failed to send order shipped email: %w", err) + } + + log.Printf("Sent order shipped email for order %d to %s", order.ID, user.Email) + } + + return nil +} + +// getUserForEmail creates a user object for email sending (handles both registered and guest orders) +func (uc *OrderUseCase) getUserForEmail(order *entity.Order) (*entity.User, error) { + if order.IsGuestOrder || order.UserID == nil { + // Guest order - create a temporary user object with customer details + if order.CustomerDetails == nil { + return nil, fmt.Errorf("guest order missing customer details") + } + return &entity.User{ + Email: order.CustomerDetails.Email, + FirstName: order.CustomerDetails.FullName, // Use FullName as FirstName for guest orders + }, nil + } else { + // Registered user - get from repository + user, err := uc.userRepo.GetByID(*order.UserID) + if err != nil { + return nil, fmt.Errorf("failed to get user %d: %w", *order.UserID, err) + } + return user, nil + } +} diff --git a/internal/domain/service/email_service.go b/internal/domain/service/email_service.go index 3372484..043902c 100644 --- a/internal/domain/service/email_service.go +++ b/internal/domain/service/email_service.go @@ -22,4 +22,7 @@ type EmailService interface { // SendOrderNotification sends an order notification email to the admin SendOrderNotification(order *entity.Order, user *entity.User) error + + // SendOrderShipped sends an order shipped notification email to the customer + SendOrderShipped(order *entity.Order, user *entity.User, trackingNumber, trackingURL string) error } diff --git a/internal/infrastructure/email/smtp_email_service.go b/internal/infrastructure/email/smtp_email_service.go index ca7098e..f9b3deb 100644 --- a/internal/infrastructure/email/smtp_email_service.go +++ b/internal/infrastructure/email/smtp_email_service.go @@ -79,7 +79,7 @@ func (s *SMTPEmailService) SendEmail(data service.EmailData) error { "%s", s.config.FromName, s.config.FromEmail, data.To, data.Subject, contentType, body) // Send email - s.logger.Info("Attempting to send email via SMTP to %s:%d", s.config.SMTPHost, s.config.SMTPPort) + // s.logger.Info("Attempting to send email via SMTP to %s:%d", s.config.SMTPHost, s.config.SMTPPort) err = smtp.SendMail( fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort), auth, @@ -115,8 +115,8 @@ func (s *SMTPEmailService) SendOrderConfirmation(order *entity.Order, user *enti data := map[string]any{ "Order": order, "User": user, - "StoreName": s.config.FromName, - "ContactEmail": s.config.FromEmail, + "StoreName": s.config.StoreName, + "ContactEmail": s.config.ContactEmail, "AppliedDiscount": appliedDiscount, "ShippingAddr": shippingAddr, "BillingAddr": billingAddr, @@ -148,7 +148,7 @@ func (s *SMTPEmailService) SendOrderNotification(order *entity.Order, user *enti data := map[string]any{ "Order": order, "User": user, - "StoreName": s.config.FromName, + "StoreName": s.config.StoreName, "AppliedDiscount": appliedDiscount, "ShippingAddr": shippingAddr, "BillingAddr": billingAddr, @@ -165,6 +165,41 @@ func (s *SMTPEmailService) SendOrderNotification(order *entity.Order, user *enti }) } +// SendOrderShipped sends an order shipped notification email to the customer +func (s *SMTPEmailService) SendOrderShipped(order *entity.Order, user *entity.User, trackingNumber, trackingURL string) error { + s.logger.Info("Sending order shipped email for Order ID: %d to User: %s", order.ID, user.Email) + + // Prepare data for the template + shippingAddr := order.GetShippingAddress() + billingAddr := order.GetBillingAddress() + appliedDiscount := order.GetAppliedDiscount() + + // Debug logging + s.logger.Info("Tracking URL: %s", trackingURL) + + data := map[string]any{ + "Order": order, + "User": user, + "StoreName": s.config.StoreName, + "ContactEmail": s.config.ContactEmail, + "AppliedDiscount": appliedDiscount, + "ShippingAddr": shippingAddr, + "BillingAddr": billingAddr, + "Currency": order.Currency, + "TrackingNumber": trackingNumber, + "TrackingURL": trackingURL, + } + + // Send email + return s.SendEmail(service.EmailData{ + To: user.Email, + Subject: fmt.Sprintf("Your Order #%d Has Been Shipped! 📦", order.ID), + IsHTML: true, + Template: "order_shipped.html", + Data: data, + }) +} + // renderTemplate renders an HTML template with the given data func (s *SMTPEmailService) renderTemplate(templateName string, data map[string]any) (string, error) { // Get template path diff --git a/internal/infrastructure/email/smtp_email_service_test.go b/internal/infrastructure/email/smtp_email_service_test.go new file mode 100644 index 0000000..62e30f1 --- /dev/null +++ b/internal/infrastructure/email/smtp_email_service_test.go @@ -0,0 +1,81 @@ +package email + +import ( + "os" + "testing" + + "github.com/zenfulcode/commercify/config" + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/infrastructure/logger" + "gorm.io/gorm" +) + +func TestSMTPEmailService_SendOrderShipped(t *testing.T) { + // Setup + zapLogger := logger.NewLogger() + emailConfig := config.EmailConfig{ + Enabled: false, // Disable actual email sending for test + FromEmail: "test@example.com", + FromName: "Test Store", + SMTPHost: "localhost", + SMTPPort: 587, + SMTPUsername: "test", + SMTPPassword: "test", + AdminEmail: "admin@example.com", + ContactEmail: "support@example.com", + StoreName: "Test Store", + } + + service := NewSMTPEmailService(emailConfig, zapLogger) + + // Create test order + order := &entity.Order{ + Model: gorm.Model{ID: 123}, + Currency: "USD", + } + + // Create test user + user := &entity.User{ + Model: gorm.Model{ID: 1}, + Email: "customer@example.com", + FirstName: "John", + LastName: "Doe", + } + + // Test sending order shipped email + err := service.SendOrderShipped(order, user, "1Z999AA1234567890", "https://example.com/track") + + // Should not error since email is disabled + if err != nil { + t.Errorf("Expected no error when email is disabled, got: %v", err) + } +} + +func TestTemplateExists(t *testing.T) { + // Check if the template file exists + if _, err := os.Stat("../../../templates/emails/order_shipped.html"); os.IsNotExist(err) { + t.Error("order_shipped.html template file does not exist") + } +} + +func TestAllTemplatesExist(t *testing.T) { + templates := []string{ + "order_shipped.html", + "order_confirmation.html", + "order_notification.html", + "checkout_recovery.html", + } + + for _, template := range templates { + path := "../../../templates/emails/" + template + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("Template file does not exist: %s", template) + } + } +} + +func TestTemplateRendering(t *testing.T) { + // Skip template rendering test since it requires proper working directory setup + // The existence test above already verifies templates are present + t.Skip("Template rendering test requires proper working directory setup") +} diff --git a/internal/interfaces/api/handler/order_handler.go b/internal/interfaces/api/handler/order_handler.go index 4f6deba..023e1dc 100644 --- a/internal/interfaces/api/handler/order_handler.go +++ b/internal/interfaces/api/handler/order_handler.go @@ -261,3 +261,62 @@ func (h *OrderHandler) UpdateOrderStatus(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } + +// UpdateOrderStatusWithTracking handles updating an order's status with tracking information (admin only) +func (h *OrderHandler) UpdateOrderStatusWithTracking(w http.ResponseWriter, r *http.Request) { + _, ok := r.Context().Value(middleware.UserIDKey).(uint) + if !ok { + h.logger.Error("Unauthorized access attempt") + response := contracts.ErrorResponse("Unauthorized") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(response) + return + } + + // Get order ID from URL + vars := mux.Vars(r) + id, err := strconv.ParseUint(vars["orderId"], 10, 32) + if err != nil { + h.logger.Error("Invalid order ID: %v", err) + http.Error(w, "Invalid order ID", http.StatusBadRequest) + return + } + + // Parse request body + var statusInput struct { + Status string `json:"status"` + TrackingNumber string `json:"tracking_number,omitempty"` + TrackingURL string `json:"tracking_url,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&statusInput); err != nil { + h.logger.Error("Failed to decode request body: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Update order status with tracking + input := usecase.UpdateOrderStatusWithTrackingInput{ + OrderID: uint(id), + Status: entity.OrderStatus(statusInput.Status), + TrackingNumber: statusInput.TrackingNumber, + TrackingURL: statusInput.TrackingURL, + } + + updatedOrder, err := h.orderUseCase.UpdateOrderStatusWithTracking(input) + if err != nil { + h.logger.Error("Failed to update order status: %v", err) + response := contracts.ErrorResponse(err.Error()) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(response) + return + } + + // Convert order to DTO + response := contracts.OrderUpdateStatusResponse(*updatedOrder.ToOrderSummaryDTO()) + + // Return updated order + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/internal/interfaces/api/server.go b/internal/interfaces/api/server.go index 7825f49..154c7c3 100644 --- a/internal/interfaces/api/server.go +++ b/internal/interfaces/api/server.go @@ -160,6 +160,7 @@ func (s *Server) setupRoutes() { admin.HandleFunc("/users", userHandler.ListUsers).Methods(http.MethodGet) admin.HandleFunc("/orders", orderHandler.ListAllOrders).Methods(http.MethodGet) admin.HandleFunc("/orders/{orderId:[0-9]+}/status", orderHandler.UpdateOrderStatus).Methods(http.MethodPut) + admin.HandleFunc("/orders/{orderId:[0-9]+}/status-with-tracking", orderHandler.UpdateOrderStatusWithTracking).Methods(http.MethodPut) // Admin checkout routes admin.HandleFunc("/checkouts", checkoutHandler.ListAdminCheckouts).Methods(http.MethodGet) diff --git a/readme.md b/readme.md index 0efc7cf..4247848 100644 --- a/readme.md +++ b/readme.md @@ -99,6 +99,36 @@ Create a `.env` file in the root directory by copying the `.env.example` cp .env.example .env ``` +### Email Configuration + +The application includes configurable email settings for transactional emails. Configure the following environment variables in your `.env` file: + +```bash +# Email Service Configuration +EMAIL_ENABLED=true # Enable/disable email functionality +EMAIL_SMTP_HOST=smtp.example.com # SMTP server hostname +EMAIL_SMTP_PORT=587 # SMTP server port (usually 587 for TLS) +EMAIL_SMTP_USERNAME=username # SMTP authentication username +EMAIL_SMTP_PASSWORD=password # SMTP authentication password + +# Email Addresses and Branding +EMAIL_FROM_ADDRESS=noreply@example.com # From address for outgoing emails +EMAIL_FROM_NAME=My Store # From name for outgoing emails +EMAIL_ADMIN_ADDRESS=admin@example.com # Admin email for order notifications +EMAIL_CONTACT_ADDRESS=support@example.com # Customer support contact email (used in templates) +STORE_NAME=My Store # Store name displayed in email templates +``` + +**Email Features:** + +- **Order Confirmation**: Sent when orders are placed +- **Order Shipped**: Sent when orders are marked as shipped (with optional tracking) +- **Order Notifications**: Sent to admin when new orders are received +- **Checkout Recovery**: Sent to customers who abandon their carts (if implemented) + +**Template Customization:** +Email templates are located in `templates/emails/` and can be customized to match your brand. + ### Database Setup The application supports both SQLite for local development and PostgreSQL for production. diff --git a/templates/emails/checkout_recovery.html b/templates/emails/checkout_recovery.html index cd72945..d7fc47e 100644 --- a/templates/emails/checkout_recovery.html +++ b/templates/emails/checkout_recovery.html @@ -8,58 +8,98 @@ body { font-family: Arial, sans-serif; line-height: 1.6; - color: #333333; + color: #333; max-width: 600px; margin: 0 auto; padding: 20px; } + .header { + text-align: center; + margin-bottom: 30px; + } + .header h1 { + color: #007bff; + margin-bottom: 10px; + } .logo { text-align: center; margin-bottom: 20px; } - .header { - text-align: center; - margin-bottom: 30px; + .recovery-info { + border: 1px solid #007bff; + padding: 20px; + margin-bottom: 20px; + background-color: #f8fcff; + border-radius: 8px; } .message { margin-bottom: 30px; + background-color: #fff3cd; + border: 1px solid #ffeaa7; + padding: 15px; + border-radius: 8px; + border-left: 4px solid #ffc107; } .items { - border: 1px solid #dddddd; + border: 1px solid #ddd; border-collapse: collapse; width: 100%; margin-bottom: 30px; + border-radius: 8px; + overflow: hidden; } .items th, .items td { text-align: left; padding: 12px; - border-bottom: 1px solid #dddddd; + border-bottom: 1px solid #ddd; } .items th { - background-color: #f5f5f5; + background-color: #f2f2f2; + font-weight: bold; } .total { text-align: right; margin-bottom: 30px; + font-weight: bold; } .cta-button { display: inline-block; - background-color: #4CAF50; + background-color: #28a745; color: white; text-decoration: none; padding: 15px 25px; font-weight: bold; - border-radius: 4px; + border-radius: 8px; margin: 20px 0; + text-align: center; + } + .cta-button:hover { + background-color: #218838; + } + .cta-section { + text-align: center; + margin: 30px 0; + padding: 20px; + background-color: #f8fff9; + border-radius: 8px; + border: 1px solid #28a745; } .footer { margin-top: 40px; font-size: 12px; - color: #777777; + color: #777; text-align: center; - border-top: 1px solid #dddddd; + border-top: 1px solid #ddd; padding-top: 20px; } + .urgency-box { + background-color: #ffe6e6; + border: 1px solid #ffcccc; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; + border-left: 4px solid #dc3545; + } @@ -69,14 +109,19 @@
-

Your Cart Is Still Waiting

-

We noticed you left some items in your shopping cart

-
+

🛒 Complete Your Purchase

+

Don't miss out on your items!

-
+
+

⏰ Limited Time

Hello {{.CustomerName}},

We noticed that you added some items to your cart but didn't complete your purchase. Your cart will be saved for a limited time, so you can easily pick up where you left off.

+ +
+

🛍️ Your Cart Summary

+

Don't let these amazing items slip away!

+
@@ -105,13 +150,15 @@

Your Cart Is Still Waiting

{{end}} -
+
+

🚀 Ready to Complete Your Order?

+

Click below to finish your purchase and get your items on their way!

Complete Your Purchase
{{if .DiscountOffer}} -
-

Special Offer!

+
+

🎉 Special Offer!

{{.DiscountOffer.Description}}

Use code: {{.DiscountOffer.Code}}

Valid until {{.DiscountOffer.ExpiryDate}}

diff --git a/templates/emails/order_confirmation.html b/templates/emails/order_confirmation.html index 0854dd3..e1fd526 100644 --- a/templates/emails/order_confirmation.html +++ b/templates/emails/order_confirmation.html @@ -17,11 +17,23 @@ text-align: center; margin-bottom: 30px; } + .header h1 { + color: #28a745; + margin-bottom: 10px; + } + .confirmation-info { + border: 1px solid #28a745; + padding: 20px; + margin-bottom: 20px; + background-color: #f8fff9; + border-radius: 8px; + } .order-details { border: 1px solid #ddd; padding: 15px; margin-bottom: 20px; background-color: #f9f9f9; + border-radius: 8px; } .order-items { width: 100%; @@ -39,6 +51,9 @@ } .address { margin-bottom: 15px; + background-color: #f8f9fa; + padding: 15px; + border-radius: 5px; } .total { text-align: right; @@ -51,11 +66,27 @@ font-size: 12px; color: #777; } + .status-badge { + background-color: #28a745; + color: white; + padding: 5px 10px; + border-radius: 15px; + font-size: 12px; + text-transform: uppercase; + font-weight: bold; + } + .info-box { + background-color: #e3f2fd; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; + border-left: 4px solid #2196f3; + }
-

Order Confirmation

+

✅ Order Confirmation

Thank you for your order!

@@ -66,6 +97,12 @@

Order Confirmation

order details:

+
+

📋 Order Information

+

Status: {{.Order.Status}}

+

Order Date: {{.Order.CreatedAt.Format "January 2, 2006 at 3:04 PM"}}

+
+

Order Number: #{{.Order.ID}}

@@ -74,7 +111,7 @@

Order Confirmation

Order Status: {{.Order.Status}}

-

Order Summary

+

📋 Order Summary

@@ -117,7 +154,7 @@

Order Summary

-

Shipping Address

+

📍 Shipping Address

{{if .ShippingAddr.Street1}} {{.ShippingAddr.Street1}}
@@ -130,7 +167,7 @@

Shipping Address

{{end}}
-

Billing Address

+

💳 Billing Address

{{if .BillingAddr.Street1}} {{.BillingAddr.Street1}}
@@ -143,10 +180,10 @@

Billing Address

{{end}}
-

- We'll notify you when your order has been shipped. If you have any - questions about your order, please contact us at {{.ContactEmail}}. -

+
+

📦 What's Next?

+

We'll notify you when your order has been shipped. If you have any questions about your order, please contact us at {{.ContactEmail}}.

+

Thank you for shopping with us!

diff --git a/templates/emails/order_notification.html b/templates/emails/order_notification.html index 7002c68..621403e 100644 --- a/templates/emails/order_notification.html +++ b/templates/emails/order_notification.html @@ -16,15 +16,24 @@ .header { text-align: center; margin-bottom: 30px; - background-color: #f2f2f2; - padding: 15px; - border-radius: 5px; + } + .header h1 { + color: #ff6b35; + margin-bottom: 10px; + } + .notification-info { + border: 1px solid #ff6b35; + padding: 20px; + margin-bottom: 20px; + background-color: #fff8f6; + border-radius: 8px; } .customer-info { border: 1px solid #ddd; padding: 15px; margin-bottom: 20px; background-color: #f9f9f9; + border-radius: 8px; } .order-items { width: 100%; @@ -47,6 +56,9 @@ } .address { margin-bottom: 15px; + background-color: #f8f9fa; + padding: 15px; + border-radius: 5px; } .footer { margin-top: 30px; @@ -54,20 +66,48 @@ font-size: 12px; color: #777; } + .status-badge { + background-color: #ff6b35; + color: white; + padding: 5px 10px; + border-radius: 15px; + font-size: 12px; + text-transform: uppercase; + font-weight: bold; + } + .alert-box { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; + border-left: 4px solid #ffc107; + }
-

New Order Notification

+

🔔 New Order Notification

A new order has been placed

-

- Order #{{.Order.ID}} has been placed by {{.User.FirstName}} - {{.User.LastName}} ({{.User.Email}}). -

+
+

⚡ Action Required

+

+ Order #{{.Order.ID}} has been placed by {{.User.FirstName}} + {{.User.LastName}} ({{.User.Email}}) and requires your attention. +

+
+ +
+

📋 Order Information

+

Status: {{.Order.Status}}

+

Order Date: {{.Order.CreatedAt.Format "January 2, 2006 at 3:04 PM"}}

+

Order Number: #{{.Order.ID}}

+

Total Amount: {{formatPriceWithCurrency .Order.FinalAmount .Currency}}

+
-

Customer Information

+

👤 Customer Information

Name: {{.User.FirstName}} {{.User.LastName}}

Email: {{.User.Email}}

@@ -76,7 +116,7 @@

Customer Information

-

Order Details

+

📋 Order Details

@@ -123,7 +163,7 @@

Order Details

-

Shipping Address

+

📍 Shipping Address

{{if .ShippingAddr.Street1}} {{.ShippingAddr.Street1}}
@@ -136,7 +176,7 @@

Shipping Address

{{end}}
-

Billing Address

+

💳 Billing Address

{{if .BillingAddr.Street1}} {{.BillingAddr.Street1}}
diff --git a/templates/emails/order_shipped.html b/templates/emails/order_shipped.html new file mode 100644 index 0000000..5d865dc --- /dev/null +++ b/templates/emails/order_shipped.html @@ -0,0 +1,212 @@ + + + + + + Your Order Has Been Shipped + + + +
+

📦 Your Order Has Been Shipped!

+

Great news! Your order is on its way to you.

+
+ +

Dear {{.User.FirstName}} {{.User.LastName}},

+ +

+ We're excited to let you know that your order has been shipped and is on its way to you! +

+ +
+

🚚 Shipping Information

+

Status: {{.Order.Status}}

+

Shipped Date: {{.Order.UpdatedAt.Format "January 2, 2006 at 3:04 PM"}}

+ {{if .TrackingNumber}} +
+

📋 Tracking Number: {{.TrackingNumber}}

+ {{if .TrackingURL}} + Track Your Package + {{end}} +
+ {{else}} +

Tracking information will be provided separately once available.

+ {{end}} +
+ +
+

Order Number: #{{.Order.ID}}

+

Order Date: {{.Order.CreatedAt.Format "January 2, 2006"}}

+

Total Amount: {{formatPriceWithCurrency .Order.FinalAmount .Currency}}

+
+ +

📋 Order Summary

+ +
+ + + + + + + + + + {{range .Order.Items}} + + + + + + + {{end}} + +
ProductQuantityPriceSubtotal
Product #{{.ProductID}}{{.Quantity}}{{formatPriceWithCurrency .Price $.Currency}}{{formatPriceWithCurrency .Subtotal $.Currency}}
+ +
+

Subtotal: {{formatPriceWithCurrency .Order.TotalAmount .Currency}}

+ + {{if gt .Order.ShippingCost 0}} +

Shipping: {{formatPriceWithCurrency .Order.ShippingCost .Currency}}

+ {{else}} +

Shipping: Free

+ {{end}} + + {{if gt .Order.DiscountAmount 0}} +

+ Discount: -{{formatPriceWithCurrency .Order.DiscountAmount .Currency}} {{if + .AppliedDiscount}} {{if .AppliedDiscount.DiscountCode}} + (Code: {{.AppliedDiscount.DiscountCode}}) {{end}} {{end}} +

+ {{end}} + +
+

Total: {{formatPriceWithCurrency .Order.FinalAmount .Currency}}

+
+
+ +

📍 Shipping Address

+
+ {{if .ShippingAddr.Street1}} + {{.ShippingAddr.Street1}}
+ {{if .ShippingAddr.Street2}}{{.ShippingAddr.Street2}}
{{end}} + {{.ShippingAddr.City}}{{if .ShippingAddr.State}}, {{.ShippingAddr.State}}{{end}} + {{if .ShippingAddr.PostalCode}} {{.ShippingAddr.PostalCode}}{{end}}
+ {{.ShippingAddr.Country}} + {{else}} +

No shipping address provided

+ {{end}} +
+ +
+

📅 Expected Delivery

+

Your order is expected to arrive within the standard delivery timeframe for your location. You'll receive a notification when your package is out for delivery.

+ {{if .TrackingNumber}} +

For real-time updates, please use the tracking number provided above.

+ {{end}} +
+ +

+ If you have any questions about your shipment or need assistance, please don't hesitate to contact us at {{.ContactEmail}}. +

+ +

Thank you for your business!

+ +

+ Best regards,
+ The {{.StoreName}} Team +

+ + + +