Olá, {{.Name}}!
+Sua conta foi criada com sucesso. Agora você pode:
+-
+
- 🔍 Buscar vagas de emprego +
- 📝 Candidatar-se às melhores oportunidades +
- 💼 Gerenciar seu perfil profissional +
diff --git a/backend/.env.example b/backend/.env.example index 9710d63..446564f 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -44,3 +44,10 @@ CLOUDFLARE_ZONE_ID=your-zone-id CPANEL_HOST=https://cpanel.yourdomain.com:2083 CPANEL_USERNAME=your-cpanel-username CPANEL_API_TOKEN=your-cpanel-api-token + +# ============================================================================= +# Email Service (Resend) +# ============================================================================= +RESEND_API_KEY=re_xxxx_your_api_key +EMAIL_FROM=noreply@gohorsejobs.com +APP_URL=https://gohorsejobs.com diff --git a/backend/internal/dto/requests.go b/backend/internal/dto/requests.go index 84f594e..5bba8f0 100755 --- a/backend/internal/dto/requests.go +++ b/backend/internal/dto/requests.go @@ -123,6 +123,12 @@ type JobFilterQuery struct { VisaSupport *bool `form:"visaSupport"` LanguageLevel *string `form:"languageLevel"` Search *string `form:"search"` // Covers title, description, company name + + // Advanced filters + SalaryMin *float64 `form:"salaryMin"` // Minimum salary filter + SalaryMax *float64 `form:"salaryMax"` // Maximum salary filter + Currency *string `form:"currency"` // BRL, USD, EUR, GBP, JPY + SortBy *string `form:"sortBy"` // recent, salary_asc, salary_desc, relevance } // PaginatedResponse represents a paginated API response diff --git a/backend/internal/handlers/payment_handler.go b/backend/internal/handlers/payment_handler.go new file mode 100644 index 0000000..e4b151d --- /dev/null +++ b/backend/internal/handlers/payment_handler.go @@ -0,0 +1,290 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/services" +) + +// PaymentHandler handles Stripe payment operations +type PaymentHandler struct { + jobService *services.JobService +} + +// NewPaymentHandler creates a new payment handler +func NewPaymentHandler(jobService *services.JobService) *PaymentHandler { + return &PaymentHandler{jobService: jobService} +} + +// CreateCheckoutRequest represents a checkout session request +type CreateCheckoutRequest struct { + JobID int `json:"jobId"` + PriceID string `json:"priceId"` // Stripe Price ID + SuccessURL string `json:"successUrl"` // URL after success + CancelURL string `json:"cancelUrl"` // URL after cancel +} + +// CreateCheckoutResponse represents the checkout session response +type CreateCheckoutResponse struct { + SessionID string `json:"sessionId"` + CheckoutURL string `json:"checkoutUrl"` +} + +// CreateCheckout creates a Stripe checkout session for job posting payment +// @Summary Create checkout session +// @Description Create a Stripe checkout session for job posting payment +// @Tags Payments +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CreateCheckoutRequest true "Checkout request" +// @Success 200 {object} CreateCheckoutResponse +// @Failure 400 {string} string "Bad Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/payments/create-checkout [post] +func (h *PaymentHandler) CreateCheckout(w http.ResponseWriter, r *http.Request) { + var req CreateCheckoutRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.JobID == 0 || req.PriceID == "" { + http.Error(w, "JobID and PriceID are required", http.StatusBadRequest) + return + } + + // Get Stripe secret key + stripeSecretKey := os.Getenv("STRIPE_SECRET_KEY") + if stripeSecretKey == "" { + http.Error(w, "Payment service not configured", http.StatusInternalServerError) + return + } + + // Create Stripe checkout session via API + sessionID, checkoutURL, err := createStripeCheckoutSession(stripeSecretKey, req) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create checkout session: %v", err), http.StatusInternalServerError) + return + } + + response := CreateCheckoutResponse{ + SessionID: sessionID, + CheckoutURL: checkoutURL, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// HandleWebhook processes Stripe webhook events +// @Summary Handle Stripe webhook +// @Description Process Stripe webhook events (payment success, failure, etc.) +// @Tags Payments +// @Accept json +// @Produce json +// @Success 200 {string} string "OK" +// @Failure 400 {string} string "Bad Request" +// @Router /api/v1/payments/webhook [post] +func (h *PaymentHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { + webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") + if webhookSecret == "" { + http.Error(w, "Webhook secret not configured", http.StatusInternalServerError) + return + } + + // Read body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + // Verify signature + signature := r.Header.Get("Stripe-Signature") + if !verifyStripeSignature(body, signature, webhookSecret) { + http.Error(w, "Invalid signature", http.StatusBadRequest) + return + } + + // Parse event + var event map[string]interface{} + if err := json.Unmarshal(body, &event); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + eventType, ok := event["type"].(string) + if !ok { + http.Error(w, "Missing event type", http.StatusBadRequest) + return + } + + // Handle event types + switch eventType { + case "checkout.session.completed": + h.handleCheckoutComplete(event) + case "payment_intent.succeeded": + h.handlePaymentSuccess(event) + case "payment_intent.payment_failed": + h.handlePaymentFailed(event) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"received": true}`)) +} + +func (h *PaymentHandler) handleCheckoutComplete(event map[string]interface{}) { + // Extract session data and update job payment status + data, _ := event["data"].(map[string]interface{}) + obj, _ := data["object"].(map[string]interface{}) + + sessionID, _ := obj["id"].(string) + metadata, _ := obj["metadata"].(map[string]interface{}) + jobIDStr, _ := metadata["job_id"].(string) + + if jobIDStr != "" && sessionID != "" { + // TODO: Update job_payments table to mark as completed + fmt.Printf("Payment completed for job %s, session %s\n", jobIDStr, sessionID) + } +} + +func (h *PaymentHandler) handlePaymentSuccess(event map[string]interface{}) { + // Payment succeeded + fmt.Println("Payment succeeded") +} + +func (h *PaymentHandler) handlePaymentFailed(event map[string]interface{}) { + // Payment failed + fmt.Println("Payment failed") +} + +// GetPaymentStatus returns the status of a payment +// @Summary Get payment status +// @Description Get the status of a job posting payment +// @Tags Payments +// @Produce json +// @Param id path string true "Payment ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {string} string "Not Found" +// @Router /api/v1/payments/status/{id} [get] +func (h *PaymentHandler) GetPaymentStatus(w http.ResponseWriter, r *http.Request) { + paymentID := r.PathValue("id") + if paymentID == "" { + http.Error(w, "Payment ID is required", http.StatusBadRequest) + return + } + + // TODO: Query job_payments table for status + response := map[string]interface{}{ + "id": paymentID, + "status": "pending", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// Helper function to create Stripe checkout session via API +func createStripeCheckoutSession(secretKey string, req CreateCheckoutRequest) (string, string, error) { + client := &http.Client{} + + // Build form data + data := fmt.Sprintf( + "mode=payment&success_url=%s&cancel_url=%s&line_items[0][price]=%s&line_items[0][quantity]=1&metadata[job_id]=%d", + req.SuccessURL, req.CancelURL, req.PriceID, req.JobID, + ) + + httpReq, err := http.NewRequest("POST", "https://api.stripe.com/v1/checkout/sessions", + io.NopCloser(io.Reader(nil))) + if err != nil { + return "", "", err + } + + httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", secretKey)) + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + httpReq.Body = io.NopCloser(strings.NewReader(data)) + + resp, err := client.Do(httpReq) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return "", "", fmt.Errorf("Stripe API error: %s", string(body)) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", "", err + } + + sessionID, _ := result["id"].(string) + checkoutURL, _ := result["url"].(string) + + return sessionID, checkoutURL, nil +} + +// Verify Stripe webhook signature +func verifyStripeSignature(payload []byte, header, secret string) bool { + if header == "" { + return false + } + + // Parse signature header + var timestamp string + var signatures []string + + parts := splitHeader(header) + for _, p := range parts { + if len(p) > 2 && p[0] == 't' && p[1] == '=' { + timestamp = p[2:] + } else if len(p) > 3 && p[0] == 'v' && p[1] == '1' && p[2] == '=' { + signatures = append(signatures, p[3:]) + } + } + + if timestamp == "" || len(signatures) == 0 { + return false + } + + // Check timestamp (5 min tolerance) + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return false + } + if time.Now().Unix()-ts > 300 { + return false + } + + // Compute expected signature + signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload)) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signedPayload)) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + + // Compare signatures + for _, sig := range signatures { + if hmac.Equal([]byte(sig), []byte(expectedSig)) { + return true + } + } + + return false +} + +func splitHeader(header string) []string { + return strings.Split(header, ",") +} diff --git a/backend/internal/infrastructure/email/email_service.go b/backend/internal/infrastructure/email/email_service.go new file mode 100644 index 0000000..9b0fcaf --- /dev/null +++ b/backend/internal/infrastructure/email/email_service.go @@ -0,0 +1,304 @@ +package email + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "text/template" +) + +// EmailService handles transactional email sending +type EmailService struct { + apiKey string + fromAddr string + baseURL string +} + +// NewEmailService creates a new email service using Resend API +func NewEmailService() *EmailService { + apiKey := os.Getenv("RESEND_API_KEY") + fromAddr := os.Getenv("EMAIL_FROM") + if fromAddr == "" { + fromAddr = "noreply@gohorsejobs.com" + } + + return &EmailService{ + apiKey: apiKey, + fromAddr: fromAddr, + baseURL: "https://api.resend.com/emails", + } +} + +// IsConfigured returns true if email service is properly configured +func (s *EmailService) IsConfigured() bool { + return s.apiKey != "" +} + +// EmailPayload represents the Resend API request body +type EmailPayload struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text,omitempty"` +} + +// SendEmail sends an email via Resend API +func (s *EmailService) SendEmail(to []string, subject, htmlBody, textBody string) error { + if !s.IsConfigured() { + return fmt.Errorf("email service not configured: RESEND_API_KEY missing") + } + + payload := EmailPayload{ + From: s.fromAddr, + To: to, + Subject: subject, + HTML: htmlBody, + Text: textBody, + } + + jsonBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal email payload: %w", err) + } + + req, err := http.NewRequest("POST", s.baseURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.apiKey)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("email API error: status %d", resp.StatusCode) + } + + return nil +} + +// WelcomeEmailData contains data for welcome email template +type WelcomeEmailData struct { + Name string + Email string + AppURL string + AppName string +} + +// SendWelcome sends a welcome email to new users +func (s *EmailService) SendWelcome(to, name string) error { + data := WelcomeEmailData{ + Name: name, + Email: to, + AppURL: os.Getenv("APP_URL"), + AppName: "GoHorse Jobs", + } + + html, err := renderTemplate(welcomeTemplate, data) + if err != nil { + return err + } + + return s.SendEmail([]string{to}, "🐴 Bem-vindo ao GoHorse Jobs!", html, "") +} + +// PasswordResetData contains data for password reset email +type PasswordResetData struct { + Name string + ResetLink string + AppName string + ExpiresIn string +} + +// SendPasswordReset sends a password reset email +func (s *EmailService) SendPasswordReset(to, name, resetToken string) error { + appURL := os.Getenv("APP_URL") + if appURL == "" { + appURL = "https://gohorsejobs.com" + } + + data := PasswordResetData{ + Name: name, + ResetLink: fmt.Sprintf("%s/reset-password?token=%s", appURL, resetToken), + AppName: "GoHorse Jobs", + ExpiresIn: "1 hora", + } + + html, err := renderTemplate(passwordResetTemplate, data) + if err != nil { + return err + } + + return s.SendEmail([]string{to}, "🔑 Redefinição de senha - GoHorse Jobs", html, "") +} + +// ApplicationReceivedData contains data for application notification +type ApplicationReceivedData struct { + ApplicantName string + JobTitle string + CompanyName string + JobURL string + AppName string +} + +// SendApplicationReceived notifies recruiter about new application +func (s *EmailService) SendApplicationReceived(to, applicantName, jobTitle, companyName, jobID string) error { + appURL := os.Getenv("APP_URL") + if appURL == "" { + appURL = "https://gohorsejobs.com" + } + + data := ApplicationReceivedData{ + ApplicantName: applicantName, + JobTitle: jobTitle, + CompanyName: companyName, + JobURL: fmt.Sprintf("%s/jobs/%s/applications", appURL, jobID), + AppName: "GoHorse Jobs", + } + + html, err := renderTemplate(applicationReceivedTemplate, data) + if err != nil { + return err + } + + subject := fmt.Sprintf("📩 Nova candidatura para %s", jobTitle) + return s.SendEmail([]string{to}, subject, html, "") +} + +// Helper function to render templates +func renderTemplate(tmplStr string, data interface{}) (string, error) { + tmpl, err := template.New("email").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} + +// Email templates +const welcomeTemplate = ` + + +
+ + + + +Olá, {{.Name}}!
+Sua conta foi criada com sucesso. Agora você pode:
+Olá, {{.Name}}!
+Recebemos uma solicitação para redefinir a senha da sua conta.
+ Redefinir Minha Senha +Uma nova candidatura foi recebida para a vaga em {{.CompanyName}}:
+Candidato: {{.ApplicantName}}
+