diff --git a/src/.env.example b/src/.env.example index 3fb9aee..b6c3f16 100644 --- a/src/.env.example +++ b/src/.env.example @@ -25,4 +25,10 @@ VALKEY_PASSWORD= # Valkey database index (0-15 for standalone mode) VALKEY_DB=0 # Key prefix for namespacing (useful for multi-tenant setups) -VALKEY_KEY_PREFIX=azwap: \ No newline at end of file +VALKEY_KEY_PREFIX=azwap: + +# Portal Settings +# Master key for internal services (Bots/Admin) to generate Magic Links +PORTAL_INTERNAL_KEY=your_super_secret_internal_key_here +# Shared secret for Portal JWT signing (should be different from APP_SECRET_KEY for isolation) +PORTAL_JWT_SECRET=your_portal_specific_jwt_secret_here \ No newline at end of file diff --git a/src/botengine/application/bot.go b/src/botengine/application/bot.go index 2923878..ec25cca 100644 --- a/src/botengine/application/bot.go +++ b/src/botengine/application/bot.go @@ -112,8 +112,11 @@ func (s *botService) Create(ctx context.Context, req domainBot.CreateBotRequest) ChatwootCredentialID: strings.TrimSpace(req.ChatwootCredentialID), ChatwootBotToken: strings.TrimSpace(req.ChatwootBotToken), Whitelist: req.Whitelist, + Variants: req.Variants, } + bot.SanitizeVariants() + if err := s.repo.Create(ctx, bot); err != nil { return domainBot.Bot{}, err } @@ -209,6 +212,9 @@ func (s *botService) Update(ctx context.Context, id string, req domainBot.Update updated.ChatwootCredentialID = strings.TrimSpace(req.ChatwootCredentialID) updated.ChatwootBotToken = strings.TrimSpace(req.ChatwootBotToken) updated.Whitelist = req.Whitelist + updated.Variants = req.Variants + + updated.SanitizeVariants() if err := s.repo.Update(ctx, updated); err != nil { return domainBot.Bot{}, err diff --git a/src/botengine/domain/bot/bot.go b/src/botengine/domain/bot/bot.go index 40da8ad..ec33e54 100644 --- a/src/botengine/domain/bot/bot.go +++ b/src/botengine/domain/bot/bot.go @@ -2,6 +2,7 @@ package bot import ( "context" + "strings" domainHealth "github.com/AzielCF/az-wap/domains/health" ) @@ -39,8 +40,23 @@ type Bot struct { ChatwootCredentialID string `json:"chatwoot_credential_id,omitempty"` ChatwootBotToken string `json:"chatwoot_bot_token,omitempty"` // New fields added - ChatwootCredential ChatwootCredential `json:"chatwoot_credential,omitempty"` - Whitelist []string `json:"whitelist,omitempty"` + ChatwootCredential ChatwootCredential `json:"chatwoot_credential,omitempty"` + Whitelist []string `json:"whitelist,omitempty"` + Variants map[string]BotVariant `json:"variants,omitempty"` +} + +type BotVariant struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + SystemPrompt string `json:"system_prompt"` + AllowedTools []string `json:"allowed_tools,omitempty"` + AllowedMCPs []string `json:"allowed_mcps,omitempty"` + + AudioEnabled *bool `json:"audio_enabled,omitempty"` + ImageEnabled *bool `json:"image_enabled,omitempty"` + VideoEnabled *bool `json:"video_enabled,omitempty"` + DocumentEnabled *bool `json:"document_enabled,omitempty"` + MemoryEnabled *bool `json:"memory_enabled,omitempty"` } type ChatwootCredential struct { @@ -67,9 +83,10 @@ type CreateBotRequest struct { MultimodalModel string `json:"multimodal_model"` CredentialID string `json:"credential_id"` // Optional Chatwoot config - ChatwootCredentialID string `json:"chatwoot_credential_id"` - ChatwootBotToken string `json:"chatwoot_bot_token"` - Whitelist []string `json:"whitelist"` + ChatwootCredentialID string `json:"chatwoot_credential_id"` + ChatwootBotToken string `json:"chatwoot_bot_token"` + Whitelist []string `json:"whitelist"` + Variants map[string]BotVariant `json:"variants"` } type UpdateBotRequest struct { @@ -91,9 +108,10 @@ type UpdateBotRequest struct { MultimodalModel string `json:"multimodal_model"` CredentialID string `json:"credential_id"` // Optional Chatwoot config - ChatwootCredentialID string `json:"chatwoot_credential_id"` - ChatwootBotToken string `json:"chatwoot_bot_token"` - Whitelist []string `json:"whitelist"` + ChatwootCredentialID string `json:"chatwoot_credential_id"` + ChatwootBotToken string `json:"chatwoot_bot_token"` + Whitelist []string `json:"whitelist"` + Variants map[string]BotVariant `json:"variants"` } type IBotUsecase interface { @@ -106,3 +124,47 @@ type IBotUsecase interface { SetHealthUsecase(h domainHealth.IHealthUsecase) Shutdown() } + +func (b *Bot) SanitizeVariants() { + if b.Variants == nil { + return + } + + cleaned := make(map[string]BotVariant) + for key, variant := range b.Variants { + name := strings.TrimSpace(variant.Name) + if name == "" { + continue // Ignorar variantes sin nombre + } + + variant.Name = name + variant.Description = strings.TrimSpace(variant.Description) + variant.SystemPrompt = strings.TrimSpace(variant.SystemPrompt) + + // Clean tools string slice + var validTools []string + for _, t := range variant.AllowedTools { + if trimmed := strings.TrimSpace(t); trimmed != "" { + validTools = append(validTools, trimmed) + } + } + variant.AllowedTools = validTools + + // Clean mcps string slice + var validMCPs []string + for _, m := range variant.AllowedMCPs { + if trimmed := strings.TrimSpace(m); trimmed != "" { + validMCPs = append(validMCPs, trimmed) + } + } + variant.AllowedMCPs = validMCPs + + cleaned[key] = variant + } + + if len(cleaned) == 0 { + b.Variants = nil + } else { + b.Variants = cleaned + } +} diff --git a/src/botengine/repository/bot_gorm.go b/src/botengine/repository/bot_gorm.go index 3b7fc26..29f03cb 100644 --- a/src/botengine/repository/bot_gorm.go +++ b/src/botengine/repository/bot_gorm.go @@ -23,19 +23,20 @@ type botModel struct { Model string SystemPrompt string KnowledgeBase string - AudioEnabled bool `gorm:"column:audio_enabled;not null;default:false"` - ImageEnabled bool `gorm:"column:image_enabled;not null;default:false"` - VideoEnabled bool `gorm:"column:video_enabled;not null;default:false"` - DocumentEnabled bool `gorm:"column:document_enabled;not null;default:false"` - MemoryEnabled bool `gorm:"column:memory_enabled;not null;default:false"` - MindsetModel sql.NullString `gorm:"column:mindset_model"` - MultimodalModel sql.NullString `gorm:"column:multimodal_model"` - CredentialID sql.NullString `gorm:"column:credential_id"` - ChatwootCredentialID sql.NullString `gorm:"column:chatwoot_credential_id"` - ChatwootBotToken sql.NullString `gorm:"column:chatwoot_bot_token"` - Whitelist sql.NullString `gorm:"column:whitelist"` // CSV string - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + AudioEnabled bool `gorm:"column:audio_enabled;not null;default:false"` + ImageEnabled bool `gorm:"column:image_enabled;not null;default:false"` + VideoEnabled bool `gorm:"column:video_enabled;not null;default:false"` + DocumentEnabled bool `gorm:"column:document_enabled;not null;default:false"` + MemoryEnabled bool `gorm:"column:memory_enabled;not null;default:false"` + MindsetModel sql.NullString `gorm:"column:mindset_model"` + MultimodalModel sql.NullString `gorm:"column:multimodal_model"` + CredentialID sql.NullString `gorm:"column:credential_id"` + ChatwootCredentialID sql.NullString `gorm:"column:chatwoot_credential_id"` + ChatwootBotToken sql.NullString `gorm:"column:chatwoot_bot_token"` + Whitelist sql.NullString `gorm:"column:whitelist"` // CSV string + Variants map[string]domainBot.BotVariant `gorm:"serializer:json"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` } // TableName especifica el nombre de la tabla para GORM. @@ -126,6 +127,7 @@ func toBotModel(b domainBot.Bot) botModel { ChatwootCredentialID: sql.NullString{String: b.ChatwootCredentialID, Valid: b.ChatwootCredentialID != ""}, ChatwootBotToken: sql.NullString{String: b.ChatwootBotToken, Valid: b.ChatwootBotToken != ""}, Whitelist: sql.NullString{String: strings.Join(b.Whitelist, ","), Valid: len(b.Whitelist) > 0}, + Variants: b.Variants, } } @@ -157,6 +159,7 @@ func fromBotModel(m botModel) domainBot.Bot { ChatwootCredentialID: nullStringValue(m.ChatwootCredentialID), ChatwootBotToken: nullStringValue(m.ChatwootBotToken), Whitelist: whitelist, + Variants: m.Variants, } } diff --git a/src/botengine/tools/only-clients/client_tools.go b/src/botengine/tools/only-clients/client_tools.go index 0625054..fe2732f 100644 --- a/src/botengine/tools/only-clients/client_tools.go +++ b/src/botengine/tools/only-clients/client_tools.go @@ -8,6 +8,7 @@ import ( "github.com/AzielCF/az-wap/botengine/domain" domainMCP "github.com/AzielCF/az-wap/botengine/domain/mcp" clientsDomain "github.com/AzielCF/az-wap/clients/domain" + portalAuth "github.com/AzielCF/az-wap/clients_portal/auth/application" ) const ( @@ -33,12 +34,16 @@ var AllowedClientFields = []string{ // ClientTools provides tools for the client to manage their information type ClientTools struct { - clientRepo clientsDomain.ClientRepository + clientRepo clientsDomain.ClientRepository + authService *portalAuth.AuthService } // NewClientTools creates a new instance of ClientTools -func NewClientTools(clientRepo clientsDomain.ClientRepository) *ClientTools { - return &ClientTools{clientRepo: clientRepo} +func NewClientTools(clientRepo clientsDomain.ClientRepository, authService *portalAuth.AuthService) *ClientTools { + return &ClientTools{ + clientRepo: clientRepo, + authService: authService, + } } // UpdateMyInfoTool allows the client to update their personal information diff --git a/src/botengine/tools/only-clients/portal_tool.go b/src/botengine/tools/only-clients/portal_tool.go new file mode 100644 index 0000000..83cfdf6 --- /dev/null +++ b/src/botengine/tools/only-clients/portal_tool.go @@ -0,0 +1,76 @@ +package onlyclients + +import ( + "context" + "fmt" + + "github.com/AzielCF/az-wap/botengine/domain" + domainMCP "github.com/AzielCF/az-wap/botengine/domain/mcp" + coreconfig "github.com/AzielCF/az-wap/core/config" +) + +// GetPortalLinkTool allows a registered client to get their portal access link +func (t *ClientTools) GetPortalLinkTool() *domain.NativeTool { + return &domain.NativeTool{ + IsVisible: IsClientRegistered, + Tool: domainMCP.Tool{ + Name: "get_portal_access_link", + Description: "Generates a secure, temporary magic link for the user to access their client portal. Use this ONLY when the user asks for 'access', 'portal', 'link to my account', or similar. If the user is not a registered client, it will fail.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, + }, + }, + Handler: func(ctx context.Context, ctxData map[string]interface{}, args map[string]interface{}) (map[string]interface{}, error) { + // 1. Get client ID from context (Already verified by IsVisible) + clientID := "" + phone := "" + + if cc, ok := ctxData["client_context"].(*domain.ClientContext); ok && cc != nil { + clientID = cc.ClientID + phone = cc.Phone + } + + // Fallback if context is somehow missing (safety) + if clientID == "" { + if metadata, ok := ctxData["metadata"].(map[string]any); ok { + if cID, ok := metadata["client_id"].(string); ok { + clientID = cID + } + if ph, ok := metadata["phone"].(string); ok { + phone = ph + } + } + } + + if clientID == "" { + return map[string]interface{}{ + "success": false, + "message": "I couldn't identify your client profile. Access denied.", + }, nil + } + + // 2. Generate Magic Link via AuthService + token, err := t.authService.GenerateMagicLink(ctx, clientID, phone) + if err != nil { + return nil, fmt.Errorf("failed to generate portal link: %w", err) + } + + // 3. Build the final URL + portalURL := coreconfig.Global.App.PortalURL + if portalURL == "" { + // Fallback to local server if no dedicated portal URL is set + portalURL = fmt.Sprintf("%s/portal", coreconfig.Global.App.BaseUrl) + } + + accessLink := fmt.Sprintf("%s/auth/redeem?token=%s", portalURL, token) + + return map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("Access link generated: %s (Expires in 15m). Please send this link to the user.", accessLink), + "link": accessLink, + }, nil + }, + } +} diff --git a/src/clients/adapter/rest/dto.go b/src/clients/adapter/rest/dto.go index 288d124..36e8ebd 100644 --- a/src/clients/adapter/rest/dto.go +++ b/src/clients/adapter/rest/dto.go @@ -6,37 +6,39 @@ import ( // CreateClientRequest representa la petición para crear un cliente type CreateClientRequest struct { - PlatformID string `json:"platform_id"` - PlatformType string `json:"platform_type"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - Phone string `json:"phone"` - Tier string `json:"tier"` - Tags []string `json:"tags"` - Metadata map[string]any `json:"metadata"` - Notes string `json:"notes"` - Language string `json:"language"` - Timezone string `json:"timezone"` - Country string `json:"country"` - AllowedBots []string `json:"allowed_bots"` - IsTester bool `json:"is_tester"` + PlatformID string `json:"platform_id"` + PlatformType string `json:"platform_type"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Phone string `json:"phone"` + Tier string `json:"tier"` + Tags []string `json:"tags"` + Metadata map[string]any `json:"metadata"` + Notes string `json:"notes"` + Language string `json:"language"` + Timezone string `json:"timezone"` + Country string `json:"country"` + AllowedBots []string `json:"allowed_bots"` + OwnedChannels []string `json:"owned_channels"` + IsTester bool `json:"is_tester"` } // UpdateClientRequest representa la petición para actualizar un cliente type UpdateClientRequest struct { - PlatformID *string `json:"platform_id"` - DisplayName *string `json:"display_name"` - Email *string `json:"email"` - Phone *string `json:"phone"` - Tier *string `json:"tier"` - Tags []string `json:"tags"` - Metadata map[string]any `json:"metadata"` - Notes *string `json:"notes"` - Language *string `json:"language"` - Timezone *string `json:"timezone"` - Country *string `json:"country"` - AllowedBots []string `json:"allowed_bots"` - IsTester *bool `json:"is_tester"` + PlatformID *string `json:"platform_id"` + DisplayName *string `json:"display_name"` + Email *string `json:"email"` + Phone *string `json:"phone"` + Tier *string `json:"tier"` + Tags []string `json:"tags"` + Metadata map[string]any `json:"metadata"` + Notes *string `json:"notes"` + Language *string `json:"language"` + Timezone *string `json:"timezone"` + Country *string `json:"country"` + AllowedBots []string `json:"allowed_bots"` + OwnedChannels []string `json:"owned_channels"` + IsTester *bool `json:"is_tester"` } // CreateSubscriptionRequest representa la petición para crear una suscripción diff --git a/src/clients/adapter/rest/handler.go b/src/clients/adapter/rest/handler.go index 0c1a452..e6cf386 100644 --- a/src/clients/adapter/rest/handler.go +++ b/src/clients/adapter/rest/handler.go @@ -3,6 +3,10 @@ package rest import ( "github.com/AzielCF/az-wap/clients/application" "github.com/AzielCF/az-wap/clients/domain" + "github.com/AzielCF/az-wap/pkg/utils" + wsDomain "github.com/AzielCF/az-wap/workspace/domain/workspace" + wsRepo "github.com/AzielCF/az-wap/workspace/repository" + wsUcase "github.com/AzielCF/az-wap/workspace/usecase" "github.com/gofiber/fiber/v2" ) @@ -10,13 +14,17 @@ import ( type ClientHandler struct { clientService *application.ClientService subService *application.SubscriptionService + wsRepo wsRepo.IWorkspaceRepository + wsUc *wsUcase.WorkspaceUsecase } // NewClientHandler crea una nueva instancia del handler -func NewClientHandler(clientService *application.ClientService, subService *application.SubscriptionService) *ClientHandler { +func NewClientHandler(clientService *application.ClientService, subService *application.SubscriptionService, wsRepo wsRepo.IWorkspaceRepository, wsUc *wsUcase.WorkspaceUsecase) *ClientHandler { return &ClientHandler{ clientService: clientService, subService: subService, + wsRepo: wsRepo, + wsUc: wsUc, } } @@ -50,6 +58,27 @@ func (h *ClientHandler) RegisterRoutes(router fiber.Router) { // Suscripciones por canal router.Get("/channels/:channelId/subscribers", h.ListChannelSubscribers) + + // Canales del cliente + clients.Get("/:id/channels", h.ListClientChannels) + + // Workspaces y Guests de cliente (Admin Access) + clients.Get("/:id/workspaces", h.ListClientWorkspaces) + clients.Post("/:id/workspaces", h.CreateClientWorkspace) + clients.Get("/:id/workspaces/:wid", h.GetClientWorkspace) + clients.Put("/:id/workspaces/:wid", h.UpdateClientWorkspace) + clients.Delete("/:id/workspaces/:wid", h.DeleteClientWorkspace) + + // Canales en Workspace + clients.Get("/:id/workspaces/:wid/channels", h.ListWorkspaceChannels) + clients.Post("/:id/workspaces/:wid/channels/:cid", h.LinkChannel) + clients.Delete("/:id/workspaces/:wid/channels/:cid", h.UnlinkChannel) + + // Guests en Workspace + clients.Get("/:id/workspaces/:wid/guests", h.ListWorkspaceGuests) + clients.Post("/:id/workspaces/:wid/guests", h.CreateWorkspaceGuest) + clients.Put("/:id/workspaces/:wid/guests/:gid", h.UpdateWorkspaceGuest) + clients.Delete("/:id/workspaces/:wid/guests/:gid", h.DeleteWorkspaceGuest) } // ListClients lista clientes con filtros @@ -88,20 +117,21 @@ func (h *ClientHandler) CreateClient(c *fiber.Ctx) error { } client := &domain.Client{ - PlatformID: req.PlatformID, - PlatformType: domain.PlatformType(req.PlatformType), - DisplayName: req.DisplayName, - Email: req.Email, - Phone: req.Phone, - Tier: domain.ClientTier(req.Tier), - Tags: req.Tags, - Metadata: req.Metadata, - Notes: req.Notes, - Language: req.Language, - Timezone: req.Timezone, - Country: req.Country, - AllowedBots: req.AllowedBots, - IsTester: req.IsTester, + PlatformID: req.PlatformID, + PlatformType: domain.PlatformType(req.PlatformType), + DisplayName: req.DisplayName, + Email: req.Email, + Phone: req.Phone, + Tier: domain.ClientTier(req.Tier), + Tags: req.Tags, + Metadata: req.Metadata, + Notes: req.Notes, + Language: req.Language, + Timezone: req.Timezone, + Country: req.Country, + AllowedBots: req.AllowedBots, + OwnedChannels: req.OwnedChannels, + IsTester: req.IsTester, } if err := h.clientService.Create(c.Context(), client); err != nil { @@ -183,6 +213,9 @@ func (h *ClientHandler) UpdateClient(c *fiber.Ctx) error { if req.AllowedBots != nil { client.AllowedBots = req.AllowedBots } + if req.OwnedChannels != nil { + client.OwnedChannels = req.OwnedChannels + } if req.IsTester != nil { client.IsTester = *req.IsTester } @@ -483,3 +516,177 @@ func (h *ClientHandler) UpdateSubscription(c *fiber.Ctx) error { return c.JSON(sub) } + +// --- Client Workspace Handlers (Admin) --- + +func (h *ClientHandler) ListClientWorkspaces(c *fiber.Ctx) error { + clientID := c.Params("id") + workspaces, err := h.wsUc.ListClientWorkspaces(c.Context(), clientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(workspaces) +} + +func (h *ClientHandler) CreateClientWorkspace(c *fiber.Ctx) error { + clientID := c.Params("id") + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + ws, err := h.wsUc.CreateClientWorkspace(c.Context(), clientID, req.Name, req.Description) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(ws) +} + +func (h *ClientHandler) GetClientWorkspace(c *fiber.Ctx) error { + wid := c.Params("wid") + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "workspace not found"}) + } + return c.JSON(ws) +} + +func (h *ClientHandler) UpdateClientWorkspace(c *fiber.Ctx) error { + wid := c.Params("wid") + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "workspace not found"}) + } + + ws.Name = req.Name + ws.Description = req.Description + if err := h.wsRepo.UpdateClientWorkspace(c.Context(), ws); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(ws) +} + +func (h *ClientHandler) DeleteClientWorkspace(c *fiber.Ctx) error { + wid := c.Params("wid") + if err := h.wsUc.DeleteClientWorkspace(c.Context(), wid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.SendStatus(fiber.StatusNoContent) +} + +func (h *ClientHandler) ListClientChannels(c *fiber.Ctx) error { + clientID := c.Params("id") + channels, err := h.wsRepo.ListChannelsByOwnerID(c.Context(), clientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(channels) +} + +func (h *ClientHandler) ListWorkspaceChannels(c *fiber.Ctx) error { + wid := c.Params("wid") + channels, err := h.wsRepo.ListChannelsInClientWorkspace(c.Context(), wid) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(channels) +} + +func (h *ClientHandler) LinkChannel(c *fiber.Ctx) error { + wid := c.Params("wid") + cid := c.Params("cid") + if err := h.wsUc.LinkChannelToClientWorkspace(c.Context(), wid, cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.SendStatus(fiber.StatusNoContent) +} + +func (h *ClientHandler) UnlinkChannel(c *fiber.Ctx) error { + wid := c.Params("wid") + cid := c.Params("cid") + if err := h.wsUc.UnlinkChannelFromClientWorkspace(c.Context(), wid, cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.SendStatus(fiber.StatusNoContent) +} + +func (h *ClientHandler) ListWorkspaceGuests(c *fiber.Ctx) error { + wid := c.Params("wid") + guests, err := h.wsRepo.ListGuestsInClientWorkspace(c.Context(), wid) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(guests) +} + +func (h *ClientHandler) CreateWorkspaceGuest(c *fiber.Ctx) error { + clientID := c.Params("id") + wid := c.Params("wid") + var guest wsDomain.ClientWorkspaceGuest + if err := c.BodyParser(&guest); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + guest.OwnerID = clientID + guest.ClientWorkspaceID = wid + + // Validate that the guest does not have the same identifier as the workspace owner + client, err := h.clientService.GetByID(c.Context(), clientID) + if err == nil && client != nil { + guestPhone := guest.PlatformIdentifiers["whatsapp"] + if utils.MatchWhatsAppIdentities(guestPhone, client.Phone) || utils.MatchWhatsAppIdentities(guestPhone, client.PlatformID) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "the owner client cannot be added as a guest in their own workspace"}) + } + } + newGuest, err := h.wsUc.CreateGuest(c.Context(), guest) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(newGuest) +} + +func (h *ClientHandler) UpdateWorkspaceGuest(c *fiber.Ctx) error { + wid := c.Params("wid") + gid := c.Params("gid") + var req wsDomain.ClientWorkspaceGuest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + req.ID = gid + req.ClientWorkspaceID = wid + + // Validar que el invitado no tenga el mismo número que el dueño del workspace + client, err := h.clientService.GetByID(c.Context(), c.Params("id")) + + if err == nil && client != nil { + guestPhone := req.PlatformIdentifiers["whatsapp"] + if utils.MatchWhatsAppIdentities(guestPhone, client.Phone) || utils.MatchWhatsAppIdentities(guestPhone, client.PlatformID) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "the owner client cannot be added as a guest in their own workspace"}) + } + } + + if err := h.wsUc.UpdateGuest(c.Context(), req); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(req) +} + +func (h *ClientHandler) DeleteWorkspaceGuest(c *fiber.Ctx) error { + gid := c.Params("gid") + if err := h.wsUc.DeleteGuest(c.Context(), gid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/src/clients/domain/models.go b/src/clients/domain/models.go index 245578b..e2b7a54 100644 --- a/src/clients/domain/models.go +++ b/src/clients/domain/models.go @@ -14,6 +14,10 @@ const ( TierEnterprise ClientTier = "enterprise" ) +const ( + TagEnableWorkspaces = "enable_workspaces" +) + // PlatformType representa el tipo de plataforma de origen del cliente type PlatformType string @@ -40,6 +44,7 @@ type Client struct { Timezone string `json:"timezone,omitempty"` // IANA timezone (e.g. America/Lima) Country string `json:"country,omitempty"` // ISO 3166-1 alpha-2 (e.g. PE, US, DO) AllowedBots []string `json:"allowed_bots"` // IDs de bots permitidos para este cliente + OwnedChannels []string `json:"owned_channels"` // IDs de los canales de los que el cliente es dueño SessionTimeout int `json:"session_timeout,omitempty"` // Minutos (Override) InactivityWarningTime int `json:"inactivity_warning_time,omitempty"` // Minutos (Override) Enabled bool `json:"enabled"` @@ -68,3 +73,13 @@ func (c *Client) HasTag(tag string) bool { } return false } + +// HasOwnedChannel verifica si el cliente es dueño de un canal +func (c *Client) HasOwnedChannel(channelID string) bool { + for _, ch := range c.OwnedChannels { + if ch == channelID { + return true + } + } + return false +} diff --git a/src/clients/repository/client_gorm.go b/src/clients/repository/client_gorm.go index 34cc6ba..2f0f5a2 100644 --- a/src/clients/repository/client_gorm.go +++ b/src/clients/repository/client_gorm.go @@ -30,6 +30,7 @@ type clientModel struct { Timezone sql.NullString Country sql.NullString AllowedBots sql.NullString `gorm:"type:text;default:'[]'"` // JSON + OwnedChannels sql.NullString `gorm:"type:text;default:'[]'"` // JSON SessionTimeout int `gorm:"default:0"` InactivityWarningTime int `gorm:"default:0"` Enabled bool `gorm:"default:true"` @@ -332,6 +333,15 @@ func toClientModel(c *domain.Client) (clientModel, error) { return clientModel{}, fmt.Errorf("marshal allowed_bots: %w", err) } + ownedChans := c.OwnedChannels + if ownedChans == nil { + ownedChans = []string{} + } + ownedChannelsJSON, err := json.Marshal(ownedChans) + if err != nil { + return clientModel{}, fmt.Errorf("marshal owned_channels: %w", err) + } + return clientModel{ ID: c.ID, PlatformID: c.PlatformID, @@ -347,6 +357,7 @@ func toClientModel(c *domain.Client) (clientModel, error) { Timezone: sql.NullString{String: c.Timezone, Valid: c.Timezone != ""}, Country: sql.NullString{String: c.Country, Valid: c.Country != ""}, AllowedBots: sql.NullString{String: string(allowedBotsJSON), Valid: true}, + OwnedChannels: sql.NullString{String: string(ownedChannelsJSON), Valid: true}, SessionTimeout: c.SessionTimeout, InactivityWarningTime: c.InactivityWarningTime, Enabled: c.Enabled, @@ -400,5 +411,12 @@ func fromClientModel(m clientModel) (*domain.Client, error) { c.AllowedBots = []string{} } + if m.OwnedChannels.Valid && m.OwnedChannels.String != "" { + _ = json.Unmarshal([]byte(m.OwnedChannels.String), &c.OwnedChannels) + } + if c.OwnedChannels == nil { + c.OwnedChannels = []string{} + } + return c, nil } diff --git a/src/clients_portal/access/domain/rule.go b/src/clients_portal/access/domain/rule.go new file mode 100644 index 0000000..da99e5a --- /dev/null +++ b/src/clients_portal/access/domain/rule.go @@ -0,0 +1,45 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// RuleType defines how the rule is evaluated +type RuleType string + +const ( + RuleTypeRouting RuleType = "ROUTING" // Redirect to a specific bot + RuleTypePermission RuleType = "PERMISSION" // Allow/Deny feature access + RuleTypeLimit RuleType = "LIMIT" // Usage thresholds +) + +// AccessRule represents a dynamic configuration for a client's workspace +type AccessRule struct { + ID string `json:"id" gorm:"primaryKey"` + PortalUserID string `json:"portal_user_id" gorm:"index;not null"` // Owner of the rule + ClientID string `json:"client_id" gorm:"index"` // Associated CRM Client (optional) + + Type RuleType `json:"type" gorm:"not null"` + TargetID string `json:"target_id"` // ID of Bot, Channel or Feature + + // Criteria (Condition for the rule to apply) + ConditionKey string `json:"condition_key"` // e.g., "phone_number", "platform" + ConditionValue string `json:"condition_value"` // e.g., "51999888777", "whatsapp" + + Enabled bool `json:"enabled" gorm:"default:true"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewAccessRule(portalUserID, clientID string, ruleType RuleType, targetID string) *AccessRule { + return &AccessRule{ + ID: uuid.New().String(), + PortalUserID: portalUserID, + ClientID: clientID, + Type: ruleType, + TargetID: targetID, + Enabled: true, + } +} diff --git a/src/clients_portal/auth/application/service.go b/src/clients_portal/auth/application/service.go new file mode 100644 index 0000000..ada8812 --- /dev/null +++ b/src/clients_portal/auth/application/service.go @@ -0,0 +1,408 @@ +package application + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + crmDomain "github.com/AzielCF/az-wap/clients/domain" + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + "github.com/AzielCF/az-wap/clients_portal/auth/repository" + portalSecurity "github.com/AzielCF/az-wap/clients_portal/shared/security" + "github.com/AzielCF/az-wap/core/kvstore" + workspaceDomain "github.com/AzielCF/az-wap/workspace/domain/workspace" + "github.com/google/uuid" +) + +// PortalAccountSummary is a lightweight DTO for admin reporting +type PortalAccountSummary struct { + IsShadow bool `json:"is_shadow"` + Email string `json:"email"` +} + +type AuthService struct { + repo domain.IAuthRepository + clientRepo crmDomain.ClientRepository + workspaceRepo workspaceDomain.IWorkspaceRepository + tokenCache kvstore.KVStore +} + +func NewAuthService(repo domain.IAuthRepository, clientRepo crmDomain.ClientRepository, workspaceRepo workspaceDomain.IWorkspaceRepository, tokenCache kvstore.KVStore) *AuthService { + return &AuthService{ + repo: repo, + clientRepo: clientRepo, + workspaceRepo: workspaceRepo, + tokenCache: tokenCache, + } +} + +// Login verifies credentials and returns a JWT token +func (s *AuthService) Login(ctx context.Context, username, password string) (string, *domain.PortalUser, error) { + // 1. Find user + user, err := s.repo.GetByUsername(ctx, username) + if err != nil { + // Even if user not found, we should ideally wait a bit to prevent timing attacks + return "", nil, errors.New("invalid credentials") + } + + // 2. SECURITY CHECK: Shadow users CANNOT login via password + // They must use Magic Link first and then set a password to set IsShadow = false + if user.IsShadow || user.PasswordHash == "" { + return "", nil, errors.New("please use the access link sent to your device to set up your account") + } + + // 3. Verify password + if !portalSecurity.CheckPasswordHash(password, user.PasswordHash) { + return "", nil, errors.New("invalid credentials") + } + + // 3. Generate Token + token, err := portalSecurity.GenerateToken(user.ID, user.ClientID, user.Role) + if err != nil { + return "", nil, errors.New("failed to generate token") + } + + // 4. Update last_login (background) + go s.repo.UpdateLastLogin(context.Background(), user.ID) + + return token, user, nil +} + +// IAuthService defines the business logic for authentication +type IAuthService interface { + Login(ctx context.Context, username, password string) (string, *domain.PortalUser, error) // Returns Token + User + Register(ctx context.Context, clientID, username, password, fullName string, role domain.PortalRole) (*domain.PortalUser, error) + ValidateToken(ctx context.Context, token string) (*domain.PortalUser, error) + GetUserProfile(ctx context.Context, userID string) (*domain.PortalProfile, error) + UpdateProfile(ctx context.Context, userID string, email *string, fullName, password string) error + GenerateMagicLink(ctx context.Context, clientID, phone string) (string, error) + RedeemMagicLink(ctx context.Context, token string) (string, *domain.PortalUser, error) + CreateAccountByAdmin(ctx context.Context, clientID, email, fullName string) (*domain.PortalUser, error) + ListAccountsState(ctx context.Context, clientIDs []string) (map[string]PortalAccountSummary, error) +} + +// Register creates a new portal user +func (s *AuthService) Register(ctx context.Context, clientID, username, password, fullName string, role domain.PortalRole) (*domain.PortalUser, error) { + // 1. Validate duplicates + existing, _ := s.repo.GetByUsername(ctx, username) + if existing != nil { + return nil, errors.New("username already exists") + } + + // 2. Hash password + hash, err := portalSecurity.HashPassword(password) + if err != nil { + return nil, errors.New("failed to hash password") + } + + // 3. Create user + user := domain.NewPortalUser(clientID, username, hash, role) + user.FullName = fullName + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +// ValidateToken verifies a token and returns the associated user +func (s *AuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.PortalUser, error) { + claims, err := portalSecurity.ValidateToken(tokenString) + if err != nil { + return nil, err + } + + // Optional: Check if user exists/active in DB + user, err := s.repo.GetByID(ctx, claims.UserID) + if err != nil { + return nil, errors.New("user context not found") + } + + if !user.Active { + return nil, errors.New("user account is inactive") + } + + return user, nil +} + +// GetUserProfile returns a combined view of the user and their account information +func (s *AuthService) GetUserProfile(ctx context.Context, userID string) (*domain.PortalProfile, error) { + // 1. Get User + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + // 2. Get associated Client info from CRM + client, err := s.clientRepo.GetByID(ctx, user.ClientID) + if err != nil { + // Log the error but proceed with user-only profile + fmt.Printf("[AuthService] Failed to load CRM client data for %s (Client: %s): %v\n", userID, user.ClientID, err) + return &domain.PortalProfile{ + User: &domain.SafePortalUserView{ + ID: user.ID, + Email: user.Email, + Phone: user.Phone, + Username: user.Username, + FullName: user.FullName, + Role: user.Role, + Active: user.Active, + IsShadow: user.IsShadow, + LastLoginAt: user.LastLoginAt, + CreatedAt: user.CreatedAt, + }, + Account: nil, + }, nil + } + + // 3. ENRICHMENT: If user data is missing, fill from Client CRM data + if user.FullName == "" { + user.FullName = client.DisplayName + } + if user.Phone == "" { + user.Phone = client.Phone + } + // We don't save this back to DB here, just for display + // 3.1 Get Workspace Count + workspaceCount := 0 + if s.workspaceRepo != nil { + wsList, _ := s.workspaceRepo.ListClientWorkspaces(ctx, client.ID) + workspaceCount = len(wsList) + } + + // 4. Return combined profile (SAFE VIEW) + return &domain.PortalProfile{ + User: &domain.SafePortalUserView{ + ID: user.ID, + Email: user.Email, + Phone: user.Phone, + Username: user.Username, + FullName: user.FullName, + Role: user.Role, + Active: user.Active, + IsShadow: user.IsShadow, + LastLoginAt: user.LastLoginAt, + CreatedAt: user.CreatedAt, + }, + Account: &domain.PortalAccountView{ + DisplayName: client.DisplayName, + Phone: client.Phone, + Tier: string(client.Tier), + Tags: client.Tags, + OwnedChannels: client.OwnedChannels, + Language: client.Language, + Timezone: client.Timezone, + Country: client.Country, + WorkspaceCount: workspaceCount, + Metadata: client.Metadata, + IsTester: client.IsTester, + }, + }, nil +} + +// GenerateMagicLink creates a short-lived opaque token for passwordless login +func (s *AuthService) GenerateMagicLink(ctx context.Context, clientID, phone string) (string, error) { + // 1. Check if we already have a valid link for this client + cacheKey := fmt.Sprintf("magic_client:%s", clientID) + if s.tokenCache != nil { + if existing, _ := s.tokenCache.Get(ctx, cacheKey); existing != "" { + // BUGFIX: Verify the token itself still exists in cache before returning it + // It might have been deleted but the mapping survived + if tokenData, _ := s.tokenCache.Get(ctx, fmt.Sprintf("magic_token:%s", existing)); tokenData != "" { + return existing, nil + } + // If not found, clean up the stale mapping and proceed to generate a new one + _ = s.tokenCache.Delete(ctx, cacheKey) + } + } + + // 2. Find or Create Shadow User + var user *domain.PortalUser + users, _ := s.repo.ListByClient(ctx, clientID) + if len(users) > 0 { + user = users[0] + } else if phone != "" { + // Fallback to phone just in case + user, _ = s.repo.GetByPhone(ctx, phone) + } + + if user == nil { + // Create Shadow User using ClientID for uniqueness + user = domain.NewShadowUser(clientID, phone, domain.RoleMember) + if err := s.repo.Create(ctx, user); err != nil { + return "", err + } + } else if user.ClientID != clientID { + // Link the client if it was found by phone but missing clientID + user.ClientID = clientID + _ = s.repo.Update(ctx, user) + } + + // 3. Generate Opaque Token (Short random string) + token, err := portalSecurity.GenerateOpaqueToken() + if err != nil { + return "", err + } + + // 4. Store the mapping: token -> userData + tokenData := map[string]string{ + "uid": user.ID, + "cid": user.ClientID, + } + jsonData, _ := json.Marshal(tokenData) + + if s.tokenCache != nil { + // Save the token data (15m) + _ = s.tokenCache.Set(ctx, fmt.Sprintf("magic_token:%s", token), string(jsonData), 15*time.Minute) + // Save the client mapping for deduplication (15m) + _ = s.tokenCache.Set(ctx, cacheKey, token, 15*time.Minute) + } + + return token, nil +} + +// RedeemMagicLink exchanges a short opaque token for a full session token +func (s *AuthService) RedeemMagicLink(ctx context.Context, opaqueToken string) (string, *domain.PortalUser, error) { + // 1. Look up opaque token in cache + if s.tokenCache == nil { + return "", nil, errors.New("token storage unavailable") + } + + data, err := s.tokenCache.Get(ctx, fmt.Sprintf("magic_token:%s", opaqueToken)) + if err != nil || data == "" { + return "", nil, errors.New("invalid or expired magic link") + } + + // 2. Parse User info + var tokenData map[string]string + if err := json.Unmarshal([]byte(data), &tokenData); err != nil { + return "", nil, errors.New("failed to process token data") + } + + userID := tokenData["uid"] + + // 3. Get User from DB + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return "", nil, errors.New("user not found") + } + + // 4. Generate Session Token (Long lived JWT) + sessionToken, err := portalSecurity.GenerateToken(user.ID, user.ClientID, user.Role) + if err != nil { + return "", nil, err + } + + // 5. Update login time and clean up token + go s.repo.UpdateLastLogin(context.Background(), user.ID) + _ = s.tokenCache.Delete(ctx, fmt.Sprintf("magic_token:%s", opaqueToken)) + + // Bugfix: Also clear the client mapping so a new link can be generated + if cid, ok := tokenData["cid"]; ok { + _ = s.tokenCache.Delete(ctx, fmt.Sprintf("magic_client:%s", cid)) + } + + return sessionToken, user, nil +} + +// CreateAccountByAdmin allows an administrator to provision a portal account without a password +func (s *AuthService) CreateAccountByAdmin(ctx context.Context, clientID, email, fullName string) (*domain.PortalUser, error) { + // 1. Check if user already exists for this client + existing, _ := s.repo.ListByClient(ctx, clientID) + if len(existing) > 0 { + return nil, errors.New("a portal account already exists for this client") + } + + // 2. Determine username (Email if provided, otherwise shadow_ID) + username := email + if username == "" { + username = "shadow_" + clientID + } + + // 3. Create a new user with no password and IsShadow true + var emailPtr *string + if email != "" { + emailPtr = &email + } + + user := &domain.PortalUser{ + ID: uuid.New().String(), + ClientID: clientID, + Email: emailPtr, + Username: username, // Ensure unique username + FullName: fullName, + Role: domain.RoleOwner, + Active: true, + IsShadow: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +// ListAccountsState returns a map of ClientID -> PortalAccountSummary for specific IDs +func (s *AuthService) ListAccountsState(ctx context.Context, clientIDs []string) (map[string]PortalAccountSummary, error) { + if len(clientIDs) == 0 { + return make(map[string]PortalAccountSummary), nil + } + + var users []*domain.PortalUser + repo, ok := s.repo.(*repository.GormAuthRepository) + if !ok { + return nil, errors.New("invalid repository type") + } + + // Updated repository to fetch only requested IDs + if err := repo.GetByClientIDs(ctx, clientIDs, &users); err != nil { + return nil, err + } + + result := make(map[string]PortalAccountSummary) + for _, u := range users { + emailStr := "" + if u.Email != nil { + emailStr = *u.Email + } + result[u.ClientID] = PortalAccountSummary{ + IsShadow: u.IsShadow, + Email: emailStr, + } + } + return result, nil +} + +// UpdateProfile updates user information and clears shadow status if password is set +func (s *AuthService) UpdateProfile(ctx context.Context, userID string, email *string, fullName, password string) error { + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return err + } + + if fullName != "" { + user.FullName = fullName + } + if email != nil { + user.Email = email + } + + // If a password is provided, we hash it and the user is no longer a shadow + if password != "" { + hash, err := portalSecurity.HashPassword(password) + if err != nil { + return err + } + user.PasswordHash = hash + user.IsShadow = false + } + + user.UpdatedAt = time.Now() + return s.repo.Update(ctx, user) +} diff --git a/src/clients_portal/auth/application/service_test.go b/src/clients_portal/auth/application/service_test.go new file mode 100644 index 0000000..ef76628 --- /dev/null +++ b/src/clients_portal/auth/application/service_test.go @@ -0,0 +1,145 @@ +package application + +import ( + "context" + "testing" + "time" + + crmDomain "github.com/AzielCF/az-wap/clients/domain" + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + coreconfig "github.com/AzielCF/az-wap/core/config" + "github.com/AzielCF/az-wap/core/kvstore" +) + +// MockAuthRepository to simulate DB +type mockAuthRepo struct { + users map[string]*domain.PortalUser +} + +func (m *mockAuthRepo) Create(ctx context.Context, user *domain.PortalUser) error { + m.users[user.ID] = user + return nil +} +func (m *mockAuthRepo) GetByUsername(ctx context.Context, username string) (*domain.PortalUser, error) { + return nil, nil // not needed for magic link +} +func (m *mockAuthRepo) GetByPhone(ctx context.Context, phone string) (*domain.PortalUser, error) { + for _, u := range m.users { + if u.Phone == phone { + return u, nil + } + } + return nil, nil +} +func (m *mockAuthRepo) GetByID(ctx context.Context, id string) (*domain.PortalUser, error) { + u, ok := m.users[id] + if !ok { + return nil, nil + } + return u, nil +} +func (m *mockAuthRepo) UpdateLastLogin(ctx context.Context, id string) error { + return nil +} +func (m *mockAuthRepo) Update(ctx context.Context, user *domain.PortalUser) error { + m.users[user.ID] = user + return nil +} +func (m *mockAuthRepo) ListByClient(ctx context.Context, clientID string) ([]*domain.PortalUser, error) { + var results []*domain.PortalUser + for _, u := range m.users { + if u.ClientID == clientID { + results = append(results, u) + } + } + return results, nil +} + +// MockCRMClientRepo +type mockCRMClientRepo struct { +} + +func (m *mockCRMClientRepo) GetByID(ctx context.Context, id string) (*crmDomain.Client, error) { + return &crmDomain.Client{ID: id, DisplayName: "Mock Client"}, nil +} +func (m *mockCRMClientRepo) Create(ctx context.Context, client *crmDomain.Client) error { return nil } +func (m *mockCRMClientRepo) Update(ctx context.Context, client *crmDomain.Client) error { return nil } +func (m *mockCRMClientRepo) Delete(ctx context.Context, id string) error { return nil } +func (m *mockCRMClientRepo) GetByPhone(ctx context.Context, phone string) (*crmDomain.Client, error) { + return nil, nil +} +func (m *mockCRMClientRepo) GetByPlatform(ctx context.Context, platformID string, platformType crmDomain.PlatformType) (*crmDomain.Client, error) { + return nil, nil +} +func (m *mockCRMClientRepo) List(ctx context.Context, filter crmDomain.ClientFilter) ([]*crmDomain.Client, error) { + return nil, nil +} +func (m *mockCRMClientRepo) ListByTier(ctx context.Context, tier crmDomain.ClientTier) ([]*crmDomain.Client, error) { + return nil, nil +} +func (m *mockCRMClientRepo) ListByTag(ctx context.Context, tag string) ([]*crmDomain.Client, error) { + return nil, nil +} +func (m *mockCRMClientRepo) Search(ctx context.Context, query string) ([]*crmDomain.Client, error) { + return nil, nil +} +func (m *mockCRMClientRepo) CountByTier(ctx context.Context) (map[crmDomain.ClientTier]int, error) { + return nil, nil +} +func (m *mockCRMClientRepo) UpdateLastInteraction(ctx context.Context, id string, t time.Time) error { + return nil +} +func (m *mockCRMClientRepo) AddTag(ctx context.Context, id string, tag string) error { + return nil +} +func (m *mockCRMClientRepo) RemoveTag(ctx context.Context, id string, tag string) error { + return nil +} + +func TestAuthService_MagicLink_Flow(t *testing.T) { + // 1. Setup minimal config for JWT + coreconfig.Global = &coreconfig.Config{} + coreconfig.Global.Security.PortalJWTSecret = "test-secret" + + // 2. Setup dependencies + repo := &mockAuthRepo{users: make(map[string]*domain.PortalUser)} + crmRepo := &mockCRMClientRepo{} + cacheStore := kvstore.NewSmartStore(nil) // In-memory + + service := NewAuthService(repo, crmRepo, nil, cacheStore) + ctx := context.Background() + + // SCENARIO 1: Generate and Redeem FIRST token + clientID := "client-123" + phone := "1234567890" + + token1, err := service.GenerateMagicLink(ctx, clientID, phone) + if err != nil { + t.Fatalf("Failed to generate first magic link: %v", err) + } + + session1, user1, err := service.RedeemMagicLink(ctx, token1) + if err != nil { + t.Fatalf("Failed to redeem first magic link: %v", err) + } + if session1 == "" || user1 == nil { + t.Fatalf("Expected valid session and user, got empty/nil") + } + + // SCENARIO 2: Generate and Redeem SECOND token (This is where the user says it crashes or fails entirely) + // Because we cleared the cid mapping, generating another token for the SAME client should create a NEW token. + token2, err := service.GenerateMagicLink(ctx, clientID, phone) + if err != nil { + t.Fatalf("Failed to generate second magic link: %v", err) + } + + session2, user2, err := service.RedeemMagicLink(ctx, token2) + if err != nil { + t.Fatalf("Failed to redeem second magic link: %v", err) + } + if session2 == "" || user2 == nil { + t.Fatalf("Expected valid session and user on second attempt, got empty/nil") + } + + t.Log("Successfully generated and redeemed magic links multiple times without panicking!") +} diff --git a/src/clients_portal/auth/domain/user.go b/src/clients_portal/auth/domain/user.go new file mode 100644 index 0000000..f599326 --- /dev/null +++ b/src/clients_portal/auth/domain/user.go @@ -0,0 +1,127 @@ +package domain + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// PortalRole defines the access level within the portal +type PortalRole string + +const ( + RoleOwner PortalRole = "OWNER" // Full access (Billing, Configuration) + RoleManager PortalRole = "MANAGER" // Team and channel management + RoleMember PortalRole = "MEMBER" // Read-only / Operative +) + +// PortalUser represents a user who can log in to the Client Portal +type PortalUser struct { + ID string `json:"id" gorm:"primaryKey"` + ClientID string `json:"client_id" gorm:"index"` // Link to CRM (Optional) + + // Identity + Email *string `json:"email" gorm:"unique;index"` // Primary ID for Login (Nullable for shadow users) + Phone string `json:"phone" gorm:"index"` // Linked phone for Magic Links (Optional) + Username string `json:"username" gorm:"unique;index"` // Can be Email or Handle + + PasswordHash string `json:"-"` + IsShadow bool `json:"is_shadow" gorm:"default:false"` // True if profile is incomplete (no password/email confirmed) + + FullName string `json:"full_name"` + Role PortalRole `json:"role" gorm:"default:'MEMBER'"` + Active bool `json:"active" gorm:"default:true"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PortalAccountView is a safe subset of Client CRM data for the portal +type PortalAccountView struct { + DisplayName string `json:"display_name"` + Phone string `json:"phone"` + Tier string `json:"tier"` + Tags []string `json:"tags"` + OwnedChannels []string `json:"owned_channels"` + Language string `json:"language"` + Timezone string `json:"timezone"` + Country string `json:"country"` + WorkspaceCount int `json:"workspace_count"` + Metadata map[string]any `json:"metadata"` + IsTester bool `json:"is_tester"` +} + +// SafePortalUserView hides internal IDs like ClientID +type SafePortalUserView struct { + ID string `json:"id"` + Email *string `json:"email"` + Phone string `json:"phone"` + Username string `json:"username"` + FullName string `json:"full_name"` + Role PortalRole `json:"role"` + Active bool `json:"active"` + IsShadow bool `json:"is_shadow"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` +} + +// PortalProfile represents a combined view of the user and their account/client info +type PortalProfile struct { + User *SafePortalUserView `json:"user"` + Account *PortalAccountView `json:"account"` // Replaced *crmDomain.Client with safe view +} + +// NewPortalUser creates a new instance (Standard Registration) +func NewPortalUser(clientID, email, passwordHash string, role PortalRole) *PortalUser { + return &PortalUser{ + ID: uuid.New().String(), + ClientID: clientID, + Email: &email, + Username: email, // Default username is email + PasswordHash: passwordHash, + Role: role, + Active: true, + IsShadow: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +// NewShadowUser creates a temporary user from a Channel (e.g. WhatsApp) +func NewShadowUser(clientID, phone string, role PortalRole) *PortalUser { + return &PortalUser{ + ID: uuid.New().String(), + ClientID: clientID, + Phone: phone, + // Username left empty or generated placeholder until registration is complete + Username: "shadow_" + uuid.New().String()[:8], + Role: role, + Active: true, + IsShadow: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +// IAuthRepository defines the persistence for authentication +type IAuthRepository interface { + Create(ctx context.Context, user *PortalUser) error + GetByUsername(ctx context.Context, username string) (*PortalUser, error) + GetByPhone(ctx context.Context, phone string) (*PortalUser, error) + GetByID(ctx context.Context, id string) (*PortalUser, error) + UpdateLastLogin(ctx context.Context, id string) error + Update(ctx context.Context, user *PortalUser) error + ListByClient(ctx context.Context, clientID string) ([]*PortalUser, error) +} + +// IAuthService defines the business logic for authentication +type IAuthService interface { + Login(ctx context.Context, username, password string) (string, *PortalUser, error) // Returns Token + User + Register(ctx context.Context, clientID, username, password, fullName string, role PortalRole) (*PortalUser, error) + ValidateToken(ctx context.Context, token string) (*PortalUser, error) + GetUserProfile(ctx context.Context, userID string) (*PortalProfile, error) + UpdateProfile(ctx context.Context, userID string, email *string, fullName, password string) error + GenerateMagicLink(ctx context.Context, clientID, phone string) (string, error) + RedeemMagicLink(ctx context.Context, token string) (string, *PortalUser, error) +} diff --git a/src/clients_portal/auth/infrastructure/handlers.go b/src/clients_portal/auth/infrastructure/handlers.go new file mode 100644 index 0000000..96bf4b0 --- /dev/null +++ b/src/clients_portal/auth/infrastructure/handlers.go @@ -0,0 +1,226 @@ +package infrastructure + +import ( + "strings" + + "github.com/AzielCF/az-wap/clients_portal/auth/application" + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + coreconfig "github.com/AzielCF/az-wap/core/config" + "github.com/gofiber/fiber/v2" +) + +type AuthHandler struct { + authService *application.AuthService +} + +func NewAuthHandler(service *application.AuthService) *AuthHandler { + return &AuthHandler{authService: service} +} + +// Request Models +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RegisterRequest struct { + ClientID string `json:"client_id"` // Typically comes from an admin token or context + Username string `json:"username"` + Password string `json:"password"` + FullName string `json:"full_name"` + Role domain.PortalRole `json:"role"` // Optional, default MEMBER +} + +// Login handles portal user authentication +func (h *AuthHandler) Login(c *fiber.Ctx) error { + var req LoginRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + token, user, err := h.authService.Login(c.Context(), req.Username, req.Password) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"}) + } + + return c.JSON(fiber.Map{ + "token": token, + "user": fiber.Map{ + "id": user.ID, + "username": user.Username, + "full_name": user.FullName, + "role": user.Role, + }, + }) +} + +// Register handles new user registration (ideally protected by Admin/Owner role) +func (h *AuthHandler) Register(c *fiber.Ctx) error { + var req RegisterRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // Basic validation + if req.Username == "" || req.Password == "" || req.ClientID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing required fields"}) + } + + // Default role + if req.Role == "" { + req.Role = domain.RoleMember + } + + user, err := h.authService.Register(c.Context(), req.ClientID, req.Username, req.Password, req.FullName, req.Role) + if err != nil { + if strings.Contains(err.Error(), "exists") { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "username already exists"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "message": "user created successfully", + "user_id": user.ID, + }) +} + +// Me returns the logged-in user information including account details +func (h *AuthHandler) Me(c *fiber.Ctx) error { + // Get UserID from context (injected by middleware) + userID, ok := c.Locals("portal_user_id").(string) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + // Get full profile using the service + profile, err := h.authService.GetUserProfile(c.Context(), userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch profile"}) + } + + return c.JSON(profile) +} + +// GenerateMagicLink creates a magic link URL for a phone number (Admin/Bot use only) +func (h *AuthHandler) GenerateMagicLink(c *fiber.Ctx) error { + // 1. Security Check: Validate internal master key + secret := c.Get("X-Internal-Key") + masterKey := coreconfig.Global.Security.PortalInternalKey + + if secret == "" || secret != masterKey { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: invalid internal key"}) + } + + type Request struct { + ClientID string `json:"client_id"` + Phone string `json:"phone"` + } + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + token, err := h.authService.GenerateMagicLink(c.Context(), req.ClientID, req.Phone) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + // In production, this would be the full frontend URL + magicURL := "/auth/magic-login?token=" + token + + return c.JSON(fiber.Map{ + "token": token, + "url": magicURL, + }) +} + +// RedeemMagicLink handles the exchange of magic token for session token +func (h *AuthHandler) RedeemMagicLink(c *fiber.Ctx) error { + type Request struct { + Token string `json:"token"` + } + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + sessionToken, user, err := h.authService.RedeemMagicLink(c.Context(), req.Token) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid or expired link"}) + } + + return c.JSON(fiber.Map{ + "token": sessionToken, + "user": user, + }) +} + +// UpdateProfile handles user profile updates +func (h *AuthHandler) UpdateProfile(c *fiber.Ctx) error { + userID, ok := c.Locals("portal_user_id").(string) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + type Request struct { + Email string `json:"email"` + FullName string `json:"full_name"` + Password string `json:"password"` + } + + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + var emailPtr *string + if req.Email != "" { + emailPtr = &req.Email + } + + err := h.authService.UpdateProfile(c.Context(), userID, emailPtr, req.FullName, req.Password) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"message": "profile updated successfully"}) +} + +// ListAccountsState allows admin to query the portal status of multiple clients +func (h *AuthHandler) ListAccountsState(c *fiber.Ctx) error { + idsParam := c.Query("ids") + var ids []string + if idsParam != "" { + ids = strings.Split(idsParam, ",") + } + + accounts, err := h.authService.ListAccountsState(c.Context(), ids) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(accounts) +} + +// CreatePortalAccount allows admin to provision a portal account for a client +func (h *AuthHandler) CreatePortalAccount(c *fiber.Ctx) error { + clientID := c.Params("id") + type Request struct { + Email string `json:"email"` + FullName string `json:"full_name"` + } + var req Request + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + user, err := h.authService.CreateAccountByAdmin(c.Context(), clientID, req.Email, req.FullName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "message": "portal account provisioned successfully", + "user_id": user.ID, + }) +} diff --git a/src/clients_portal/auth/infrastructure/middleware.go b/src/clients_portal/auth/infrastructure/middleware.go new file mode 100644 index 0000000..9d107b9 --- /dev/null +++ b/src/clients_portal/auth/infrastructure/middleware.go @@ -0,0 +1,73 @@ +package infrastructure + +import ( + "strings" + + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + portalSecurity "github.com/AzielCF/az-wap/clients_portal/shared/security" + "github.com/gofiber/fiber/v2" +) + +// Config for middleware (future: support BetterAuth remote keys) +type AuthConfig struct { + SecretKey []byte +} + +// NewAuthMiddleware creates the middleware to protect portal routes +func NewAuthMiddleware(userRepo domain.IAuthRepository) fiber.Handler { + return func(c *fiber.Ctx) error { + // 1. Extract token + authHeader := c.Get("Authorization") + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing authorization header"}) + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid authorization format"}) + } + + tokenString := parts[1] + + // 2. Validate token (This is where we would switch logic if using BetterAuth) + // In the future, we could validate against an external public key here. + claims, err := portalSecurity.ValidateToken(tokenString) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid or expired token"}) + } + + // 3. Validate user existence and get fresh data + user, err := userRepo.GetByID(c.Context(), claims.UserID) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "user not found or inactive"}) + } + + if !user.Active { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "account is deactivated"}) + } + + // 4. Inject context for next handlers + c.Locals("user", user) // Crucial for feature handlers + c.Locals("portal_user_id", user.ID) + c.Locals("portal_client_id", user.ClientID) + c.Locals("portal_role", user.Role) + + return c.Next() + } +} + +// RequireRole is an additional middleware for granular permissions +func RequireRole(requiredRole domain.PortalRole) fiber.Handler { + return func(c *fiber.Ctx) error { + role, ok := c.Locals("portal_role").(domain.PortalRole) + if !ok { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "role not found in context"}) + } + + if role != requiredRole && role != domain.RoleOwner { // Owner can always do everything + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "insufficient permissions"}) + } + + return c.Next() + } +} diff --git a/src/clients_portal/auth/repository/gorm_repo.go b/src/clients_portal/auth/repository/gorm_repo.go new file mode 100644 index 0000000..fc2e9d2 --- /dev/null +++ b/src/clients_portal/auth/repository/gorm_repo.go @@ -0,0 +1,86 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + "gorm.io/gorm" +) + +type GormAuthRepository struct { + db *gorm.DB +} + +func NewGormAuthRepository(db *gorm.DB) *GormAuthRepository { + return &GormAuthRepository{db: db} +} + +// AutoMigrate ensures the table exists +func (r *GormAuthRepository) AutoMigrate() error { + return r.db.AutoMigrate(&domain.PortalUser{}) +} + +func (r *GormAuthRepository) Create(ctx context.Context, user *domain.PortalUser) error { + return r.db.WithContext(ctx).Create(user).Error +} + +func (r *GormAuthRepository) GetByUsername(ctx context.Context, username string) (*domain.PortalUser, error) { + var user domain.PortalUser + err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return &user, err +} + +func (r *GormAuthRepository) GetByPhone(ctx context.Context, phone string) (*domain.PortalUser, error) { + var user domain.PortalUser + err := r.db.WithContext(ctx).Where("phone = ?", phone).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return &user, err +} + +func (r *GormAuthRepository) GetByID(ctx context.Context, id string) (*domain.PortalUser, error) { + var user domain.PortalUser + err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return &user, err +} + +func (r *GormAuthRepository) UpdateLastLogin(ctx context.Context, id string) error { + now := time.Now() + // Use Updates for efficiency + return r.db.WithContext(ctx).Model(&domain.PortalUser{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "last_login_at": now, + "updated_at": now, + }).Error +} + +func (r *GormAuthRepository) Update(ctx context.Context, user *domain.PortalUser) error { + return r.db.WithContext(ctx).Save(user).Error +} + +func (r *GormAuthRepository) ListByClient(ctx context.Context, clientID string) ([]*domain.PortalUser, error) { + var users []*domain.PortalUser + err := r.db.WithContext(ctx). + Where("client_id = ?", clientID). + Order("created_at DESC"). + Find(&users).Error + return users, err +} + +func (r *GormAuthRepository) GetAll(ctx context.Context, dest *[]*domain.PortalUser) error { + return r.db.WithContext(ctx).Find(dest).Error +} + +func (r *GormAuthRepository) GetByClientIDs(ctx context.Context, clientIDs []string, dest *[]*domain.PortalUser) error { + return r.db.WithContext(ctx).Where("client_id IN ?", clientIDs).Find(dest).Error +} diff --git a/src/clients_portal/features/infrastructure/handler.go b/src/clients_portal/features/infrastructure/handler.go new file mode 100644 index 0000000..47bcbdb --- /dev/null +++ b/src/clients_portal/features/infrastructure/handler.go @@ -0,0 +1,1158 @@ +package infrastructure + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/sirupsen/logrus" + + "github.com/AzielCF/az-wap/botengine/domain/bot" + clientsApp "github.com/AzielCF/az-wap/clients/application" + clientsDomain "github.com/AzielCF/az-wap/clients/domain" + portalDomain "github.com/AzielCF/az-wap/clients_portal/auth/domain" + domainNewsletter "github.com/AzielCF/az-wap/domains/newsletter" + "github.com/AzielCF/az-wap/pkg/utils" + "github.com/AzielCF/az-wap/workspace" + "github.com/AzielCF/az-wap/workspace/domain/channel" + "github.com/AzielCF/az-wap/workspace/domain/common" + wsDomain "github.com/AzielCF/az-wap/workspace/domain/workspace" + wsRepo "github.com/AzielCF/az-wap/workspace/repository" + wsUsecase "github.com/AzielCF/az-wap/workspace/usecase" + "github.com/gofiber/fiber/v2" +) + +type FeaturesHandler struct { + subService *clientsApp.SubscriptionService + clientService *clientsApp.ClientService + newsletter domainNewsletter.INewsletterUsecase + wsRepo wsRepo.IWorkspaceRepository + botUsecase bot.IBotUsecase + wsUc *wsUsecase.WorkspaceUsecase + wm *workspace.Manager +} + +func NewFeaturesHandler( + subService *clientsApp.SubscriptionService, + clientService *clientsApp.ClientService, + newsletter domainNewsletter.INewsletterUsecase, + wsRepo wsRepo.IWorkspaceRepository, + botUsecase bot.IBotUsecase, + wsUc *wsUsecase.WorkspaceUsecase, + wm *workspace.Manager, +) *FeaturesHandler { + return &FeaturesHandler{ + subService: subService, + clientService: clientService, + newsletter: newsletter, + wsRepo: wsRepo, + botUsecase: botUsecase, + wsUc: wsUc, + wm: wm, + } +} + +func (h *FeaturesHandler) EnforceWorkspaceFeature(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + client, err := h.clientService.GetByID(c.Context(), user.ClientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "client not found"}) + } + + if !client.HasTag(clientsDomain.TagEnableWorkspaces) && !client.IsVIP() { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Workspaces management not allowed for this account"}) + } + + return c.Next() +} + +type ReminderView struct { + ID string `json:"id"` + Text string `json:"text"` + ScheduledAt time.Time `json:"scheduled_at"` + Status string `json:"status"` + RecurrenceDays string `json:"recurrence_days,omitempty"` + ChannelType string `json:"channel_type"` + BotName string `json:"bot_name"` +} + +func (h *FeaturesHandler) ListReminders(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + ctxStd := context.Background() + + subs, err := h.subService.ListByClient(ctxStd, user.ClientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch subscriptions"}) + } + + var allReminders []ReminderView = make([]ReminderView, 0) + + for _, sub := range subs { + if !sub.IsActive() { + continue + } + + ch, err := h.wsRepo.GetChannel(ctxStd, sub.ChannelID) + botName := "Asistente" + channelType := "desconocido" + if err == nil { + channelType = string(ch.Type) + if ch.Config.BotID != "" { + if b, errBot := h.botUsecase.GetByID(ctxStd, ch.Config.BotID); errBot == nil { + botName = b.Name + } else { + botName = ch.Name + } + } else { + botName = ch.Name + } + } + + posts, err := h.newsletter.ListScheduled(ctxStd, sub.ChannelID) + if err != nil { + continue + } + + for _, p := range posts { + allReminders = append(allReminders, ReminderView{ + ID: p.ID, + Text: p.Text, + ScheduledAt: p.ScheduledAt, + Status: string(p.Status), + RecurrenceDays: p.RecurrenceDays, + ChannelType: channelType, + BotName: botName, + }) + } + } + + sort.Slice(allReminders, func(i, j int) bool { + return allReminders[i].ScheduledAt.Before(allReminders[j].ScheduledAt) + }) + + return c.JSON(allReminders) +} + +type SubscriptionInfo struct { + ChannelType string `json:"channel_type"` + BotName string `json:"bot_name"` + BotDescription string `json:"bot_description,omitempty"` + Phone string `json:"phone,omitempty"` +} + +func (h *FeaturesHandler) GetGeneralInfo(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + ctxStd := context.Background() + + subs, err := h.subService.ListByClient(ctxStd, user.ClientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch subscriptions"}) + } + + var activeSubs []SubscriptionInfo = make([]SubscriptionInfo, 0) + for _, sub := range subs { + if !sub.IsActive() { + continue + } + + ch, err := h.wsRepo.GetChannel(ctxStd, sub.ChannelID) + if err == nil { + botID := ch.Config.BotID + // Priority: Subscription override > Channel config + if sub.CustomBotID != "" { + botID = sub.CustomBotID + } + + botName := ch.Name + botDesc := "" + if botID != "" { + if b, errBot := h.botUsecase.GetByID(ctxStd, botID); errBot == nil { + botName = b.Name + botDesc = b.Description + } + } + + phone := "" + if ch.Type == "whatsapp" { + // We prioritize ExternalRef if it looks like a phone, otherwise we could look in Settings + if ch.ExternalRef != "" && !strings.Contains(ch.ExternalRef, "-") && len(ch.ExternalRef) <= 15 { + phone = ch.ExternalRef + } else { + // Check if phone exists in settings + if p, ok := ch.Config.Settings["phone"].(string); ok { + phone = p + } + } + } + + activeSubs = append(activeSubs, SubscriptionInfo{ + ChannelType: string(ch.Type), + BotName: botName, + BotDescription: botDesc, + Phone: phone, + }) + } + } + + return c.JSON(fiber.Map{ + "subscriptions": activeSubs, + }) +} + +// GetOwnedChannels returns the channels owned by this client regardless of subscription +func (h *FeaturesHandler) GetOwnedChannels(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + ctxStd := context.Background() + + channels, err := h.wsRepo.ListChannelsByOwnerID(ctxStd, user.ClientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch owned channels"}) + } + + type SafeChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + BotName string `json:"bot_name,omitempty"` + BotDescription string `json:"bot_description,omitempty"` + AccumulatedCost float64 `json:"accumulated_cost,omitempty"` + Phone string `json:"phone,omitempty"` + AccessMode string `json:"access_mode,omitempty"` + } + + // 1. Get all active subscriptions for this client to find bot overrides + subs, err := h.subService.ListByClient(ctxStd, user.ClientID) + subBotMap := make(map[string]string) // ChannelID -> BotID + if err == nil { + for _, s := range subs { + if s.IsActive() && s.CustomBotID != "" { + subBotMap[s.ChannelID] = s.CustomBotID + } + } + } + + var safeChannels []SafeChannel + for _, ch := range channels { + sc := SafeChannel{ + ID: ch.ID, + Name: ch.Name, + Type: string(ch.Type), + Status: string(ch.Status), + Enabled: ch.Enabled, + AccessMode: string(ch.Config.AccessMode), + } + + // 2. Determine Bot ID (Subscription override > Channel config) + botID := ch.Config.BotID + if sBotID, ok := subBotMap[ch.ID]; ok { + botID = sBotID + } + + if botID != "" { + if b, errBot := h.botUsecase.GetByID(ctxStd, botID); errBot == nil { + sc.BotName = b.Name + sc.BotDescription = b.Description + } + } + + phone := "" + if string(ch.Type) == "whatsapp" { + ext := ch.ExternalRef + if strings.Contains(ext, ":") { // Handle multi-device suffix + ext = strings.Split(ext, ":")[0] + } + if strings.Contains(ext, "@") { + ext = strings.Split(ext, "@")[0] + } + + if ext != "" && !strings.Contains(ext, "-") && len(ext) > 5 && len(ext) <= 20 { + phone = ext + } else { + if p, ok := ch.Config.Settings["phone"].(string); ok { + phone = p + } + } + } + sc.Phone = phone + + client, err := h.clientService.GetByID(ctxStd, user.ClientID) + if err == nil && client.IsTester { + sc.AccumulatedCost = ch.AccumulatedCost + } + + safeChannels = append(safeChannels, sc) + } + + return c.JSON(safeChannels) +} + +func (h *FeaturesHandler) GetAuthorizedAgents(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + client, err := h.clientService.GetByID(c.Context(), user.ClientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "client not found"}) + } + + var agents []fiber.Map + for _, botID := range client.AllowedBots { + // Use botUsecase to get the human readable bot name, fallback to ID + bot, err := h.botUsecase.GetByID(c.Context(), botID) + name := botID + desc := "" + capabilities := fiber.Map{ + "audio": false, + "image": false, + "video": false, + "document": false, + } + if err == nil && bot.ID != "" { + name = bot.Name + desc = bot.Description + capabilities["audio"] = bot.AudioEnabled + capabilities["image"] = bot.ImageEnabled + capabilities["video"] = bot.VideoEnabled + capabilities["document"] = bot.DocumentEnabled + } + agents = append(agents, fiber.Map{ + "id": botID, + "name": name, + "description": desc, + "capabilities": capabilities, + }) + } + + if agents == nil { + agents = []fiber.Map{} + } + + return c.JSON(agents) +} + +// Access Rules for portal + +func (h *FeaturesHandler) GetChannelAccessRules(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + // Verify possession + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + rules, err := h.wsUc.GetAccessRules(c.Context(), cid) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + type SafeRule struct { + ID string `json:"id"` + Identity string `json:"identity"` + Action string `json:"action"` + Label string `json:"label"` + } + + var safeRules []SafeRule + for _, r := range rules { + identity := r.Identity + if idx := strings.Index(identity, "@"); idx != -1 { + identity = identity[:idx] + } + safeRules = append(safeRules, SafeRule{ + ID: r.ID, + Identity: identity, + Action: string(r.Action), + Label: r.Label, + }) + } + + return c.JSON(safeRules) +} + +func (h *FeaturesHandler) UpdateChannelName(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + var req struct { + Name string `json:"name"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + if req.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + } + + ch.Name = req.Name + if err := h.wsRepo.UpdateChannel(c.Context(), ch); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "could not update channel"}) + } + + return c.JSON(fiber.Map{"status": "updated", "name": ch.Name}) +} + +func (h *FeaturesHandler) AddChannelAccessRule(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + var req struct { + Identity string `json:"identity"` + Action common.AccessAction `json:"action"` + Label string `json:"label"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + if req.Identity == "" || req.Action == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "identity and action are required"}) + } + + adapter, ok := h.wm.GetAdapter(cid) + if ok { + resolved, err := adapter.ResolveIdentity(c.Context(), req.Identity) + if err == nil && resolved != "" { + req.Identity = resolved + } + } else if string(ch.Type) == "whatsapp" { + if !strings.Contains(req.Identity, "@") { + phoneNumber := strings.TrimLeft(req.Identity, "+") + phoneNumber = strings.ReplaceAll(phoneNumber, " ", "") + phoneNumber = strings.ReplaceAll(phoneNumber, "-", "") + req.Identity = phoneNumber + "@s.whatsapp.net" + } + } + + err = h.wsUc.AddAccessRule(c.Context(), cid, req.Identity, req.Action, req.Label) + if err != nil { + if err == common.ErrDuplicateRule { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "duplicate_entry"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"status": "created", "resolved_identity": req.Identity}) +} + +func (h *FeaturesHandler) DeleteChannelAccessRule(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + rid := c.Params("rid") + + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + if err := h.wsUc.DeleteAccessRule(c.Context(), rid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.SendStatus(fiber.StatusNoContent) +} + +func (h *FeaturesHandler) ResolveIdentity(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + identity := c.Query("identity") + if identity == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "identity is required"}) + } + + adapter, ok := h.wm.GetAdapter(cid) + if !ok { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "channel adapter not running"}) + } + + resolved, err := adapter.ResolveIdentity(c.Context(), identity) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "could_not_resolve"}) + } + + phone := "" + if !strings.Contains(identity, "@") { + phone = identity + } + + name := "" + if contact, err := adapter.GetContact(c.Context(), resolved); err == nil { + name = contact.Name + } + return c.JSON(fiber.Map{ + "resolved_identity": resolved, + "phone": phone, + "name": name, + "status": "verified", + }) +} + +// --- Client Workspace Handlers --- + +func (h *FeaturesHandler) ListWorkspaces(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + workspaces, err := h.wsUc.ListClientWorkspaces(c.Context(), user.ClientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + if workspaces == nil { + workspaces = []wsDomain.ClientWorkspace{} + } + + return c.JSON(workspaces) +} + +func (h *FeaturesHandler) CreateWorkspace(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + ws, err := h.wsUc.CreateClientWorkspace(c.Context(), user.ClientID, req.Name, req.Description) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(ws) +} + +func (h *FeaturesHandler) UpdateWorkspace(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + // Verify ownership + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil || ws.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + ws.Name = req.Name + ws.Description = req.Description + ws.UpdatedAt = time.Now().UTC() + + if err := h.wsRepo.UpdateClientWorkspace(c.Context(), ws); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(ws) +} + +func (h *FeaturesHandler) DeleteWorkspace(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + // Verify ownership + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil || ws.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + if err := h.wsUc.DeleteClientWorkspace(c.Context(), wid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// --- Workspace Channels --- + +func (h *FeaturesHandler) ListWorkspaceChannels(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + // Verify ownership + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil || ws.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + channels, err := h.wsRepo.ListChannelsInClientWorkspace(c.Context(), wid) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + if channels == nil { + channels = []channel.Channel{} + } + + return c.JSON(channels) +} + +func (h *FeaturesHandler) LinkChannel(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + cid := c.Params("cid") + + // Verify ownership of both + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil || ws.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden (workspace)"}) + } + + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden (channel)"}) + } + + if err := h.wsUc.LinkChannelToClientWorkspace(c.Context(), wid, cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +func (h *FeaturesHandler) UnlinkChannel(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + cid := c.Params("cid") + + // Verify ownership + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil || ws.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + if err := h.wsUc.UnlinkChannelFromClientWorkspace(c.Context(), wid, cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// --- Workspace Guests --- + +func (h *FeaturesHandler) ListGuests(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + // Verify ownership + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil || ws.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + guests, err := h.wsRepo.ListGuestsInClientWorkspace(c.Context(), wid) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + if guests == nil { + guests = []wsDomain.ClientWorkspaceGuest{} + } + + for i := range guests { + if wa, ok := guests[i].PlatformIdentifiers["whatsapp"]; ok { + guests[i].PlatformIdentifiers["whatsapp"] = utils.NormalizeWhatsAppIdentity(wa) + guests[i].PlatformIdentifiers["whatsapp_number"] = utils.NormalizeWhatsAppIdentity(wa) + } + } + + return c.JSON(guests) +} + +func (h *FeaturesHandler) CreateGuest(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + // Verify ownership + ws, err := h.wsRepo.GetClientWorkspace(c.Context(), wid) + if err != nil || ws.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + var guest wsDomain.ClientWorkspaceGuest + if err := c.BodyParser(&guest); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + guest.OwnerID = user.ClientID + guest.ClientWorkspaceID = wid + + // Validate against owner client + client, err := h.clientService.GetByID(c.Context(), user.ClientID) + if err == nil && client != nil { + guestPhone := guest.PlatformIdentifiers["whatsapp"] + if utils.MatchWhatsAppIdentities(guestPhone, client.Phone) || utils.MatchWhatsAppIdentities(guestPhone, client.PlatformID) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "the owner client cannot be added as a guest in their own workspace"}) + } + } + + newGuest, err := h.wsUc.CreateGuest(c.Context(), guest) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(newGuest) +} + +func (h *FeaturesHandler) UpdateGuest(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + wid := c.Params("wid") + gid := c.Params("gid") + + // Verify ownership of guest + guest, err := h.wsRepo.GetGuest(c.Context(), gid) + if err != nil || guest.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + var req wsDomain.ClientWorkspaceGuest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"}) + } + + req.ID = gid + req.OwnerID = user.ClientID + req.ClientWorkspaceID = wid // Ensure it stays in this workspace + + // Validate against owner client + client, err := h.clientService.GetByID(c.Context(), user.ClientID) + if err == nil && client != nil { + guestPhone := req.PlatformIdentifiers["whatsapp"] + if utils.MatchWhatsAppIdentities(guestPhone, client.Phone) || utils.MatchWhatsAppIdentities(guestPhone, client.PlatformID) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "the owner client cannot be added as a guest in their own workspace"}) + } + } + + if err := h.wsUc.UpdateGuest(c.Context(), req); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(req) +} + +func (h *FeaturesHandler) DeleteGuest(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + gid := c.Params("gid") + + // Verify ownership + guest, err := h.wsRepo.GetGuest(c.Context(), gid) + if err != nil || guest.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + if err := h.wsUc.DeleteGuest(c.Context(), gid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// --- WhatsApp Specific Channel Handlers --- + +func (h *FeaturesHandler) GetWhatsAppStatus(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + // Verify possession + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + adapter, ok := h.wm.GetAdapter(cid) + if !ok { + // If adapter not running, check DB status for a "dry" status report + if err == nil { + return c.JSON(fiber.Map{ + "is_connected": false, + "is_logged_in": ch.Status == channel.ChannelStatusConnected, + "status": ch.Status, + "channel_id": cid, + "is_paused": !ch.Enabled, + }) + } + + return c.JSON(fiber.Map{ + "is_connected": false, + "is_logged_in": false, + "status": "disconnected", + }) + } + + if adapter.Type() != channel.ChannelTypeWhatsApp { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "not a whatsapp channel"}) + } + + status := adapter.Status() + isConnected := status == channel.ChannelStatusConnected + isHibernating := status == channel.ChannelStatusHibernating + isLoggedIn := adapter.IsLoggedIn() + + phone := "" + ext := ch.ExternalRef + if strings.Contains(ext, ":") { + ext = strings.Split(ext, ":")[0] + } + if strings.Contains(ext, "@") { + ext = strings.Split(ext, "@")[0] + } + + if ext != "" && !strings.Contains(ext, "-") && len(ext) > 5 && len(ext) <= 20 { + phone = ext + } else { + if p, ok := ch.Config.Settings["phone"].(string); ok { + phone = p + } + } + + if phone == "" && isConnected { + if me, err := adapter.GetMe(); err == nil && me.JID != "" { + jid := me.JID + if strings.Contains(jid, ":") { + jid = strings.Split(jid, ":")[0] + } + phone = strings.Split(jid, "@")[0] + ch.ExternalRef = phone + _ = h.wsRepo.UpdateChannel(c.Context(), ch) + } + } + + // If manual sync requested and we are hibernating, try to resume + if c.Query("resume") == "true" && isHibernating { + logrus.Infof("[REST Portal] Force Status Sync: Resuming hibernated channel %s", cid) + _ = adapter.Resume(c.Context()) + // Refresh status after resume + status = adapter.Status() + isConnected = status == channel.ChannelStatusConnected + isHibernating = status == channel.ChannelStatusHibernating + } + + // Sync local DB if there's a discrepancy (e.g. manual DB deletion or hibernation) + if ch.Status != status { + ch.Status = status + _ = h.wsRepo.UpdateChannel(c.Context(), ch) + } + + return c.JSON(fiber.Map{ + "is_connected": isConnected, + "is_logged_in": isLoggedIn, + "is_hibernating": isHibernating, + "is_paused": !ch.Enabled, + "status": status, + "channel_id": cid, + "phone": phone, + }) +} + +func (h *FeaturesHandler) WhatsAppLogin(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + // Verify possession + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + adapter, ok := h.wm.GetAdapter(cid) + if !ok { + // Try auto-start if not running + if err := h.wm.StartChannel(c.Context(), cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("channel start failed: %v", err)}) + } + // Try get again + adapter, ok = h.wm.GetAdapter(cid) + if !ok { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "adapter failed to initialize"}) + } + } + + if err := adapter.Login(c.Context()); err != nil { + // If already connected but we are here, something is out of sync. + // Forced disconnect and retry once. + if strings.Contains(err.Error(), "already connected") { + logrus.Warnf("[REST Portal] Adapter %s reports already connected, forcing reset...", cid) + _ = adapter.Logout(c.Context()) + if err := adapter.Login(c.Context()); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to reset connection: " + err.Error()}) + } + } else { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + } + + qrChan, err := adapter.GetQRChannel(c.Context()) + if err != nil { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) + } + + select { + case code, open := <-qrChan: + if !open { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "qr channel closed"}) + } + return c.JSON(utils.ResponseData{ + Status: 200, + Code: "SUCCESS", + Message: "QR Generated", + Results: fiber.Map{ + "qr_link": code, + "qr_duration": 60, // approximate + }, + }) + case <-c.Context().Done(): + return c.Status(fiber.StatusRequestTimeout).JSON(fiber.Map{"error": "timeout waiting for qr"}) + } +} + +func (h *FeaturesHandler) WhatsAppLoginWithCode(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + type req struct { + PhoneNumber string `json:"phone_number"` + } + var r req + if err := c.BodyParser(&r); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + + if r.PhoneNumber == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "phone_number is required"}) + } + + phone := strings.ReplaceAll(r.PhoneNumber, "+", "") + phone = strings.ReplaceAll(phone, " ", "") + phone = strings.ReplaceAll(phone, "-", "") + phone = strings.TrimSpace(phone) + + adapter, ok := h.wm.GetAdapter(cid) + if !ok { + // Try auto-start if not running + if err := h.wm.StartChannel(c.Context(), cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("channel start failed: %v", err)}) + } + adapter, ok = h.wm.GetAdapter(cid) + if !ok { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "channel adapter not available"}) + } + } + + code, err := adapter.LoginWithCode(c.Context(), phone) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "code": code, + "status": "pairing_code_generated", + }) +} + +func (h *FeaturesHandler) WhatsAppLogout(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + logrus.Infof("[REST Portal] Logging out channel %s", cid) + + adapter, ok := h.wm.GetAdapter(cid) + if ok { + if err := adapter.Logout(c.Context()); err != nil { + logrus.Warnf("[REST Portal] Logout failed for %s, but syncing state anyway: %v", cid, err) + } + if err := adapter.Cleanup(c.Context()); err != nil { + logrus.Warnf("[REST Portal] Cleanup failed for %s: %v", cid, err) + } + h.wm.UnregisterAdapter(cid) + } else { + logrus.Infof("[REST Portal] Channel %s not running, attempting to start for proper logout", cid) + + if err := h.wm.StartChannel(c.Context(), cid); err == nil { + if adapter, ok := h.wm.GetAdapter(cid); ok { + if err := adapter.Logout(c.Context()); err != nil { + logrus.Warnf("[REST Portal] Logout failed for %s: %v", cid, err) + } + if err := adapter.Cleanup(c.Context()); err != nil { + logrus.Warnf("[REST Portal] Cleanup failed for %s: %v", cid, err) + } + h.wm.UnregisterAdapter(cid) + } + } else { + logrus.Warnf("[REST Portal] Could not start channel %s for logout, cleaning up files only: %v", cid, err) + waDBPath := fmt.Sprintf("storages/whatsapp-%s.db", cid) + waFiles := []string{waDBPath, waDBPath + "-shm", waDBPath + "-wal"} + for _, f := range waFiles { + if err := os.Remove(f); err != nil && !os.IsNotExist(err) { + logrus.Warnf("[REST Portal] Failed to remove %s: %v", f, err) + } + } + } + } + + // Update DB status + ch.Status = channel.ChannelStatusDisconnected + _ = h.wsRepo.UpdateChannel(c.Context(), ch) + + return c.JSON(fiber.Map{"status": "logged_out"}) +} + +func (h *FeaturesHandler) EnableChannel(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + if err := h.wsUc.EnableChannel(c.Context(), cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if err := h.wm.StartChannel(c.Context(), cid); err != nil { + logrus.WithError(err).Warn("[REST Portal] Failed to start channel in manager after enabling") + } + return c.JSON(fiber.Map{"status": "enabled"}) +} + +func (h *FeaturesHandler) DisableChannel(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*portalDomain.PortalUser) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + + cid := c.Params("cid") + ch, err := h.wsRepo.GetChannel(c.Context(), cid) + if err != nil || ch.OwnerID != user.ClientID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + } + + if err := h.wsUc.DisableChannel(c.Context(), cid); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + h.wm.UnregisterAdapter(cid) + return c.JSON(fiber.Map{"status": "disabled"}) +} diff --git a/src/clients_portal/http/router.go b/src/clients_portal/http/router.go new file mode 100644 index 0000000..8ea87b9 --- /dev/null +++ b/src/clients_portal/http/router.go @@ -0,0 +1,84 @@ +package http + +import ( + authInfra "github.com/AzielCF/az-wap/clients_portal/auth/infrastructure" + featuresInfra "github.com/AzielCF/az-wap/clients_portal/features/infrastructure" + coreConfig "github.com/AzielCF/az-wap/core/config" + "github.com/gofiber/fiber/v2" +) + +// RegisterPortalRoutes centralizes all Client Portal route definitions. +// It manages: +// 1. Internal/Admin routes (via baseAPI, protected by system BasicAuth) +// 2. Public Portal routes (via app, separated from system auth) +// 3. Protected Portal routes (via middleware) +func RegisterPortalRoutes( + app fiber.Router, + baseAPI fiber.Router, + authHandler *authInfra.AuthHandler, + featuresHandler *featuresInfra.FeaturesHandler, + portalAuthMiddleware fiber.Handler, +) { + // 1. Internal Admin Routes (Attached to system API group) + // Used by admin dashboard relative to /api/internal + baseAPI.Post("/internal/clients/:id/portal-account", authHandler.CreatePortalAccount) + baseAPI.Get("/internal/portal-accounts", authHandler.ListAccountsState) + baseAPI.Post("/internal/magic-link/generate", authHandler.GenerateMagicLink) + + // 2. Public Portal Routes (/api/portal) + // Created directly on app router to manage middleware stack independently + portalGroup := app.Group(coreConfig.Global.App.BasePath + "/api/portal") + + portalGroup.Post("/login", authHandler.Login) + portalGroup.Post("/magic-link/redeem", authHandler.RedeemMagicLink) + + // 3. Protected Portal Routes (Require valid Portal Token) + protected := portalGroup.Group("", portalAuthMiddleware) + + // Auth Module Routes + protected.Get("/me", authHandler.Me) + protected.Put("/profile", authHandler.UpdateProfile) + + // Features Module Routes + protected.Get("/reminders", featuresHandler.ListReminders) + protected.Get("/info", featuresHandler.GetGeneralInfo) + protected.Get("/owned-channels", featuresHandler.GetOwnedChannels) + protected.Get("/authorized-agents", featuresHandler.GetAuthorizedAgents) + + // Access Rules for Channels + protected.Get("/owned-channels/:cid/access-rules", featuresHandler.GetChannelAccessRules) + protected.Post("/owned-channels/:cid/access-rules", featuresHandler.AddChannelAccessRule) + protected.Delete("/owned-channels/:cid/access-rules/:rid", featuresHandler.DeleteChannelAccessRule) + protected.Get("/owned-channels/:cid/resolve-identity", featuresHandler.ResolveIdentity) + + // Edit Channel + protected.Put("/owned-channels/:cid/name", featuresHandler.UpdateChannelName) + + // WhatsApp Channel Controls + protected.Get("/owned-channels/:cid/whatsapp/status", featuresHandler.GetWhatsAppStatus) + protected.Get("/owned-channels/:cid/whatsapp/login", featuresHandler.WhatsAppLogin) + protected.Post("/owned-channels/:cid/whatsapp/login-code", featuresHandler.WhatsAppLoginWithCode) + protected.Get("/owned-channels/:cid/whatsapp/logout", featuresHandler.WhatsAppLogout) + + protected.Post("/owned-channels/:cid/enable", featuresHandler.EnableChannel) + protected.Post("/owned-channels/:cid/disable", featuresHandler.DisableChannel) + + // Workspace Management (ABAC restricted) + protected.Get("/workspaces", featuresHandler.ListWorkspaces) + protected.Get("/workspaces/:wid/channels", featuresHandler.ListWorkspaceChannels) + protected.Get("/workspaces/:wid/guests", featuresHandler.ListGuests) + + wsGroup := protected.Group("/workspaces", featuresHandler.EnforceWorkspaceFeature) + wsGroup.Post("", featuresHandler.CreateWorkspace) + wsGroup.Put("/:wid", featuresHandler.UpdateWorkspace) + wsGroup.Delete("/:wid", featuresHandler.DeleteWorkspace) + + // Workspace Channels (Write Actions) + wsGroup.Post("/:wid/channels/:cid", featuresHandler.LinkChannel) + wsGroup.Delete("/:wid/channels/:cid", featuresHandler.UnlinkChannel) + + // Workspace Guests (Write Actions) + wsGroup.Post("/:wid/guests", featuresHandler.CreateGuest) + wsGroup.Put("/:wid/guests/:gid", featuresHandler.UpdateGuest) + wsGroup.Delete("/:wid/guests/:gid", featuresHandler.DeleteGuest) +} diff --git a/src/clients_portal/shared/security/jwt.go b/src/clients_portal/shared/security/jwt.go new file mode 100644 index 0000000..ec67f80 --- /dev/null +++ b/src/clients_portal/shared/security/jwt.go @@ -0,0 +1,100 @@ +package security + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "time" + + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + coreconfig "github.com/AzielCF/az-wap/core/config" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +func getSecretKey() []byte { + return []byte(coreconfig.Global.Security.PortalJWTSecret) +} + +type PortalClaims struct { + UserID string `json:"uid"` + ClientID string `json:"cid"` + Role domain.PortalRole `json:"role"` + jwt.RegisteredClaims +} + +// GenerateToken creates a new JWT for the portal user +func GenerateToken(userID, clientID string, role domain.PortalRole) (string, error) { + expirationTime := time.Now().Add(30 * time.Minute) // Short session for security + + claims := &PortalClaims{ + UserID: userID, + ClientID: clientID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "az-wap-portal", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(getSecretKey()) +} + +// GenerateMagicToken creates a short-lived (15m) token for passwordless login +func GenerateMagicToken(userID, clientID string) (string, error) { + expirationTime := time.Now().Add(15 * time.Minute) + + claims := &PortalClaims{ + UserID: userID, + ClientID: clientID, + Role: domain.RoleMember, // Magic link users have minimal role initially + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(getSecretKey()) +} + +// GenerateOpaqueToken creates a short, random opaque string for magic links +func GenerateOpaqueToken() (string, error) { + b := make([]byte, 16) // 32 chars in hex, much shorter than JWT + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// ValidateToken parses and validates a JWT token +func ValidateToken(tokenString string) (*PortalClaims, error) { + // 2. Validate token (With 1m leeway for clock skew) + token, err := jwt.ParseWithClaims(tokenString, &PortalClaims{}, func(token *jwt.Token) (interface{}, error) { + return getSecretKey(), nil + }, jwt.WithLeeway(time.Minute)) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*PortalClaims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +// HashPassword encrypts the password using bcrypt +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPasswordHash verifies if the password matches the hash +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/src/cmd/rest.go b/src/cmd/rest.go index 21d3aaf..24b2540 100644 --- a/src/cmd/rest.go +++ b/src/cmd/rest.go @@ -8,7 +8,12 @@ import ( "syscall" "time" + portalAuthInfra "github.com/AzielCF/az-wap/clients_portal/auth/infrastructure" + portalAuthRepo "github.com/AzielCF/az-wap/clients_portal/auth/repository" + portalFeatures "github.com/AzielCF/az-wap/clients_portal/features/infrastructure" + clientPortalHTTP "github.com/AzielCF/az-wap/clients_portal/http" coreconfig "github.com/AzielCF/az-wap/core/config" + coreDB "github.com/AzielCF/az-wap/core/database" "github.com/AzielCF/az-wap/ui/rest" "github.com/AzielCF/az-wap/ui/rest/middleware" "github.com/AzielCF/az-wap/ui/websocket" @@ -33,9 +38,15 @@ var restCmd = &cobra.Command{ } func init() { + restCmd.Flags().String("basic-auth", "", "Basic auth for API (format: user:pass,user2:pass2)") rootCmd.AddCommand(restCmd) } -func restServer(_ *cobra.Command, _ []string) { +func restServer(cmd *cobra.Command, _ []string) { + // Override basic auth if flag is provided + if baFlag, _ := cmd.Flags().GetString("basic-auth"); baFlag != "" { + coreconfig.Global.App.BasicAuth = strings.Split(baFlag, ",") + } + fiberConfig := fiber.Config{ EnableTrustedProxyCheck: true, BodyLimit: int(coreconfig.Global.Whatsapp.MaxVideoSize), @@ -118,6 +129,10 @@ func restServer(_ *cobra.Command, _ []string) { if c.Method() == fiber.MethodOptions { return true } + // SKIP Basic Auth for Portal routes (they handle their own auth) + if strings.HasPrefix(c.Path(), coreconfig.Global.App.BasePath+"/api/portal") { + return true + } return false }, })) @@ -151,12 +166,20 @@ func restServer(_ *cobra.Command, _ []string) { rest.InitRestCache(apiGroup, cacheUsecase) rest.InitRestMCP(apiGroup, mcpUsecase) rest.InitRestHealth(apiGroup, healthUsecase) - - // Unified Monitoring System (Multi-server aware) + rest.InitRestWorkspace(apiGroup, wkUsecase, workspaceManager, appUsecase) rest.InitRestMonitoring(apiGroup, monitorStore, workspaceManager, contextCacheStore) - // Register Workspace Handlers - rest.InitRestWorkspace(apiGroup, wkUsecase, workspaceManager, appUsecase) + // --- CLIENTS PORTAL (ALREADY INITIALIZED IN ROOT) --- + // Register Portal Handlers + portalAuthHandler := portalAuthInfra.NewAuthHandler(portalAuthService) + portalFeaturesHandler := portalFeatures.NewFeaturesHandler(subService, clientService, newsletterUsecase, wkRepo, botUsecase, wkUsecase, workspaceManager) + // Re-instantiate middleware here or reuse if global, but for clarity using local repo instance as before + portalAuthMiddleware := portalAuthInfra.NewAuthMiddleware(portalAuthRepo.NewGormAuthRepository(coreDB.GlobalDB)) + + // Register ALL Portal Routes via Global Portal Router + clientPortalHTTP.RegisterPortalRoutes(app, apiGroup, portalAuthHandler, portalFeaturesHandler, portalAuthMiddleware) + + // Unified Monitoring System (Multi-server aware) // Register Client Handlers clientHandler.RegisterRoutes(apiGroup) diff --git a/src/cmd/root.go b/src/cmd/root.go index e00590a..e3a7213 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -27,6 +27,7 @@ import ( coreconfig "github.com/AzielCF/az-wap/core/config" coreDB "github.com/AzielCF/az-wap/core/database" + "github.com/AzielCF/az-wap/core/kvstore" coreSettings "github.com/AzielCF/az-wap/core/settings/application" "github.com/AzielCF/az-wap/botengine" @@ -73,7 +74,10 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "go.mau.fi/whatsmeow" - // "go.mau.fi/whatsmeow/store/sqlstore" + + // Portal Module + portalAuthApp "github.com/AzielCF/az-wap/clients_portal/auth/application" + portalAuthRepo "github.com/AzielCF/az-wap/clients_portal/auth/repository" ) var ( @@ -119,6 +123,9 @@ var ( // Core Services settingsSvc *coreSettings.SettingsService + + // Portal + portalAuthService *portalAuthApp.AuthService ) // rootCmd represents the base command when called without any subcommands @@ -282,6 +289,15 @@ func initApp() { logrus.Fatalf("failed to init subscription repo: %v", err) } + // 2.2 Clients Portal Module Initialization + kvstore.Init(vkClient) + + portalAuthRepoInst := portalAuthRepo.NewGormAuthRepository(gormDB) + if err := portalAuthRepoInst.AutoMigrate(); err != nil { + logrus.Fatalf("[PORTAL] Failed to migrate portal auth table: %v", err) + } + portalAuthService = portalAuthApp.NewAuthService(portalAuthRepoInst, clientRepo, wkRepo, kvstore.Global) + // Client Services clientService = clientsApp.NewClientService(clientRepo, subRepo) subService = clientsApp.NewSubscriptionService(subRepo, clientRepo) @@ -289,9 +305,6 @@ func initApp() { // Client Resolver (for runtime context resolution) clientResolver = clientsApp.NewClientResolver(clientRepo, subRepo, wkRepo) - // Client REST Handler - clientHandler = clientsRest.NewClientHandler(clientService, subService) - logrus.Info("[CLIENTS] Client module initialized successfully") // 3. Monitoring and Typing (Distributed if Valkey is available) @@ -324,6 +337,9 @@ func initApp() { messageUsecase = usecase.NewMessageService(workspaceManager) wkUsecase = workspaceUsecaseLayer.NewWorkspaceUsecase(wkRepo, workspaceManager) + // Client REST Handler (Admin) + clientHandler = clientsRest.NewClientHandler(clientService, subService, wkRepo, wkUsecase) + // 5. WhatsApp Adapter Factory workspaceManager.RegisterFactory(channel.ChannelTypeWhatsApp, func(conf channel.ChannelConfig) (channel.ChannelAdapter, error) { channelID, _ := conf.Settings["channel_id"].(string) @@ -424,10 +440,11 @@ func initApp() { botEngine.RegisterNativeTool(rTools.CountRemindersTool()) // Register Client Profile Tools (allow users to manage their personal info via AI) - cTools := onlyClients.NewClientTools(clientRepo) + cTools := onlyClients.NewClientTools(clientRepo, portalAuthService) botEngine.RegisterNativeTool(cTools.UpdateMyInfoTool()) botEngine.RegisterNativeTool(cTools.GetMyInfoTool()) botEngine.RegisterNativeTool(cTools.DeleteMyFieldTool()) + botEngine.RegisterNativeTool(cTools.GetPortalLinkTool()) // Register Currency Tools cxTools := onlyClients.NewExchangeRateTools() diff --git a/src/core/config/config.go b/src/core/config/config.go index c100302..0d424bd 100644 --- a/src/core/config/config.go +++ b/src/core/config/config.go @@ -35,6 +35,7 @@ type AppConfig struct { BaseUrl string CorsAllowedOrigins []string ServerID string + PortalURL string } type MCPConfig struct { @@ -93,7 +94,9 @@ type WorkerPoolConfig struct { } type SecurityConfig struct { - SecretKey string + SecretKey string + PortalJWTSecret string + PortalInternalKey string } type APIKeysConfig struct { @@ -147,6 +150,7 @@ func LoadConfig() (*Config, error) { BaseUrl: getEnv("APP_BASE_URL", "http://localhost:3000"), CorsAllowedOrigins: cors_origins, ServerID: getEnv("SERVER_ID", ""), + PortalURL: getEnv("PORTAL_URL", ""), } if v := os.Getenv("APP_TRUSTED_PROXIES"); v != "" { appCfg.TrustedProxies = strings.Split(v, ",") @@ -216,7 +220,11 @@ func LoadConfig() (*Config, error) { Whatsapp: waCfg, AI: aiCfg, WorkerPool: WorkerPoolConfig{Size: poolSize, QueueSize: getEnvInt("MESSAGE_WORKER_QUEUE_SIZE", 1000)}, - Security: SecurityConfig{SecretKey: getEnv("APP_SECRET_KEY", "changeme_please_change_me_in_prod_12345")}, + Security: SecurityConfig{ + SecretKey: getEnv("APP_SECRET_KEY", "changeme_please_change_me_in_prod_12345"), + PortalJWTSecret: getEnv("PORTAL_JWT_SECRET", getEnv("APP_SECRET_KEY", "changeme_portal_jwt")), + PortalInternalKey: getEnv("PORTAL_INTERNAL_KEY", "changeme_internal_key"), + }, APIKeys: APIKeysConfig{ Gemini: getEnv("GEMINI_API_KEY", ""), OpenAI: getEnv("OPENAI_API_KEY", ""), diff --git a/src/core/kvstore/kvstore.go b/src/core/kvstore/kvstore.go new file mode 100644 index 0000000..19f48da --- /dev/null +++ b/src/core/kvstore/kvstore.go @@ -0,0 +1,253 @@ +package kvstore + +import ( + "context" + "path/filepath" + "sync" + "time" + + "github.com/AzielCF/az-wap/infrastructure/valkey" + "github.com/sirupsen/logrus" +) + +// KVStore defines the standard interface for key-value storage in the system. +// It abstracts the implementation (Valkey vs Memory) while providing advanced features. +type KVStore interface { + // Basic Operations + Set(ctx context.Context, key string, value string, ttl time.Duration) error + Get(ctx context.Context, key string) (string, error) + Delete(ctx context.Context, key string) error + Exists(ctx context.Context, key string) (bool, error) + + // Locks (Atomic - useful for distributed tasks or avoiding race conditions) + Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) + Unlock(ctx context.Context, key string) error + + // Discovery + Keys(ctx context.Context, pattern string) ([]string, error) +} + +type cachedItem struct { + value string + expiresAt time.Time +} + +type smartStore struct { + vkClient *valkey.Client + memory sync.Map + locks sync.Map // For memory-based locking +} + +// NewSmartStore creates a store that automatically chooses between Valkey and Memory +func NewSmartStore(vkClient *valkey.Client) KVStore { + s := &smartStore{ + vkClient: vkClient, + } + + // Start background cleanup for memory-only mode + if vkClient == nil { + go s.memoryCleanupLoop() + } + + return s +} + +func (s *smartStore) Set(ctx context.Context, key string, value string, ttl time.Duration) error { + if s.vkClient != nil && s.vkClient.IsConnected() { + err := s.vkClient.Set(ctx, key, value, ttl) + if err != nil { + logrus.Errorf("[KVStore] Valkey Set error for key %s: %v", key, err) + return err + } + return nil + } + + // Fallback to memory + s.memory.Store(key, cachedItem{ + value: value, + expiresAt: time.Now().Add(ttl), + }) + return nil +} + +func (s *smartStore) Get(ctx context.Context, key string) (string, error) { + if s.vkClient != nil && s.vkClient.IsConnected() { + val, err := s.vkClient.Get(ctx, key) + if err != nil { + logrus.Errorf("[KVStore] Valkey Get error for key %s: %v", key, err) + return "", err + } + return val, nil + } + + // Fallback to memory + val, ok := s.memory.Load(key) + if !ok { + return "", nil + } + + item := val.(cachedItem) + if time.Now().After(item.expiresAt) { + s.memory.Delete(key) + return "", nil + } + + return item.value, nil +} + +func (s *smartStore) Exists(ctx context.Context, key string) (bool, error) { + if s.vkClient != nil && s.vkClient.IsConnected() { + res, err := s.vkClient.Inner().Do(ctx, s.vkClient.Inner().B().Exists().Key(key).Build()).AsInt64() + if err != nil { + logrus.Errorf("[KVStore] Valkey Exists error for key %s: %v", key, err) + return false, err + } + return res > 0, nil + } + + val, ok := s.memory.Load(key) + if !ok { + return false, nil + } + item := val.(cachedItem) + if time.Now().After(item.expiresAt) { + s.memory.Delete(key) + return false, nil + } + return true, nil +} + +func (s *smartStore) Delete(ctx context.Context, key string) error { + if s.vkClient != nil && s.vkClient.IsConnected() { + err := s.vkClient.Inner().Do(ctx, s.vkClient.Inner().B().Del().Key(key).Build()).Error() + if err != nil { + logrus.Errorf("[KVStore] Valkey Delete error for key %s: %v", key, err) + return err + } + return nil + } + + s.memory.Delete(key) + return nil +} + +func (s *smartStore) Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) { + lockKey := "lock:" + key + if s.vkClient != nil && s.vkClient.IsConnected() { + err := s.vkClient.Inner().Do(ctx, s.vkClient.Inner().B().Set().Key(lockKey).Value("1").Nx().Ex(ttl).Build()).Error() + if err != nil { + if valkey.IsNil(err) { + return false, nil + } + logrus.Errorf("[KVStore] Valkey Lock error for key %s: %v", key, err) + return false, err + } + return true, nil + } + + // Memory locking + now := time.Now() + val, loaded := s.locks.LoadOrStore(lockKey, now.Add(ttl)) + if loaded { + // Check if existing lock is expired + expiry := val.(time.Time) + if now.After(expiry) { + s.locks.Store(lockKey, now.Add(ttl)) + return true, nil + } + return false, nil + } + return true, nil +} + +func (s *smartStore) Unlock(ctx context.Context, key string) error { + lockKey := "lock:" + key + if s.vkClient != nil && s.vkClient.IsConnected() { + err := s.vkClient.Inner().Do(ctx, s.vkClient.Inner().B().Del().Key(lockKey).Build()).Error() + if err != nil { + logrus.Errorf("[KVStore] Valkey Unlock error for key %s: %v", key, err) + return err + } + return nil + } + + s.locks.Delete(lockKey) + return nil +} + +func (s *smartStore) Keys(ctx context.Context, pattern string) ([]string, error) { + if s.vkClient != nil && s.vkClient.IsConnected() { + var allKeys []string + var cursor uint64 + for { + res, err := s.vkClient.Inner().Do(ctx, s.vkClient.Inner().B().Scan().Cursor(cursor).Match(pattern).Count(100).Build()).AsScanEntry() + if err != nil { + logrus.Errorf("[KVStore] Valkey Scan error with pattern %s: %v", pattern, err) + return nil, err + } + allKeys = append(allKeys, res.Elements...) + cursor = res.Cursor + if cursor == 0 { + break + } + } + return allKeys, nil + } + + // Memory keys search (simple glob matching) + var keys []string + s.memory.Range(func(key, value any) bool { + k := key.(string) + item := value.(cachedItem) + if time.Now().Before(item.expiresAt) { + if matched, _ := filepath.Match(pattern, k); matched { + keys = append(keys, k) + } + } + return true + }) + return keys, nil +} + +func (s *smartStore) memoryCleanupLoop() { + ticker := time.NewTicker(10 * time.Minute) + logrus.Debug("[KVStore] Memory cleanup loop started") + for range ticker.C { + now := time.Now() + cleanedMain := 0 + cleanedLocks := 0 + + s.memory.Range(func(key, value any) bool { + item := value.(cachedItem) + if now.After(item.expiresAt) { + s.memory.Delete(key) + cleanedMain++ + } + return true + }) + s.locks.Range(func(key, value any) bool { + expiry := value.(time.Time) + if now.After(expiry) { + s.locks.Delete(key) + cleanedLocks++ + } + return true + }) + + if cleanedMain > 0 || cleanedLocks > 0 { + logrus.Debugf("[KVStore] Memory Cleanup: removed %d items and %d expired locks", cleanedMain, cleanedLocks) + } + } +} + +// Global instance helper +var Global KVStore + +func Init(vkClient *valkey.Client) { + Global = NewSmartStore(vkClient) + if vkClient != nil { + logrus.Info("[CORE] KVStore initialized with Valkey support") + } else { + logrus.Warn("[CORE] KVStore initialized in-memory mode (distributed features disabled)") + } +} diff --git a/src/frontend/src/components/AppModal.vue b/src/frontend/src/components/AppModal.vue index b441179..7a65920 100644 --- a/src/frontend/src/components/AppModal.vue +++ b/src/frontend/src/components/AppModal.vue @@ -19,9 +19,12 @@ function close() {