Prepara versao dev 1.0

This commit is contained in:
Erik Silva
2025-12-08 21:47:38 -03:00
parent 512287698e
commit 190fde20c3
85 changed files with 7755 additions and 2317 deletions

View File

@@ -0,0 +1,192 @@
package handlers
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AgencyRegistrationHandler handles agency management endpoints
type AgencyRegistrationHandler struct {
agencyService *service.AgencyService
cfg *config.Config
}
// NewAgencyRegistrationHandler creates a new agency registration handler
func NewAgencyRegistrationHandler(agencyService *service.AgencyService, cfg *config.Config) *AgencyRegistrationHandler {
return &AgencyRegistrationHandler{
agencyService: agencyService,
cfg: cfg,
}
}
// RegisterAgency handles agency registration (SUPERADMIN only)
func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.RegisterAgencyRequest
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("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
tenant, admin, err := h.agencyService.RegisterAgency(req)
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 {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Get tenant_id from authenticated user context
// For now, this would need the auth middleware to set it
var req domain.RegisterClientRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get tenantID from context (set by middleware)
tenantIDStr := r.Header.Get("X-Tenant-ID")
if tenantIDStr == "" {
http.Error(w, "Tenant not found", http.StatusBadRequest)
return
}
// Parse tenant ID
// tenantID, _ := uuid.Parse(tenantIDStr)
// client, err := h.agencyService.RegisterClient(req, tenantID)
// ... handle response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"message": "Client registration endpoint - implementation pending",
})
}
// HandleAgency supports GET (details) and DELETE operations for a specific agency
func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/admin/agencies/" {
http.Error(w, "Agency ID required", http.StatusBadRequest)
return
}
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/")
if agencyID == "" || agencyID == r.URL.Path {
http.NotFound(w, r)
return
}
id, err := uuid.Parse(agencyID)
if err != nil {
http.Error(w, "Invalid agency ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
details, err := h.agencyService.GetAgencyDetails(id)
if 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.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(details)
case http.MethodDelete:
if err := h.agencyService.DeleteAgency(id); 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.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"net/http"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
type AgencyHandler struct {
tenantRepo *repository.TenantRepository
}
func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler {
return &AgencyHandler{
tenantRepo: tenantRepo,
}
}
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"`
}
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"`
}
// GetProfile returns the current agency profile
func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get tenant from context (set by middleware)
tenantID := r.Context().Value("tenantID")
if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized)
return
}
// Parse tenant ID
tid, err := uuid.Parse(tenantID.(string))
if err != nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
// Get tenant from database
tenant, err := h.tenantRepo.FindByID(tid)
if err != nil {
http.Error(w, "Error fetching profile", http.StatusInternalServerError)
return
}
if tenant == nil {
http.Error(w, "Tenant not found", http.StatusNotFound)
return
}
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,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UpdateProfile updates the current agency profile
func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut && r.Method != http.MethodPatch {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get tenant from context
tenantID := r.Context().Value("tenantID")
if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized)
return
}
var req UpdateAgencyProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Parse tenant ID
tid, err := uuid.Parse(tenantID.(string))
if err != nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
// 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,
}
// Update in database
if err := h.tenantRepo.UpdateProfile(tid, updates); err != nil {
http.Error(w, "Error updating profile", http.StatusInternalServerError)
return
}
// Fetch updated data
tenant, err := h.tenantRepo.FindByID(tid)
if err != nil {
http.Error(w, "Error fetching updated profile", http.StatusInternalServerError)
return
}
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,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,139 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"strings"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *service.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Register handles user registration
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.authService.Register(req)
if err != nil {
switch err {
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
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// 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 {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.Login(req)
if err != nil {
if err == service.ErrInvalidCredentials {
http.Error(w, err.Error(), http.StatusUnauthorized)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// ChangePasswordRequest represents a password change request
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
// ChangePassword handles password change
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get user ID from context (set by auth middleware)
userID, ok := r.Context().Value("userID").(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "Current password and new password are required", http.StatusBadRequest)
return
}
// Call auth service to change password
if err := h.authService.ChangePassword(userID, req.CurrentPassword, req.NewPassword); err != nil {
if err == service.ErrInvalidCredentials {
http.Error(w, "Current password is incorrect", http.StatusUnauthorized)
} else if err == service.ErrWeakPassword {
http.Error(w, "New password is too weak", http.StatusBadRequest)
} else {
http.Error(w, "Error changing password", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Password changed successfully",
})
}

View File

@@ -0,0 +1,90 @@
package handlers
import (
"encoding/json"
"net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/google/uuid"
)
// CompanyHandler handles company endpoints
type CompanyHandler struct {
companyService *service.CompanyService
}
// NewCompanyHandler creates a new company handler
func NewCompanyHandler(companyService *service.CompanyService) *CompanyHandler {
return &CompanyHandler{
companyService: companyService,
}
}
// Create handles company creation
func (h *CompanyHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get user ID from context (set by auth middleware)
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
var req domain.CreateCompanyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// TODO: Get tenantID from user context
// For now, this is a placeholder - you'll need to get the tenant from the authenticated user
tenantID := uuid.New() // Replace with actual tenant from user
company, err := h.companyService.Create(req, tenantID, userID)
if err != nil {
switch err {
case service.ErrCNPJAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(company)
}
// List handles listing companies for a tenant
func (h *CompanyHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Get tenantID from authenticated user
tenantID := uuid.New() // Replace with actual tenant from user
companies, err := h.companyService.ListByTenant(tenantID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(companies)
}

View File

@@ -0,0 +1,31 @@
package handlers
import (
"encoding/json"
"net/http"
)
// HealthHandler handles health check endpoint
type HealthHandler struct{}
// NewHealthHandler creates a new health handler
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
// Check returns API health status
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]interface{}{
"status": "healthy",
"service": "aggios-api",
"version": "1.0.0",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,42 @@
package handlers
import (
"encoding/json"
"net/http"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
// TenantHandler handles tenant/agency listing endpoints
type TenantHandler struct {
tenantService *service.TenantService
}
// NewTenantHandler creates a new tenant handler
func NewTenantHandler(tenantService *service.TenantService) *TenantHandler {
return &TenantHandler{
tenantService: tenantService,
}
}
// ListAll lists all agencies/tenants (SUPERADMIN only)
func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tenants, err := h.tenantService.ListAll()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if tenants == nil {
tenants = []*domain.Tenant{}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(tenants)
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"context"
"net/http"
"strings"
"aggios-app/backend/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const UserIDKey contextKey = "userID"
// Auth validates JWT tokens
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
bearerToken := strings.Split(authHeader, " ")
if len(bearerToken) != 2 || bearerToken[0] != "Bearer" {
http.Error(w, "Invalid token format", http.StatusUnauthorized)
return
}
token, err := jwt.Parse(bearerToken[1], func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWT.Secret), nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", 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)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"aggios-app/backend/internal/config"
)
// CORS adds CORS headers to responses
func CORS(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Allow all localhost origins for development
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Host")
w.Header().Set("Access-Control-Max-Age", "3600")
// Handle preflight request
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,96 @@
package middleware
import (
"net/http"
"sync"
"time"
"aggios-app/backend/internal/config"
)
type rateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
maxAttempts int
}
func newRateLimiter(maxAttempts int) *rateLimiter {
rl := &rateLimiter{
attempts: make(map[string][]time.Time),
maxAttempts: maxAttempts,
}
// Clean old entries every minute
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.cleanup()
}
}()
return rl
}
func (rl *rateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for ip, attempts := range rl.attempts {
var valid []time.Time
for _, t := range attempts {
if now.Sub(t) < time.Minute {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(rl.attempts, ip)
} else {
rl.attempts[ip] = valid
}
}
}
func (rl *rateLimiter) isAllowed(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
attempts := rl.attempts[ip]
// Filter attempts within the last minute
var validAttempts []time.Time
for _, t := range attempts {
if now.Sub(t) < time.Minute {
validAttempts = append(validAttempts, t)
}
}
if len(validAttempts) >= rl.maxAttempts {
return false
}
validAttempts = append(validAttempts, now)
rl.attempts[ip] = validAttempts
return true
}
// RateLimit limits requests per IP address
func RateLimit(cfg *config.Config) func(http.Handler) http.Handler {
limiter := newRateLimiter(cfg.Security.MaxAttemptsPerMin)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !limiter.isAllowed(ip) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,17 @@
package middleware
import (
"net/http"
)
// SecurityHeaders adds security headers to responses
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,56 @@
package middleware
import (
"context"
"net/http"
"strings"
"aggios-app/backend/internal/repository"
)
type tenantContextKey string
const TenantIDKey tenantContextKey = "tenantID"
const SubdomainKey tenantContextKey = "subdomain"
// TenantDetector detects tenant from subdomain
func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
// Extract subdomain
// Examples:
// - agencia-xyz.localhost -> agencia-xyz
// - agencia-xyz.aggios.app -> agencia-xyz
// - dash.localhost -> dash (master admin)
// - localhost -> (institutional site)
parts := strings.Split(host, ".")
var subdomain string
if len(parts) >= 2 {
// Has subdomain
subdomain = parts[0]
// Remove port if present
if strings.Contains(subdomain, ":") {
subdomain = strings.Split(subdomain, ":")[0]
}
}
// Add subdomain to context
ctx := context.WithValue(r.Context(), SubdomainKey, subdomain)
// If subdomain is not empty and not "dash" or "api", try to find tenant
if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" {
tenant, err := tenantRepo.FindBySubdomain(subdomain)
if err == nil && tenant != nil {
ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String())
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,96 @@
package config
import (
"os"
)
// Config holds all application configuration
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
Security SecurityConfig
App AppConfig
}
// AppConfig holds application-level settings
type AppConfig struct {
Environment string // "development" or "production"
BaseDomain string // "localhost" or "aggios.app"
}
// ServerConfig holds server-specific configuration
type ServerConfig struct {
Port string
}
// DatabaseConfig holds database connection settings
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Name string
}
// JWTConfig holds JWT configuration
type JWTConfig struct {
Secret string
}
// SecurityConfig holds security settings
type SecurityConfig struct {
AllowedOrigins []string
MaxAttemptsPerMin int
PasswordMinLength int
}
// Load loads configuration from environment variables
func Load() *Config {
env := getEnvOrDefault("APP_ENV", "development")
baseDomain := "localhost"
if env == "production" {
baseDomain = "aggios.app"
}
return &Config{
Server: ServerConfig{
Port: getEnvOrDefault("SERVER_PORT", "8080"),
},
Database: DatabaseConfig{
Host: getEnvOrDefault("DB_HOST", "localhost"),
Port: getEnvOrDefault("DB_PORT", "5432"),
User: getEnvOrDefault("DB_USER", "postgres"),
Password: getEnvOrDefault("DB_PASSWORD", "postgres"),
Name: getEnvOrDefault("DB_NAME", "aggios"),
},
JWT: JWTConfig{
Secret: getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"),
},
App: AppConfig{
Environment: env,
BaseDomain: baseDomain,
},
Security: SecurityConfig{
AllowedOrigins: []string{
"http://localhost",
"http://dash.localhost",
"http://aggios.local",
"http://dash.aggios.local",
"https://aggios.app",
"https://dash.aggios.app",
"https://www.aggios.app",
},
MaxAttemptsPerMin: 5,
PasswordMinLength: 8,
},
}
}
// getEnvOrDefault returns environment variable or default value
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -0,0 +1,31 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// Company represents a company in the system
type Company struct {
ID uuid.UUID `json:"id" db:"id"`
CNPJ string `json:"cnpj" db:"cnpj"`
RazaoSocial string `json:"razao_social" db:"razao_social"`
NomeFantasia string `json:"nome_fantasia" db:"nome_fantasia"`
Email string `json:"email" db:"email"`
Telefone string `json:"telefone" db:"telefone"`
Status string `json:"status" db:"status"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
CreatedByUserID *uuid.UUID `json:"created_by_user_id,omitempty" db:"created_by_user_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateCompanyRequest represents the request to create a new company
type CreateCompanyRequest struct {
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razao_social"`
NomeFantasia string `json:"nome_fantasia"`
Email string `json:"email"`
Telefone string `json:"telefone"`
}

View File

@@ -0,0 +1,43 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// Tenant represents a tenant (agency) in the system
type Tenant struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Domain string `json:"domain" db:"domain"`
Subdomain string `json:"subdomain" db:"subdomain"`
CNPJ string `json:"cnpj,omitempty" db:"cnpj"`
RazaoSocial string `json:"razao_social,omitempty" db:"razao_social"`
Email string `json:"email,omitempty" db:"email"`
Phone string `json:"phone,omitempty" db:"phone"`
Website string `json:"website,omitempty" db:"website"`
Address string `json:"address,omitempty" db:"address"`
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"`
}
// CreateTenantRequest represents the request to create a new tenant
type CreateTenantRequest struct {
Name string `json:"name"`
Domain string `json:"domain"`
Subdomain string `json:"subdomain"`
}
// AgencyDetails aggregates tenant info with its admin user for superadmin view
type AgencyDetails struct {
Tenant *Tenant `json:"tenant"`
Admin *User `json:"admin,omitempty"`
AccessURL string `json:"access_url"`
}

View File

@@ -0,0 +1,73 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// User represents a user in the system
type User struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"`
Email string `json:"email" db:"email"`
Password string `json:"-" db:"password_hash"`
Name string `json:"name" db:"first_name"`
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateUserRequest represents the request to create a new user
type CreateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role,omitempty"` // Optional, defaults to CLIENTE
}
// RegisterAgencyRequest represents agency registration (SUPERADMIN only)
type RegisterAgencyRequest struct {
// Agência
AgencyName string `json:"agencyName"`
Subdomain string `json:"subdomain"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
// Endereço
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"`
// Admin da Agência
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
}
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
type RegisterClientRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
// LoginRequest represents the login request
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse represents the login response
type LoginResponse struct {
Token string `json:"token"`
User User `json:"user"`
Subdomain *string `json:"subdomain,omitempty"`
}

View File

@@ -0,0 +1,127 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// CompanyRepository handles database operations for companies
type CompanyRepository struct {
db *sql.DB
}
// NewCompanyRepository creates a new company repository
func NewCompanyRepository(db *sql.DB) *CompanyRepository {
return &CompanyRepository{db: db}
}
// Create creates a new company
func (r *CompanyRepository) Create(company *domain.Company) error {
query := `
INSERT INTO companies (id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at
`
now := time.Now()
company.ID = uuid.New()
company.CreatedAt = now
company.UpdatedAt = now
return r.db.QueryRow(
query,
company.ID,
company.CNPJ,
company.RazaoSocial,
company.NomeFantasia,
company.Email,
company.Telefone,
company.Status,
company.TenantID,
company.CreatedByUserID,
company.CreatedAt,
company.UpdatedAt,
).Scan(&company.ID, &company.CreatedAt, &company.UpdatedAt)
}
// FindByID finds a company by ID
func (r *CompanyRepository) FindByID(id uuid.UUID) (*domain.Company, error) {
query := `
SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at
FROM companies
WHERE id = $1
`
company := &domain.Company{}
err := r.db.QueryRow(query, id).Scan(
&company.ID,
&company.CNPJ,
&company.RazaoSocial,
&company.NomeFantasia,
&company.Email,
&company.Telefone,
&company.Status,
&company.TenantID,
&company.CreatedByUserID,
&company.CreatedAt,
&company.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return company, err
}
// FindByTenantID finds all companies for a tenant
func (r *CompanyRepository) FindByTenantID(tenantID uuid.UUID) ([]*domain.Company, error) {
query := `
SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at
FROM companies
WHERE tenant_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var companies []*domain.Company
for rows.Next() {
company := &domain.Company{}
err := rows.Scan(
&company.ID,
&company.CNPJ,
&company.RazaoSocial,
&company.NomeFantasia,
&company.Email,
&company.Telefone,
&company.Status,
&company.TenantID,
&company.CreatedByUserID,
&company.CreatedAt,
&company.UpdatedAt,
)
if err != nil {
return nil, err
}
companies = append(companies, company)
}
return companies, nil
}
// CNPJExists checks if a CNPJ is already registered for a tenant
func (r *CompanyRepository) CNPJExists(cnpj string, tenantID uuid.UUID) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM companies WHERE cnpj = $1 AND tenant_id = $2)`
err := r.db.QueryRow(query, cnpj, tenantID).Scan(&exists)
return exists, err
}

View File

@@ -0,0 +1,268 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// TenantRepository handles database operations for tenants
type TenantRepository struct {
db *sql.DB
}
// NewTenantRepository creates a new tenant repository
func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: 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
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, created_at, updated_at
`
now := time.Now()
tenant.ID = uuid.New()
tenant.CreatedAt = now
tenant.UpdatedAt = now
return r.db.QueryRow(
query,
tenant.ID,
tenant.Name,
tenant.Domain,
tenant.Subdomain,
tenant.CNPJ,
tenant.RazaoSocial,
tenant.Email,
tenant.Website,
tenant.Address,
tenant.City,
tenant.State,
tenant.Zip,
tenant.Description,
tenant.Industry,
tenant.CreatedAt,
tenant.UpdatedAt,
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
}
// 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
FROM tenants
WHERE id = $1
`
tenant := &domain.Tenant{}
var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString
err := r.db.QueryRow(query, id).Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&cnpj,
&razaoSocial,
&email,
&phone,
&website,
&address,
&city,
&state,
&zip,
&description,
&industry,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
// Handle nullable fields
if cnpj.Valid {
tenant.CNPJ = cnpj.String
}
if razaoSocial.Valid {
tenant.RazaoSocial = razaoSocial.String
}
if email.Valid {
tenant.Email = email.String
}
if phone.Valid {
tenant.Phone = phone.String
}
if website.Valid {
tenant.Website = website.String
}
if address.Valid {
tenant.Address = address.String
}
if city.Valid {
tenant.City = city.String
}
if state.Valid {
tenant.State = state.String
}
if zip.Valid {
tenant.Zip = zip.String
}
if description.Valid {
tenant.Description = description.String
}
if industry.Valid {
tenant.Industry = industry.String
}
return tenant, nil
}
// FindBySubdomain finds a tenant by subdomain
func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, created_at, updated_at
FROM tenants
WHERE subdomain = $1
`
tenant := &domain.Tenant{}
err := r.db.QueryRow(query, subdomain).Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return tenant, err
}
// SubdomainExists checks if a subdomain is already taken
func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE subdomain = $1)`
err := r.db.QueryRow(query, subdomain).Scan(&exists)
return exists, err
}
// FindAll returns all tenants
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, is_active, created_at, updated_at
FROM tenants
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var tenants []*domain.Tenant
for rows.Next() {
tenant := &domain.Tenant{}
err := rows.Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err != nil {
return nil, err
}
tenants = append(tenants, tenant)
}
if tenants == nil {
return []*domain.Tenant{}, nil
}
return tenants, nil
}
// 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)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
// UpdateProfile updates tenant profile information
func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interface{}) error {
query := `
UPDATE tenants SET
name = COALESCE($1, name),
cnpj = COALESCE($2, cnpj),
razao_social = COALESCE($3, razao_social),
email = COALESCE($4, email),
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
`
_, err := r.db.Exec(
query,
updates["name"],
updates["cnpj"],
updates["razao_social"],
updates["email"],
updates["phone"],
updates["website"],
updates["address"],
updates["city"],
updates["state"],
updates["zip"],
updates["description"],
updates["industry"],
time.Now(),
id,
)
return err
}

View File

@@ -0,0 +1,154 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// UserRepository handles database operations for users
type UserRepository struct {
db *sql.DB
}
// NewUserRepository creates a new user repository
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
// Create creates a new user
func (r *UserRepository) Create(user *domain.User) error {
query := `
INSERT INTO users (id, tenant_id, email, password_hash, first_name, role, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at
`
now := time.Now()
user.ID = uuid.New()
user.CreatedAt = now
user.UpdatedAt = now
// Default role to CLIENTE if not specified
if user.Role == "" {
user.Role = "CLIENTE"
}
return r.db.QueryRow(
query,
user.ID,
user.TenantID,
user.Email,
user.Password,
user.Name,
user.Role,
true, // is_active
user.CreatedAt,
user.UpdatedAt,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
}
// FindByEmail finds a user by email
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE email = $1 AND is_active = true
`
user := &domain.User{}
err := r.db.QueryRow(query, email).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return user, err
}
// FindByID finds a user by ID
func (r *UserRepository) FindByID(id uuid.UUID) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE id = $1 AND is_active = true
`
user := &domain.User{}
err := r.db.QueryRow(query, id).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return user, err
}
// EmailExists checks if an email is already registered
func (r *UserRepository) EmailExists(email string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`
err := r.db.QueryRow(query, email).Scan(&exists)
return exists, err
}
// UpdatePassword updates a user's password
func (r *UserRepository) UpdatePassword(userID, hashedPassword string) error {
query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
_, err := r.db.Exec(query, hashedPassword, time.Now(), userID)
return err
}
// FindAdminByTenantID returns the primary admin user for a tenant
func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE tenant_id = $1 AND role = 'ADMIN_AGENCIA' AND is_active = true
ORDER BY created_at ASC
LIMIT 1
`
user := &domain.User{}
err := r.db.QueryRow(query, tenantID).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return user, nil
}

View File

@@ -0,0 +1,191 @@
package service
import (
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// AgencyService handles agency registration and management
type AgencyService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
cfg *config.Config
}
// NewAgencyService creates a new agency service
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService {
return &AgencyService{
userRepo: userRepo,
tenantRepo: tenantRepo,
cfg: cfg,
}
}
// RegisterAgency creates a new agency (tenant) and its admin user
// Only SUPERADMIN can call this
func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domain.Tenant, *domain.User, error) {
// Validate password
if len(req.AdminPassword) < s.cfg.Security.PasswordMinLength {
return nil, nil, ErrWeakPassword
}
// Check if subdomain is available
exists, err := s.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
return nil, nil, err
}
if exists {
return nil, nil, ErrSubdomainTaken
}
// Check if admin email already exists
emailExists, err := s.userRepo.EmailExists(req.AdminEmail)
if err != nil {
return nil, nil, err
}
if emailExists {
return nil, nil, ErrEmailAlreadyExists
}
// Create tenant
address := req.Street
if req.Number != "" {
address += ", " + req.Number
}
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,
}
if err := s.tenantRepo.Create(tenant); err != nil {
return nil, nil, err
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return nil, nil, err
}
// Create admin user for the agency
adminUser := &domain.User{
TenantID: &tenant.ID,
Email: req.AdminEmail,
Password: string(hashedPassword),
Name: req.AdminName,
Role: "ADMIN_AGENCIA",
}
if err := s.userRepo.Create(adminUser); err != nil {
return nil, nil, err
}
return tenant, adminUser, nil
}
// RegisterClient creates a new client user for a specific agency
// Only ADMIN_AGENCIA can call this
func (s *AgencyService) RegisterClient(req domain.RegisterClientRequest, tenantID uuid.UUID) (*domain.User, error) {
// Validate password
if len(req.Password) < s.cfg.Security.PasswordMinLength {
return nil, ErrWeakPassword
}
// Check if email already exists
exists, err := s.userRepo.EmailExists(req.Email)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEmailAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Create client user
client := &domain.User{
TenantID: &tenantID,
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Role: "CLIENTE",
}
if err := s.userRepo.Create(client); err != nil {
return nil, err
}
return client, nil
}
// GetAgencyDetails returns tenant and admin information for superadmin view
func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, error) {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
admin, err := s.userRepo.FindAdminByTenantID(id)
if err != nil {
return nil, err
}
protocol := "http://"
if s.cfg.App.Environment == "production" {
protocol = "https://"
}
details := &domain.AgencyDetails{
Tenant: tenant,
AccessURL: fmt.Sprintf("%s%s", protocol, tenant.Domain),
}
if admin != nil {
details.Admin = admin
}
return details, nil
}
// DeleteAgency removes a tenant and its related resources
func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return err
}
if tenant == nil {
return ErrTenantNotFound
}
return s.tenantRepo.Delete(id)
}

