feat: redesign superadmin agencies list, implement flat design, add date filters, and fix UI bugs

This commit is contained in:
Erik Silva
2025-12-11 23:39:54 -03:00
parent 053e180321
commit dc98d5dccc
129 changed files with 20730 additions and 1611 deletions

View File

@@ -5,7 +5,6 @@ import (
"errors"
"log"
"net/http"
"strings"
"time"
"aggios-app/backend/internal/config"
@@ -13,6 +12,7 @@ import (
"aggios-app/backend/internal/service"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"github.com/google/uuid"
)
@@ -45,6 +45,8 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
}
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
log.Printf("📊 Payload received: RazaoSocial=%s, Phone=%s, City=%s, State=%s, Neighborhood=%s, TeamSize=%s, PrimaryColor=%s, SecondaryColor=%s",
req.RazaoSocial, req.Phone, req.City, req.State, req.Neighborhood, req.TeamSize, req.PrimaryColor, req.SecondaryColor)
tenant, admin, err := h.agencyService.RegisterAgency(req)
if err != nil {
@@ -104,6 +106,112 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
json.NewEncoder(w).Encode(response)
}
// PublicRegister handles public agency registration
func (h *AgencyRegistrationHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.PublicRegisterAgencyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Error decoding request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📥 Public Registering agency: %s (subdomain: %s)", req.CompanyName, req.Subdomain)
log.Printf("📦 Full Payload: %+v", req)
// Map to internal request
phone := ""
if len(req.Contacts) > 0 {
phone = req.Contacts[0].Whatsapp
}
internalReq := domain.RegisterAgencyRequest{
AgencyName: req.CompanyName,
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Description: req.Description,
Website: req.Website,
Industry: req.Industry,
Phone: phone,
TeamSize: req.TeamSize,
CEP: req.CEP,
State: req.State,
City: req.City,
Neighborhood: req.Neighborhood,
Street: req.Street,
Number: req.Number,
Complement: req.Complement,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
AdminEmail: req.Email,
AdminPassword: req.Password,
AdminName: req.FullName,
}
tenant, admin, err := h.agencyService.RegisterAgency(internalReq)
if err != nil {
log.Printf("❌ Error registering agency: %v", err)
switch err {
case service.ErrSubdomainTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrEmailAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrWeakPassword:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID)
// Generate JWT token for the new admin
claims := jwt.MapClaims{
"user_id": admin.ID.String(),
"email": admin.Email,
"role": admin.Role,
"tenant_id": tenant.ID.String(),
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret))
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
protocol := "http://"
if h.cfg.App.Environment == "production" {
protocol = "https://"
}
response := map[string]interface{}{
"token": tokenString,
"id": admin.ID,
"email": admin.Email,
"name": admin.Name,
"role": admin.Role,
"tenantId": tenant.ID,
"company": tenant.Name,
"subdomain": tenant.Subdomain,
"message": "Agency registered successfully",
"access_url": protocol + tenant.Domain,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// RegisterClient handles client registration (ADMIN_AGENCIA only)
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -147,9 +255,10 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
return
}
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/")
if agencyID == "" || agencyID == r.URL.Path {
http.NotFound(w, r)
vars := mux.Vars(r)
agencyID := vars["id"]
if agencyID == "" {
http.Error(w, "Missing agency ID", http.StatusBadRequest)
return
}
@@ -174,6 +283,27 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(details)
case http.MethodPatch:
var updateData map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if isActive, ok := updateData["is_active"].(bool); ok {
if err := h.agencyService.UpdateAgencyStatus(id, isActive); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {
http.Error(w, "Agency not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Status updated"})
case http.MethodDelete:
if err := h.agencyService.DeleteAgency(id); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {

View File

@@ -0,0 +1,225 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"time"
"aggios-app/backend/internal/api/middleware"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// UploadLogo handles logo file uploads
func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
// Only accept POST
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
log.Printf("Logo upload request received from tenant")
// Get tenant ID from context
tenantIDVal := r.Context().Value(middleware.TenantIDKey)
if tenantIDVal == nil {
log.Printf("No tenant ID in context")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Try to get as uuid.UUID first, if that fails try string and parse
var tenantID uuid.UUID
var ok bool
tenantID, ok = tenantIDVal.(uuid.UUID)
if !ok {
// Try as string
tenantIDStr, isString := tenantIDVal.(string)
if !isString {
log.Printf("Invalid tenant ID type: %T", tenantIDVal)
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
var err error
tenantID, err = uuid.Parse(tenantIDStr)
if err != nil {
log.Printf("Failed to parse tenant ID: %v", err)
http.Error(w, "Invalid tenant ID format", http.StatusBadRequest)
return
}
}
log.Printf("Processing logo upload for tenant: %s", tenantID)
// Parse multipart form (2MB max)
const maxLogoSize = 2 * 1024 * 1024
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("logo")
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type
contentType := header.Header.Get("Content-Type")
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/svg+xml" && contentType != "image/jpg" {
http.Error(w, "Only PNG, JPG or SVG files are allowed", http.StatusBadRequest)
return
}
// Get logo type (logo or horizontal)
logoType := r.FormValue("type")
if logoType != "logo" && logoType != "horizontal" {
logoType = "logo"
}
// Get current logo URL from database to delete old file
var currentLogoURL string
var queryErr error
if logoType == "horizontal" {
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_horizontal_url FROM tenants WHERE id = $1", tenantID).Scan(&currentLogoURL)
} else {
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_url FROM tenants WHERE id = $1", tenantID).Scan(&currentLogoURL)
}
if queryErr != nil && queryErr.Error() != "sql: no rows in result set" {
log.Printf("Warning: Failed to get current logo URL: %v", queryErr)
}
// Initialize MinIO client
minioClient, err := minio.New("aggios-minio:9000", &minio.Options{
Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""),
Secure: false,
})
if err != nil {
log.Printf("Failed to create MinIO client: %v", err)
http.Error(w, "Storage service unavailable", http.StatusInternalServerError)
return
}
// Ensure bucket exists
bucketName := "aggios-logos"
ctx := context.Background()
exists, err := minioClient.BucketExists(ctx, bucketName)
if err != nil {
log.Printf("Failed to check bucket: %v", err)
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
if !exists {
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
if err != nil {
log.Printf("Failed to create bucket: %v", err)
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
// Set bucket policy to public-read
policy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}]
}`, bucketName)
err = minioClient.SetBucketPolicy(ctx, bucketName, policy)
if err != nil {
log.Printf("Warning: Failed to set bucket policy: %v", err)
}
}
// Read file content
fileBytes, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
// Generate unique filename
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("tenants/%s/%s-%d%s", tenantID, logoType, time.Now().Unix(), ext)
// Upload to MinIO
_, err = minioClient.PutObject(
ctx,
bucketName,
filename,
bytes.NewReader(fileBytes),
int64(len(fileBytes)),
minio.PutObjectOptions{
ContentType: contentType,
},
)
if err != nil {
log.Printf("Failed to upload to MinIO: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Generate public URL
logoURL := fmt.Sprintf("http://localhost:9000/%s/%s", bucketName, filename)
log.Printf("Logo uploaded successfully: %s", logoURL)
// Delete old logo file from MinIO if exists
if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" {
// Extract object key from URL
// Example: http://localhost:9000/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
oldFilename := ""
if len(currentLogoURL) > 0 {
// Split by bucket name
if idx := len("http://localhost:9000/aggios-logos/"); idx < len(currentLogoURL) {
oldFilename = currentLogoURL[idx:]
}
}
if oldFilename != "" {
err = minioClient.RemoveObject(ctx, bucketName, oldFilename, minio.RemoveObjectOptions{})
if err != nil {
log.Printf("Warning: Failed to delete old logo %s: %v", oldFilename, err)
// Don't fail the request if deletion fails
} else {
log.Printf("Old logo deleted successfully: %s", oldFilename)
}
}
}
// Update tenant record in database
var err2 error
if logoType == "horizontal" {
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
} else {
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
}
if err2 != nil {
log.Printf("Failed to update logo: %v", err2)
http.Error(w, "Failed to update database", http.StatusInternalServerError)
return
}
// Return success response
response := map[string]string{
"logo_url": logoURL,
"message": "Logo uploaded successfully",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -22,34 +22,50 @@ func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler {
}
type AgencyProfileResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
ID string `json:"id"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
Neighborhood string `json:"neighborhood"`
Number string `json:"number"`
Complement string `json:"complement"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"team_size"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
LogoURL string `json:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url"`
}
type UpdateAgencyProfileRequest struct {
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
Neighborhood string `json:"neighborhood"`
Number string `json:"number"`
Complement string `json:"complement"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"team_size"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
LogoURL string `json:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url"`
}
// GetProfile returns the current agency profile
@@ -61,10 +77,8 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
// Get tenant from context (set by auth middleware)
tenantID := r.Context().Value(middleware.TenantIDKey)
log.Printf("DEBUG GetProfile: tenantID from context = %v (type: %T)", tenantID, tenantID)
if tenantID == nil {
log.Printf("DEBUG GetProfile: tenantID is nil from auth middleware")
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
return
}
@@ -87,20 +101,32 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("🔍 GetProfile for tenant %s: Found %s", tid, tenant.Name)
log.Printf("📄 Tenant Data: Address=%s, Number=%s, TeamSize=%s, RazaoSocial=%s",
tenant.Address, tenant.Number, tenant.TeamSize, tenant.RazaoSocial)
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
Neighborhood: tenant.Neighborhood,
Number: tenant.Number,
Complement: tenant.Complement,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
TeamSize: tenant.TeamSize,
PrimaryColor: tenant.PrimaryColor,
SecondaryColor: tenant.SecondaryColor,
LogoURL: tenant.LogoURL,
LogoHorizontalURL: tenant.LogoHorizontalURL,
}
w.Header().Set("Content-Type", "application/json")
@@ -136,18 +162,26 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
// Prepare updates
updates := map[string]interface{}{
"name": req.Name,
"cnpj": req.CNPJ,
"razao_social": req.RazaoSocial,
"email": req.Email,
"phone": req.Phone,
"website": req.Website,
"address": req.Address,
"city": req.City,
"state": req.State,
"zip": req.Zip,
"description": req.Description,
"industry": req.Industry,
"name": req.Name,
"cnpj": req.CNPJ,
"razao_social": req.RazaoSocial,
"email": req.Email,
"phone": req.Phone,
"website": req.Website,
"address": req.Address,
"neighborhood": req.Neighborhood,
"number": req.Number,
"complement": req.Complement,
"city": req.City,
"state": req.State,
"zip": req.Zip,
"description": req.Description,
"industry": req.Industry,
"team_size": req.TeamSize,
"primary_color": req.PrimaryColor,
"secondary_color": req.SecondaryColor,
"logo_url": req.LogoURL,
"logo_horizontal_url": req.LogoHorizontalURL,
}
// Update in database
@@ -164,21 +198,30 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
}
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
Neighborhood: tenant.Neighborhood,
Number: tenant.Number,
Complement: tenant.Complement,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
TeamSize: tenant.TeamSize,
PrimaryColor: tenant.PrimaryColor,
SecondaryColor: tenant.SecondaryColor,
LogoURL: tenant.LogoURL,
LogoHorizontalURL: tenant.LogoHorizontalURL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,239 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"encoding/json"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
)
type AgencyTemplateHandler struct {
templateRepo *repository.AgencyTemplateRepository
agencyService *service.AgencyService
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
}
func NewAgencyTemplateHandler(
templateRepo *repository.AgencyTemplateRepository,
agencyService *service.AgencyService,
userRepo *repository.UserRepository,
tenantRepo *repository.TenantRepository,
) *AgencyTemplateHandler {
return &AgencyTemplateHandler{
templateRepo: templateRepo,
agencyService: agencyService,
userRepo: userRepo,
tenantRepo: tenantRepo,
}
}
// GetTemplateBySlug - Public endpoint to get template details
func (h *AgencyTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get("slug")
if slug == "" {
http.Error(w, "Missing slug parameter", http.StatusBadRequest)
return
}
template, err := h.templateRepo.FindBySlug(slug)
if err != nil {
log.Printf("Template not found: %v", err)
http.Error(w, "Template not found or expired", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// PublicRegisterAgency - Public endpoint for agency registration via template
func (h *AgencyTemplateHandler) PublicRegisterAgency(w http.ResponseWriter, r *http.Request) {
var req domain.AgencyRegistrationViaTemplate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// 1. Validar template
template, err := h.templateRepo.FindBySlug(req.TemplateSlug)
if err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Invalid or expired template", http.StatusBadRequest)
return
}
// 2. Validar campos obrigatórios
if req.AgencyName == "" || req.Subdomain == "" || req.AdminEmail == "" || req.AdminPassword == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// 3. Validar senha
if len(req.AdminPassword) < 8 {
http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
// 4. Verificar se email já existe
existingUser, _ := h.userRepo.FindByEmail(req.AdminEmail)
if existingUser != nil {
http.Error(w, "Email already registered", http.StatusConflict)
return
}
// 5. Verificar se subdomain já existe
existingTenant, _ := h.tenantRepo.FindBySubdomain(req.Subdomain)
if existingTenant != nil {
http.Error(w, "Subdomain already taken", http.StatusConflict)
return
}
// 6. Hash da senha
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
http.Error(w, "Error processing password", http.StatusInternalServerError)
return
}
// 7. Criar tenant (agência)
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: req.Subdomain + ".aggios.app",
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Website: req.Website,
Phone: req.Phone,
Description: req.Description,
Industry: req.Industry,
TeamSize: req.TeamSize,
}
// Endereço (se fornecido)
if req.Address != nil {
tenant.Address = req.Address["street"]
tenant.Number = req.Address["number"]
tenant.Complement = req.Address["complement"]
tenant.Neighborhood = req.Address["neighborhood"]
tenant.City = req.Address["city"]
tenant.State = req.Address["state"]
tenant.Zip = req.Address["cep"]
}
// Personalização do template
if template.CustomPrimaryColor.Valid {
tenant.PrimaryColor = template.CustomPrimaryColor.String
}
if template.CustomLogoURL.Valid {
tenant.LogoURL = template.CustomLogoURL.String
}
if err := h.tenantRepo.Create(tenant); err != nil {
log.Printf("Error creating tenant: %v", err)
http.Error(w, "Error creating agency", http.StatusInternalServerError)
return
}
// 8. Criar usuário admin da agência
user := &domain.User{
Email: req.AdminEmail,
Password: string(hashedPassword),
Name: req.AdminName,
Role: "ADMIN_AGENCIA",
TenantID: &tenant.ID,
}
if err := h.userRepo.Create(user); err != nil {
log.Printf("Error creating user: %v", err)
http.Error(w, "Error creating admin user", http.StatusInternalServerError)
return
}
// 9. Incrementar contador de uso do template
if err := h.templateRepo.IncrementUsageCount(template.ID.String()); err != nil {
log.Printf("Warning: failed to increment usage count: %v", err)
}
// 10. Preparar resposta com redirect
redirectURL := template.RedirectURL.String
if redirectURL == "" {
redirectURL = "http://" + req.Subdomain + ".localhost/login"
}
response := map[string]interface{}{
"success": true,
"message": template.SuccessMessage.String,
"tenant_id": tenant.ID,
"user_id": user.ID,
"redirect_url": redirectURL,
"subdomain": req.Subdomain,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// CreateTemplate - SUPERADMIN only
func (h *AgencyTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var req domain.CreateAgencyTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
formFieldsJSON, _ := repository.FormFieldsToJSON(req.FormFields)
modulesJSON, _ := json.Marshal(req.AvailableModules)
template := &domain.AgencySignupTemplate{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
FormFields: formFieldsJSON,
AvailableModules: modulesJSON,
IsActive: true,
}
if req.CustomPrimaryColor != "" {
template.CustomPrimaryColor.Valid = true
template.CustomPrimaryColor.String = req.CustomPrimaryColor
}
if req.CustomLogoURL != "" {
template.CustomLogoURL.Valid = true
template.CustomLogoURL.String = req.CustomLogoURL
}
if req.RedirectURL != "" {
template.RedirectURL.Valid = true
template.RedirectURL.String = req.RedirectURL
}
if req.SuccessMessage != "" {
template.SuccessMessage.Valid = true
template.SuccessMessage.String = req.SuccessMessage
}
if err := h.templateRepo.Create(template); err != nil {
log.Printf("Error creating template: %v", err)
http.Error(w, "Error creating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// ListTemplates - SUPERADMIN only
func (h *AgencyTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.templateRepo.List()
if err != nil {
http.Error(w, "Error fetching templates", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(templates)
}

View File

@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strings"
@@ -55,28 +56,38 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method)
if r.Method != http.MethodPost {
log.Printf("❌ Method not allowed: %s", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ Failed to read body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
log.Printf("📥 Raw body: %s", string(bodyBytes))
// Trim whitespace to avoid decode errors caused by BOM or stray chars
sanitized := strings.TrimSpace(string(bodyBytes))
var req domain.LoginRequest
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
log.Printf("❌ JSON parse error: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📧 Login attempt for email: %s", req.Email)
response, err := h.authService.Login(req)
if err != nil {
log.Printf("❌ authService.Login error: %v", err)
if err == service.ErrInvalidCredentials {
http.Error(w, err.Error(), http.StatusUnauthorized)
} else {
@@ -85,6 +96,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,38 @@
package handlers
import (
"encoding/json"
"net/http"
"golang.org/x/crypto/bcrypt"
)
type HashRequest struct {
Password string `json:"password"`
}
type HashResponse struct {
Hash string `json:"hash"`
}
func GenerateHash(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req HashRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to generate hash", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(HashResponse{Hash: string(hash)})
}

View File

@@ -0,0 +1,180 @@
package handlers
import (
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"context"
"encoding/json"
"log"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type SignupTemplateHandler struct {
repo *repository.SignupTemplateRepository
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
agencyService *service.AgencyService
}
func NewSignupTemplateHandler(
repo *repository.SignupTemplateRepository,
userRepo *repository.UserRepository,
tenantRepo *repository.TenantRepository,
agencyService *service.AgencyService,
) *SignupTemplateHandler {
return &SignupTemplateHandler{
repo: repo,
userRepo: userRepo,
tenantRepo: tenantRepo,
agencyService: agencyService,
}
}
// CreateTemplate cria um novo template (SuperAdmin)
func (h *SignupTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var template domain.SignupTemplate
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Pegar user_id do contexto (do middleware de autenticação)
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok || userIDStr == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Error parsing user_id: %v", err)
http.Error(w, "Invalid user ID", http.StatusUnauthorized)
return
}
template.CreatedBy = userID
template.IsActive = true
ctx := context.Background()
if err := h.repo.Create(ctx, &template); err != nil {
log.Printf("Error creating signup template: %v", err)
http.Error(w, "Error creating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(template)
}
// ListTemplates lista todos os templates (SuperAdmin)
func (h *SignupTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
templates, err := h.repo.List(ctx)
if err != nil {
log.Printf("Error listing signup templates: %v", err)
http.Error(w, "Error listing templates", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(templates)
}
// GetTemplateBySlug retorna um template pelo slug (público)
func (h *SignupTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := vars["slug"]
ctx := context.Background()
template, err := h.repo.FindBySlug(ctx, slug)
if err != nil {
log.Printf("Error finding signup template by slug %s: %v", slug, err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// GetTemplateByID retorna um template pelo ID (SuperAdmin)
func (h *SignupTemplateHandler) GetTemplateByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
ctx := context.Background()
template, err := h.repo.FindByID(ctx, id)
if err != nil {
log.Printf("Error finding signup template by ID %s: %v", idStr, err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// UpdateTemplate atualiza um template (SuperAdmin)
func (h *SignupTemplateHandler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
var template domain.SignupTemplate
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
template.ID = id
ctx := context.Background()
if err := h.repo.Update(ctx, &template); err != nil {
log.Printf("Error updating signup template: %v", err)
http.Error(w, "Error updating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// DeleteTemplate deleta um template (SuperAdmin)
func (h *SignupTemplateHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
ctx := context.Background()
if err := h.repo.Delete(ctx, id); err != nil {
log.Printf("Error deleting signup template: %v", err)
http.Error(w, "Error deleting template", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,121 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"context"
"encoding/json"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
)
// PublicSignupRequest representa o cadastro público via template
type PublicSignupRequest struct {
TemplateSlug string `json:"template_slug"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Subdomain string `json:"subdomain"`
CompanyName string `json:"company_name"`
}
// PublicRegister handles public registration via template
func (h *SignupTemplateHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
var req PublicSignupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
ctx := context.Background()
// 1. Buscar o template
template, err := h.repo.FindBySlug(ctx, req.TemplateSlug)
if err != nil {
log.Printf("Error finding template: %v", err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
// 2. Incrementar usage_count
if err := h.repo.IncrementUsageCount(ctx, template.ID); err != nil {
log.Printf("Error incrementing usage count: %v", err)
}
// 3. Verificar se email já existe
emailExists, err := h.userRepo.EmailExists(req.Email)
if err != nil {
log.Printf("Error checking email: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
if emailExists {
http.Error(w, "Email already registered", http.StatusBadRequest)
return
}
// 4. Verificar se subdomain já existe (se fornecido)
if req.Subdomain != "" {
exists, err := h.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
log.Printf("Error checking subdomain: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
if exists {
http.Error(w, "Subdomain already taken", http.StatusBadRequest)
return
}
}
// 5. Hash da senha
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
// 6. Criar tenant (empresa/cliente)
tenant := &domain.Tenant{
Name: req.CompanyName,
Domain: req.Subdomain + ".aggios.app",
Subdomain: req.Subdomain,
Description: "Registered via " + template.Name,
}
if err := h.tenantRepo.Create(tenant); err != nil {
log.Printf("Error creating tenant: %v", err)
http.Error(w, "Error creating account", http.StatusInternalServerError)
return
}
// 7. Criar usuário admin do tenant
user := &domain.User{
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Role: "CLIENTE",
TenantID: &tenant.ID,
}
if err := h.userRepo.Create(user); err != nil {
log.Printf("Error creating user: %v", err)
http.Error(w, "Error creating user", http.StatusInternalServerError)
return
}
// 8. Resposta de sucesso
response := map[string]interface{}{
"success": true,
"message": template.SuccessMessage,
"tenant_id": tenant.ID,
"user_id": user.ID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,130 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// UploadHandler handles file upload endpoints
type UploadHandler struct {
minioClient *minio.Client
cfg *config.Config
}
// NewUploadHandler creates a new upload handler
func NewUploadHandler(cfg *config.Config) (*UploadHandler, error) {
// Initialize MinIO client
minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""),
Secure: cfg.Minio.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("failed to create MinIO client: %w", err)
}
// Ensure bucket exists
ctx := context.Background()
bucketName := cfg.Minio.BucketName
exists, err := minioClient.BucketExists(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to check bucket existence: %w", err)
}
if !exists {
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
if err != nil {
return nil, fmt.Errorf("failed to create bucket: %w", err)
}
}
return &UploadHandler{
minioClient: minioClient,
cfg: cfg,
}, nil
}
// UploadResponse represents the upload response
type UploadResponse struct {
FileID string `json:"file_id"`
FileName string `json:"file_name"`
FileURL string `json:"file_url"`
FileSize int64 `json:"file_size"`
}
// Upload handles file upload
func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Try to get user ID from context (optional for signup flow)
userIDStr, _ := r.Context().Value(middleware.UserIDKey).(string)
// Use temp tenant for unauthenticated uploads (signup flow)
tenantID := uuid.MustParse("00000000-0000-0000-0000-000000000000")
if userIDStr != "" {
// TODO: Query database to get tenant_id from user_id when authenticated
}
// Parse multipart form (max 10MB)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "File too large (max 10MB)", http.StatusBadRequest)
return
}
// Get file from form
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type (images only)
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
http.Error(w, "Only images are allowed", http.StatusBadRequest)
return
}
// Generate unique file ID
fileID := uuid.New()
ext := filepath.Ext(header.Filename)
objectName := fmt.Sprintf("tenants/%s/logos/%s%s", tenantID.String(), fileID.String(), ext)
// Upload to MinIO
ctx := context.Background()
_, err = h.minioClient.PutObject(ctx, h.cfg.Minio.BucketName, objectName, file, header.Size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
return
}
// Generate public URL (replace internal hostname with localhost for browser access)
fileURL := fmt.Sprintf("http://localhost:9000/%s/%s", h.cfg.Minio.BucketName, objectName)
// Return response
response := UploadResponse{
FileID: fileID.String(),
FileName: header.Filename,
FileURL: fileURL,
FileSize: header.Size,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -40,17 +40,33 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
userID := claims["user_id"].(string)
tenantID := claims["tenant_id"].(string)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Verificar se user_id existe e é do tipo correto
userIDClaim, ok := claims["user_id"]
if !ok || userIDClaim == nil {
http.Error(w, "Missing user_id in token", http.StatusUnauthorized)
return
}
userID, ok := userIDClaim.(string)
if !ok {
http.Error(w, "Invalid user_id format in token", http.StatusUnauthorized)
return
}
// tenant_id pode ser nil para SuperAdmin
var tenantID string
if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil {
tenantID, _ = tenantIDClaim.(string)
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -11,6 +11,7 @@ type Config struct {
JWT JWTConfig
Security SecurityConfig
App AppConfig
Minio MinioConfig
}
// AppConfig holds application-level settings
@@ -45,6 +46,15 @@ type SecurityConfig struct {
PasswordMinLength int
}
// MinioConfig holds MinIO configuration
type MinioConfig struct {
Endpoint string
RootUser string
RootPassword string
UseSSL bool
BucketName string
}
// Load loads configuration from environment variables
func Load() *Config {
env := getEnvOrDefault("APP_ENV", "development")
@@ -90,6 +100,13 @@ func Load() *Config {
MaxAttemptsPerMin: maxAttempts,
PasswordMinLength: 8,
},
Minio: MinioConfig{
Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"),
RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"),
RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"),
UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true",
BucketName: getEnvOrDefault("MINIO_BUCKET_NAME", "aggios"),
},
}
}

View File

@@ -0,0 +1,66 @@
package domain
import (
"database/sql"
"time"
"github.com/google/uuid"
)
// AgencySignupTemplate represents a signup template for agencies (SuperAdmin → Agency)
type AgencySignupTemplate struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description" db:"description"`
FormFields []byte `json:"form_fields" db:"form_fields"` // JSONB
AvailableModules []byte `json:"available_modules" db:"available_modules"` // JSONB
CustomPrimaryColor sql.NullString `json:"custom_primary_color" db:"custom_primary_color"`
CustomLogoURL sql.NullString `json:"custom_logo_url" db:"custom_logo_url"`
RedirectURL sql.NullString `json:"redirect_url" db:"redirect_url"`
SuccessMessage sql.NullString `json:"success_message" db:"success_message"`
IsActive bool `json:"is_active" db:"is_active"`
UsageCount int `json:"usage_count" db:"usage_count"`
MaxUses sql.NullInt64 `json:"max_uses" db:"max_uses"`
ExpiresAt sql.NullTime `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateAgencyTemplateRequest for creating a new agency template
type CreateAgencyTemplateRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
FormFields []string `json:"form_fields"`
AvailableModules []string `json:"available_modules"`
CustomPrimaryColor string `json:"custom_primary_color"`
CustomLogoURL string `json:"custom_logo_url"`
RedirectURL string `json:"redirect_url"`
SuccessMessage string `json:"success_message"`
MaxUses int `json:"max_uses"`
}
// AgencyRegistrationViaTemplate for public registration via template
type AgencyRegistrationViaTemplate struct {
TemplateSlug string `json:"template_slug"`
// Agency info
AgencyName string `json:"agencyName"`
Subdomain string `json:"subdomain"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Website string `json:"website"`
Phone string `json:"phone"`
// Admin
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
// Optional fields
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
Address map[string]string `json:"address"`
}

View File

@@ -0,0 +1,35 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// FormField representa um campo do formulário de cadastro
type FormField struct {
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"` // email, password, text, tel, etc
Required bool `json:"required"`
Order int `json:"order"`
}
// SignupTemplate representa um template de cadastro personalizado
type SignupTemplate struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Slug string `json:"slug"`
FormFields []FormField `json:"form_fields"`
EnabledModules []string `json:"enabled_modules"` // ["CRM", "ERP", "PROJECTS"]
RedirectURL string `json:"redirect_url,omitempty"`
SuccessMessage string `json:"success_message,omitempty"`
CustomLogoURL string `json:"custom_logo_url,omitempty"`
CustomPrimaryColor string `json:"custom_primary_color,omitempty"`
IsActive bool `json:"is_active"`
UsageCount int `json:"usage_count"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -18,14 +18,22 @@ type Tenant struct {
Phone string `json:"phone,omitempty" db:"phone"`
Website string `json:"website,omitempty" db:"website"`
Address string `json:"address,omitempty" db:"address"`
Neighborhood string `json:"neighborhood,omitempty" db:"neighborhood"`
Number string `json:"number,omitempty" db:"number"`
Complement string `json:"complement,omitempty" db:"complement"`
City string `json:"city,omitempty" db:"city"`
State string `json:"state,omitempty" db:"state"`
Zip string `json:"zip,omitempty" db:"zip"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
TeamSize string `json:"team_size,omitempty" db:"team_size"`
PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"`
SecondaryColor string `json:"secondary_color,omitempty" db:"secondary_color"`
LogoURL string `json:"logo_url,omitempty" db:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url,omitempty" db:"logo_horizontal_url"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateTenantRequest represents the request to create a new tenant

View File

@@ -36,6 +36,8 @@ type RegisterAgencyRequest struct {
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
Phone string `json:"phone"`
TeamSize string `json:"teamSize"`
// Endereço
CEP string `json:"cep"`
@@ -46,12 +48,59 @@ type RegisterAgencyRequest struct {
Number string `json:"number"`
Complement string `json:"complement"`
// Personalização
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
LogoHorizontalURL string `json:"logoHorizontalUrl"`
// Admin da Agência
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
}
// PublicRegisterAgencyRequest represents the public signup payload
type PublicRegisterAgencyRequest struct {
// User
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
Newsletter bool `json:"newsletter"`
// Company
CompanyName string `json:"companyName"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
// Address
CEP string `json:"cep"`
State string `json:"state"`
City string `json:"city"`
Neighborhood string `json:"neighborhood"`
Street string `json:"street"`
Number string `json:"number"`
Complement string `json:"complement"`
// Contacts (simplified for now, taking the first one as phone if available)
Contacts []struct {
ID int `json:"id"`
Whatsapp string `json:"whatsapp"`
} `json:"contacts"`
// Domain
Subdomain string `json:"subdomain"`
// Branding
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
}
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
type RegisterClientRequest struct {
Email string `json:"email"`

View File

@@ -0,0 +1,168 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"encoding/json"
"fmt"
)
type AgencyTemplateRepository struct {
db *sql.DB
}
func NewAgencyTemplateRepository(db *sql.DB) *AgencyTemplateRepository {
return &AgencyTemplateRepository{db: db}
}
func (r *AgencyTemplateRepository) Create(template *domain.AgencySignupTemplate) error {
query := `
INSERT INTO agency_signup_templates (
name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message, max_uses
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at
`
return r.db.QueryRow(
query,
template.Name,
template.Slug,
template.Description,
template.FormFields,
template.AvailableModules,
template.CustomPrimaryColor,
template.CustomLogoURL,
template.RedirectURL,
template.SuccessMessage,
template.MaxUses,
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
}
func (r *AgencyTemplateRepository) FindBySlug(slug string) (*domain.AgencySignupTemplate, error) {
var template domain.AgencySignupTemplate
query := `
SELECT id, name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message,
is_active, usage_count, max_uses, expires_at, created_at, updated_at
FROM agency_signup_templates
WHERE slug = $1 AND is_active = true
`
err := r.db.QueryRow(query, slug).Scan(
&template.ID, &template.Name, &template.Slug, &template.Description,
&template.FormFields, &template.AvailableModules,
&template.CustomPrimaryColor, &template.CustomLogoURL,
&template.RedirectURL, &template.SuccessMessage,
&template.IsActive, &template.UsageCount, &template.MaxUses,
&template.ExpiresAt, &template.CreatedAt, &template.UpdatedAt,
)
if err != nil {
return nil, err
}
// Validar se expirou
if template.ExpiresAt.Valid && template.ExpiresAt.Time.Before(sql.NullTime{}.Time) {
return nil, fmt.Errorf("template expired")
}
// Validar limite de usos
if template.MaxUses.Valid && template.UsageCount >= int(template.MaxUses.Int64) {
return nil, fmt.Errorf("template usage limit reached")
}
return &template, nil
}
func (r *AgencyTemplateRepository) List() ([]domain.AgencySignupTemplate, error) {
var templates []domain.AgencySignupTemplate
query := `
SELECT id, name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message,
is_active, usage_count, max_uses, expires_at, created_at, updated_at
FROM agency_signup_templates
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var t domain.AgencySignupTemplate
if err := rows.Scan(
&t.ID, &t.Name, &t.Slug, &t.Description,
&t.FormFields, &t.AvailableModules,
&t.CustomPrimaryColor, &t.CustomLogoURL,
&t.RedirectURL, &t.SuccessMessage,
&t.IsActive, &t.UsageCount, &t.MaxUses,
&t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
); err != nil {
return nil, err
}
templates = append(templates, t)
}
return templates, rows.Err()
}
func (r *AgencyTemplateRepository) IncrementUsageCount(id string) error {
query := `UPDATE agency_signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
func (r *AgencyTemplateRepository) Update(template *domain.AgencySignupTemplate) error {
query := `
UPDATE agency_signup_templates
SET name = $1, description = $2, form_fields = $3, available_modules = $4,
custom_primary_color = $5, custom_logo_url = $6, redirect_url = $7,
success_message = $8, is_active = $9, max_uses = $10, updated_at = CURRENT_TIMESTAMP
WHERE id = $11
`
_, err := r.db.Exec(
query,
template.Name,
template.Description,
template.FormFields,
template.AvailableModules,
template.CustomPrimaryColor,
template.CustomLogoURL,
template.RedirectURL,
template.SuccessMessage,
template.IsActive,
template.MaxUses,
template.ID,
)
return err
}
func (r *AgencyTemplateRepository) Delete(id string) error {
query := `DELETE FROM agency_signup_templates WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
// Helper: Convert form fields to JSON
func FormFieldsToJSON(fields []string) ([]byte, error) {
type FormField struct {
Name string `json:"name"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
}
var formFields []FormField
for _, field := range fields {
formFields = append(formFields, FormField{
Name: field,
Required: field == "agencyName" || field == "subdomain" || field == "adminEmail" || field == "adminPassword",
Enabled: true,
})
}
return json.Marshal(formFields)
}

View File

@@ -0,0 +1,280 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
type SignupTemplateRepository struct {
db *sql.DB
}
func NewSignupTemplateRepository(db *sql.DB) *SignupTemplateRepository {
return &SignupTemplateRepository{db: db}
}
// Create cria um novo template de cadastro
func (r *SignupTemplateRepository) Create(ctx context.Context, template *domain.SignupTemplate) error {
formFieldsJSON, err := json.Marshal(template.FormFields)
if err != nil {
return fmt.Errorf("error marshaling form_fields: %w", err)
}
modulesJSON, err := json.Marshal(template.EnabledModules)
if err != nil {
return fmt.Errorf("error marshaling enabled_modules: %w", err)
}
query := `
INSERT INTO signup_templates (
name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at
`
err = r.db.QueryRowContext(
ctx, query,
template.Name, template.Description, template.Slug,
formFieldsJSON, modulesJSON,
template.RedirectURL, template.SuccessMessage,
template.CustomLogoURL, template.CustomPrimaryColor,
template.IsActive, template.CreatedBy,
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
if err != nil {
return fmt.Errorf("error creating signup template: %w", err)
}
return nil
}
// FindBySlug busca um template pelo slug
func (r *SignupTemplateRepository) FindBySlug(ctx context.Context, slug string) (*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
WHERE slug = $1 AND is_active = true
`
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := r.db.QueryRowContext(ctx, query, slug).Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err == sql.ErrNoRows {
return nil, fmt.Errorf("signup template not found")
}
if err != nil {
return nil, fmt.Errorf("error finding signup template: %w", err)
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
return &template, nil
}
// FindByID busca um template pelo ID
func (r *SignupTemplateRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
WHERE id = $1
`
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := r.db.QueryRowContext(ctx, query, id).Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err == sql.ErrNoRows {
return nil, fmt.Errorf("signup template not found")
}
if err != nil {
return nil, fmt.Errorf("error finding signup template: %w", err)
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
return &template, nil
}
// List lista todos os templates
func (r *SignupTemplateRepository) List(ctx context.Context) ([]*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("error listing signup templates: %w", err)
}
defer rows.Close()
var templates []*domain.SignupTemplate
for rows.Next() {
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := rows.Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("error scanning signup template: %w", err)
}
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
templates = append(templates, &template)
}
return templates, nil
}
// IncrementUsageCount incrementa o contador de uso
func (r *SignupTemplateRepository) IncrementUsageCount(ctx context.Context, id uuid.UUID) error {
query := `UPDATE signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
// Update atualiza um template
func (r *SignupTemplateRepository) Update(ctx context.Context, template *domain.SignupTemplate) error {
formFieldsJSON, err := json.Marshal(template.FormFields)
if err != nil {
return fmt.Errorf("error marshaling form_fields: %w", err)
}
modulesJSON, err := json.Marshal(template.EnabledModules)
if err != nil {
return fmt.Errorf("error marshaling enabled_modules: %w", err)
}
query := `
UPDATE signup_templates SET
name = $1, description = $2, slug = $3, form_fields = $4, enabled_modules = $5,
redirect_url = $6, success_message = $7, custom_logo_url = $8, custom_primary_color = $9,
is_active = $10
WHERE id = $11
`
_, err = r.db.ExecContext(
ctx, query,
template.Name, template.Description, template.Slug,
formFieldsJSON, modulesJSON,
template.RedirectURL, template.SuccessMessage,
template.CustomLogoURL, template.CustomPrimaryColor,
template.IsActive, template.ID,
)
if err != nil {
return fmt.Errorf("error updating signup template: %w", err)
}
return nil
}
// Delete deleta um template
func (r *SignupTemplateRepository) Delete(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM signup_templates WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("error deleting signup template: %w", err)
}
return nil
}

View File

@@ -19,14 +19,21 @@ func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: db}
}
// DB returns the underlying database connection
func (r *TenantRepository) DB() *sql.DB {
return r.db
}
// Create creates a new tenant
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
query := `
INSERT INTO tenants (
id, name, domain, subdomain, cnpj, razao_social, email, website,
address, city, state, zip, description, industry, created_at, updated_at
id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, neighborhood, number, complement, city, state, zip,
description, industry, team_size, primary_color, secondary_color,
logo_url, logo_horizontal_url, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
RETURNING id, created_at, updated_at
`
@@ -44,13 +51,22 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
tenant.CNPJ,
tenant.RazaoSocial,
tenant.Email,
tenant.Phone,
tenant.Website,
tenant.Address,
tenant.Neighborhood,
tenant.Number,
tenant.Complement,
tenant.City,
tenant.State,
tenant.Zip,
tenant.Description,
tenant.Industry,
tenant.TeamSize,
tenant.PrimaryColor,
tenant.SecondaryColor,
tenant.LogoURL,
tenant.LogoHorizontalURL,
tenant.CreatedAt,
tenant.UpdatedAt,
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
@@ -59,14 +75,16 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
// FindByID finds a tenant by ID
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, city, state, zip, description, industry, is_active, created_at, updated_at
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, neighborhood, number, complement, city, state, zip, description, industry, team_size,
primary_color, secondary_color, logo_url, logo_horizontal_url,
is_active, created_at, updated_at
FROM tenants
WHERE id = $1
`
tenant := &domain.Tenant{}
var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString
var cnpj, razaoSocial, email, phone, website, address, neighborhood, number, complement, city, state, zip, description, industry, teamSize, primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
err := r.db.QueryRow(query, id).Scan(
&tenant.ID,
@@ -79,11 +97,19 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
&phone,
&website,
&address,
&neighborhood,
&number,
&complement,
&city,
&state,
&zip,
&description,
&industry,
&teamSize,
&primaryColor,
&secondaryColor,
&logoURL,
&logoHorizontalURL,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
@@ -116,6 +142,15 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
if address.Valid {
tenant.Address = address.String
}
if neighborhood.Valid {
tenant.Neighborhood = neighborhood.String
}
if number.Valid {
tenant.Number = number.String
}
if complement.Valid {
tenant.Complement = complement.String
}
if city.Valid {
tenant.City = city.String
}
@@ -131,6 +166,21 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
if industry.Valid {
tenant.Industry = industry.String
}
if teamSize.Valid {
tenant.TeamSize = teamSize.String
}
if primaryColor.Valid {
tenant.PrimaryColor = primaryColor.String
}
if secondaryColor.Valid {
tenant.SecondaryColor = secondaryColor.String
}
if logoURL.Valid {
tenant.LogoURL = logoURL.String
}
if logoHorizontalURL.Valid {
tenant.LogoHorizontalURL = logoHorizontalURL.String
}
return tenant, nil
}
@@ -171,7 +221,7 @@ func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
// FindAll returns all tenants
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, is_active, created_at, updated_at
SELECT id, name, domain, subdomain, email, phone, cnpj, logo_url, is_active, created_at, updated_at
FROM tenants
ORDER BY created_at DESC
`
@@ -185,11 +235,17 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
var tenants []*domain.Tenant
for rows.Next() {
tenant := &domain.Tenant{}
var email, phone, cnpj, logoURL sql.NullString
err := rows.Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&email,
&phone,
&cnpj,
&logoURL,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
@@ -197,6 +253,20 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
if err != nil {
return nil, err
}
if email.Valid {
tenant.Email = email.String
}
if phone.Valid {
tenant.Phone = phone.String
}
if cnpj.Valid {
tenant.CNPJ = cnpj.String
}
if logoURL.Valid {
tenant.LogoURL = logoURL.String
}
tenants = append(tenants, tenant)
}
@@ -209,7 +279,21 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
// Delete removes a tenant (and cascades to related data)
func (r *TenantRepository) Delete(id uuid.UUID) error {
result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id)
// Start transaction
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Delete all users associated with this tenant first
_, err = tx.Exec(`DELETE FROM users WHERE tenant_id = $1`, id)
if err != nil {
return err
}
// Delete the tenant
result, err := tx.Exec(`DELETE FROM tenants WHERE id = $1`, id)
if err != nil {
return err
}
@@ -223,7 +307,8 @@ func (r *TenantRepository) Delete(id uuid.UUID) error {
return sql.ErrNoRows
}
return nil
// Commit transaction
return tx.Commit()
}
// UpdateProfile updates tenant profile information
@@ -237,13 +322,21 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
phone = COALESCE($5, phone),
website = COALESCE($6, website),
address = COALESCE($7, address),
city = COALESCE($8, city),
state = COALESCE($9, state),
zip = COALESCE($10, zip),
description = COALESCE($11, description),
industry = COALESCE($12, industry),
updated_at = $13
WHERE id = $14
neighborhood = COALESCE($8, neighborhood),
number = COALESCE($9, number),
complement = COALESCE($10, complement),
city = COALESCE($11, city),
state = COALESCE($12, state),
zip = COALESCE($13, zip),
description = COALESCE($14, description),
industry = COALESCE($15, industry),
team_size = COALESCE($16, team_size),
primary_color = COALESCE($17, primary_color),
secondary_color = COALESCE($18, secondary_color),
logo_url = COALESCE($19, logo_url),
logo_horizontal_url = COALESCE($20, logo_horizontal_url),
updated_at = $21
WHERE id = $22
`
_, err := r.db.Exec(
@@ -255,14 +348,29 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
updates["phone"],
updates["website"],
updates["address"],
updates["neighborhood"],
updates["number"],
updates["complement"],
updates["city"],
updates["state"],
updates["zip"],
updates["description"],
updates["industry"],
updates["team_size"],
updates["primary_color"],
updates["secondary_color"],
updates["logo_url"],
updates["logo_horizontal_url"],
time.Now(),
id,
)
return err
}
// UpdateStatus updates the is_active status of a tenant
func (r *TenantRepository) UpdateStatus(id uuid.UUID, isActive bool) error {
query := `UPDATE tenants SET is_active = $1, updated_at = $2 WHERE id = $3`
_, err := r.db.Exec(query, isActive, time.Now(), id)
return err
}

View File

@@ -2,6 +2,7 @@ package repository
import (
"database/sql"
"log"
"time"
"aggios-app/backend/internal/domain"
@@ -53,6 +54,8 @@ func (r *UserRepository) Create(user *domain.User) error {
// FindByEmail finds a user by email
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
log.Printf("🔍 FindByEmail called with: %s", email)
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
@@ -72,10 +75,16 @@ func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
)
if err == sql.ErrNoRows {
log.Printf("❌ User not found: %s", email)
return nil, nil
}
if err != nil {
log.Printf("❌ DB error finding user %s: %v", email, err)
return nil, err
}
return user, err
log.Printf("✅ Found user: %s, role: %s", user.Email, user.Role)
return user, nil
}
// FindByID finds a user by ID

View File

@@ -60,24 +60,30 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
if req.Complement != "" {
address += " - " + req.Complement
}
if req.Neighborhood != "" {
address += " - " + req.Neighborhood
}
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Website: req.Website,
Address: address,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Phone: req.Phone,
Website: req.Website,
Address: address,
Neighborhood: req.Neighborhood,
Number: req.Number,
Complement: req.Complement,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
TeamSize: req.TeamSize,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
LogoHorizontalURL: req.LogoHorizontalURL,
}
if err := s.tenantRepo.Create(tenant); err != nil {
@@ -189,3 +195,16 @@ func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
return s.tenantRepo.Delete(id)
}
// UpdateAgencyStatus updates the is_active status of a tenant
func (s *AgencyService) UpdateAgencyStatus(id uuid.UUID, isActive bool) error {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return err
}
if tenant == nil {
return ErrTenantNotFound
}
return s.tenantRepo.UpdateStatus(id, isActive)
}

View File

@@ -2,6 +2,7 @@ package service
import (
"errors"
"log"
"time"
"aggios-app/backend/internal/config"
@@ -78,14 +79,20 @@ func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, err
// Find user by email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
log.Printf("❌ DB error finding user %s: %v", req.Email, err)
return nil, err
}
if user == nil {
log.Printf("❌ User not found: %s", req.Email)
return nil, ErrInvalidCredentials
}
log.Printf("🔍 Attempting login for %s with password_hash: %.10s...", req.Email, user.Password)
log.Printf("🔍 Provided password length: %d", len(req.Password))
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
log.Printf("❌ Password mismatch for %s: %v", req.Email, err)
return nil, ErrInvalidCredentials
}