View File

@@ -0,0 +1,170 @@
package service
import (
"errors"
"time"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
var (
ErrEmailAlreadyExists = errors.New("email already registered")
ErrInvalidCredentials = errors.New("invalid email or password")
ErrWeakPassword = errors.New("password too weak")
ErrSubdomainTaken = errors.New("subdomain already taken")
ErrUnauthorized = errors.New("unauthorized access")
)
// AuthService handles authentication business logic
type AuthService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
cfg *config.Config
}
// NewAuthService creates a new auth service
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService {
return &AuthService{
userRepo: userRepo,
tenantRepo: tenantRepo,
cfg: cfg,
}
}
// Register creates a new user account
func (s *AuthService) Register(req domain.CreateUserRequest) (*domain.User, error) {
// Validate password strength
if len(req.Password) < s.cfg.Security.PasswordMinLength {
return nil, ErrWeakPassword
}
// Check if email already exists
exists, err := s.userRepo.EmailExists(req.Email)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEmailAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Create user
user := &domain.User{
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
return user, nil
}
// Login authenticates a user and returns a JWT token
func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, error) {
// Find user by email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrInvalidCredentials
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return nil, ErrInvalidCredentials
}
// Generate JWT token
token, err := s.generateToken(user)
if err != nil {
return nil, err
}
response := &domain.LoginResponse{
Token: token,
User: *user,
}
// If user has a tenant, get the subdomain
if user.TenantID != nil {
tenant, err := s.tenantRepo.FindByID(*user.TenantID)
if err == nil && tenant != nil {
response.Subdomain = &tenant.Subdomain
}
}
return response, nil
}
func (s *AuthService) generateToken(user *domain.User) (string, error) {
claims := jwt.MapClaims{
"user_id": user.ID.String(),
"email": user.Email,
"role": user.Role,
"tenant_id": nil,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
}
if user.TenantID != nil {
claims["tenant_id"] = user.TenantID.String()
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.cfg.JWT.Secret))
}
// ChangePassword changes a user's password
func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword string) error {
// Validate new password strength
if len(newPassword) < s.cfg.Security.PasswordMinLength {
return ErrWeakPassword
}
// Parse userID
uid, err := parseUUID(userID)
if err != nil {
return ErrInvalidCredentials
}
// Find user
user, err := s.userRepo.FindByID(uid)
if err != nil {
return err
}
if user == nil {
return ErrInvalidCredentials
}
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(currentPassword)); err != nil {
return ErrInvalidCredentials
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
// Update password
return s.userRepo.UpdatePassword(userID, string(hashedPassword))
}
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}

View File

@@ -0,0 +1,73 @@
package service
import (
"errors"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
var (
ErrCompanyNotFound = errors.New("company not found")
ErrCNPJAlreadyExists = errors.New("CNPJ already registered")
)
// CompanyService handles company business logic
type CompanyService struct {
companyRepo *repository.CompanyRepository
}
// NewCompanyService creates a new company service
func NewCompanyService(companyRepo *repository.CompanyRepository) *CompanyService {
return &CompanyService{
companyRepo: companyRepo,
}
}
// Create creates a new company
func (s *CompanyService) Create(req domain.CreateCompanyRequest, tenantID, userID uuid.UUID) (*domain.Company, error) {
// Check if CNPJ already exists for this tenant
exists, err := s.companyRepo.CNPJExists(req.CNPJ, tenantID)
if err != nil {
return nil, err
}
if exists {
return nil, ErrCNPJAlreadyExists
}
company := &domain.Company{
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
NomeFantasia: req.NomeFantasia,
Email: req.Email,
Telefone: req.Telefone,
Status: "active",
TenantID: tenantID,
CreatedByUserID: &userID,
}
if err := s.companyRepo.Create(company); err != nil {
return nil, err
}
return company, nil
}
// GetByID retrieves a company by ID
func (s *CompanyService) GetByID(id uuid.UUID) (*domain.Company, error) {
company, err := s.companyRepo.FindByID(id)
if err != nil {
return nil, err
}
if company == nil {
return nil, ErrCompanyNotFound
}
return company, nil
}
// ListByTenant retrieves all companies for a tenant
func (s *CompanyService) ListByTenant(tenantID uuid.UUID) ([]*domain.Company, error) {
return s.companyRepo.FindByTenantID(tenantID)
}

View File

@@ -0,0 +1,91 @@
package service
import (
"database/sql"
"errors"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
var (
ErrTenantNotFound = errors.New("tenant not found")
)
// TenantService handles tenant business logic
type TenantService struct {
tenantRepo *repository.TenantRepository
}
// NewTenantService creates a new tenant service
func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService {
return &TenantService{
tenantRepo: tenantRepo,
}
}
// Create creates a new tenant
func (s *TenantService) Create(req domain.CreateTenantRequest) (*domain.Tenant, error) {
// Check if subdomain already exists
exists, err := s.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
return nil, err
}
if exists {
return nil, ErrSubdomainTaken
}
tenant := &domain.Tenant{
Name: req.Name,
Domain: req.Domain,
Subdomain: req.Subdomain,
}
if err := s.tenantRepo.Create(tenant); err != nil {
return nil, err
}
return tenant, nil
}
// GetByID retrieves a tenant by ID
func (s *TenantService) GetByID(id uuid.UUID) (*domain.Tenant, error) {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
return tenant, nil
}
// GetBySubdomain retrieves a tenant by subdomain
func (s *TenantService) GetBySubdomain(subdomain string) (*domain.Tenant, error) {
tenant, err := s.tenantRepo.FindBySubdomain(subdomain)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
return tenant, nil
}
// ListAll retrieves all tenants
func (s *TenantService) ListAll() ([]*domain.Tenant, error) {
return s.tenantRepo.FindAll()
}
// Delete removes a tenant by ID
func (s *TenantService) Delete(id uuid.UUID) error {
if err := s.tenantRepo.Delete(id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrTenantNotFound
}
return err
}
return nil
}