package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // PlatformAppService implements business logic for PlatformApp CRUD, // AccessToken lifecycle, and Permissible management. // Reference: Chatwoot app/controllers/api/v1/platform/apps_controller.rb // + AccessTokenable concern + PlatformAppPermissible type PlatformAppService struct { platformAppRepo *repository.PlatformAppRepo accessTokenRepo *repository.AccessTokenRepo permissibleRepo *repository.PermissibleRepo } // NewPlatformAppService creates a new PlatformApp service. func NewPlatformAppService( platformAppRepo *repository.PlatformAppRepo, accessTokenRepo *repository.AccessTokenRepo, permissibleRepo *repository.PermissibleRepo, ) *PlatformAppService { return &PlatformAppService{ platformAppRepo: platformAppRepo, accessTokenRepo: accessTokenRepo, permissibleRepo: permissibleRepo, } } // --- DTOs for PlatformApp CRUD --- // CreatePlatformAppRequest is the DTO for creating a platform app. type CreatePlatformAppRequest struct { Name string `json:"name" validate:"required,min=2"` Description string `json:"description,omitempty" validate:"omitempty,max=500"` Icon string `json:"icon,omitempty"` URL string `json:"url,omitempty"` Config json.RawMessage `json:"config,omitempty"` Type string `json:"type,omitempty" validate:"omitempty,oneof=api agent_bot integration"` AccountID uint `json:"account_id,omitempty"` // 0 = platform-level (no account restriction) } // UpdatePlatformAppRequest is the DTO for updating a platform app. type UpdatePlatformAppRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` Description string `json:"description,omitempty" validate:"omitempty,max=500"` Icon string `json:"icon,omitempty"` URL string `json:"url,omitempty"` Config json.RawMessage `json:"config,omitempty"` Type string `json:"type,omitempty" validate:"omitempty,oneof=api agent_bot integration"` Status string `json:"status,omitempty" validate:"omitempty,oneof=active disabled"` Active *bool `json:"active,omitempty"` } // --- PlatformApp CRUD methods --- // Create inserts a new PlatformApp and auto-generates an AccessToken. // Reference: Chatwoot PlatformAppsController#create — creates app + auto-creates AccessTokenable token. // Returns the app and the plaintext token (shown only once, never stored). func (s *PlatformAppService) Create(ctx context.Context, req CreatePlatformAppRequest) (*model.PlatformApp, string, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, "", err } // Generate plaintext token (prefix + random suffix, Chatwoot pattern) plainToken, err := generatePlatformAccessToken() if err != nil { applogger.L().Errorf("Create platform app: generate token: %v", err) return nil, "", errors.New("failed to generate access token") } // Default type to "api" if not specified appType := req.Type if appType == "" { appType = "api" } // Default active to true active := true // AccountID — nil means platform-level (no account restriction) var accountIDPtr *uint if req.AccountID != 0 { accountIDPtr = &req.AccountID } app := &model.PlatformApp{ Name: req.Name, Description: req.Description, Icon: req.Icon, URL: req.URL, Config: req.Config, AccountID: accountIDPtr, Type: appType, Status: "active", Active: &active, } if err := s.platformAppRepo.Create(ctx, app); err != nil { applogger.L().Errorf("Create platform app: %v", err) return nil, "", err } // Auto-create AccessToken for the new PlatformApp (AccessTokenable concern) tokenHash := hashTokenSHA256(plainToken) prefix := tokenPrefix(plainToken) accessToken := &model.AccessToken{ OwnerType: model.AccessTokenOwnerTypePlatformApp, OwnerID: app.ID, Token: tokenHash, TokenPrefix: prefix, Name: fmt.Sprintf("PlatformApp: %s", app.Name), } if err := s.accessTokenRepo.Create(ctx, accessToken); err != nil { applogger.L().Errorf("Create platform app: access token creation failed: %v", err) // Rollback: delete the app we just created if delErr := s.platformAppRepo.Delete(ctx, app.ID); delErr != nil { applogger.L().Errorf("Create platform app: rollback delete failed: %v", delErr) } return nil, "", fmt.Errorf("failed to create access token: %w", err) } applogger.L().Infof("Create platform app: app %d created with access token", app.ID) return app, plainToken, nil } // GetByID retrieves a single PlatformApp by ID. func (s *PlatformAppService) GetByID(ctx context.Context, id uint) (*model.PlatformApp, error) { app, err := s.platformAppRepo.GetByID(ctx, id) if err != nil { applogger.L().Errorf("Get platform app %d: %v", id, err) return nil, err } return app, nil } // GetByIDWithRelations retrieves a PlatformApp with Permissibles and AccessToken loaded. func (s *PlatformAppService) GetByIDWithRelations(ctx context.Context, id uint) (*model.PlatformApp, error) { app, err := s.platformAppRepo.GetByIDWithRelations(ctx, id) if err != nil { applogger.L().Errorf("Get platform app %d with relations: %v", id, err) return nil, err } return app, nil } // Update modifies an existing PlatformApp. Only non-empty fields are applied. func (s *PlatformAppService) Update(ctx context.Context, id uint, req UpdatePlatformAppRequest) (*model.PlatformApp, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } app, err := s.platformAppRepo.GetByID(ctx, id) if err != nil { return nil, err } // Apply only non-empty fields if req.Name != "" { app.Name = req.Name } if req.Description != "" { app.Description = req.Description } if req.Icon != "" { app.Icon = req.Icon } if req.URL != "" { app.URL = req.URL } if req.Config != nil { app.Config = req.Config } if req.Type != "" { app.Type = req.Type } if req.Status != "" { app.Status = req.Status } if req.Active != nil { app.Active = req.Active } if err := s.platformAppRepo.Update(ctx, app); err != nil { applogger.L().Errorf("Update platform app %d: %v", id, err) return nil, err } return app, nil } // Delete soft-deletes a PlatformApp by ID. // Also soft-deletes associated AccessTokens and hard-deletes Permissibles. func (s *PlatformAppService) Delete(ctx context.Context, id uint) error { // Clean up AccessTokens (soft delete each) tokens, err := s.accessTokenRepo.FindByOwner(ctx, model.AccessTokenOwnerTypePlatformApp, id) if err != nil { applogger.L().Warnf("Delete platform app %d: find access tokens: %v", id, err) } for _, token := range tokens { if delErr := s.accessTokenRepo.Delete(ctx, token.ID); delErr != nil { applogger.L().Warnf("Delete platform app %d: delete access token %d: %v", id, token.ID, delErr) } } // Clean up Permissibles (hard delete — permission rules are ephemeral) if delErr := s.permissibleRepo.DeleteByPlatformAppID(ctx, id); delErr != nil { applogger.L().Warnf("Delete platform app %d: delete permissibles: %v", id, delErr) } // Soft-delete the app itself if err := s.platformAppRepo.Delete(ctx, id); err != nil { applogger.L().Errorf("Delete platform app %d: %v", id, err) return err } return nil } // ListAll retrieves all PlatformApps with pagination (super-admin view). func (s *PlatformAppService) ListAll(ctx context.Context, offset, limit int) ([]model.PlatformApp, int64, error) { return s.platformAppRepo.FindAll(ctx, offset, limit) } // ListByAccount retrieves PlatformApps scoped to an account with pagination. func (s *PlatformAppService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.PlatformApp, int64, error) { return s.platformAppRepo.FindByAccountID(ctx, accountID, offset, limit) } // Search searches PlatformApps by name (platform-wide, super-admin). // Uses LIKE (not ILIKE) for SQLite compatibility. func (s *PlatformAppService) Search(ctx context.Context, nameQuery string, offset, limit int) ([]model.PlatformApp, int64, error) { if nameQuery == "" { return s.platformAppRepo.FindAll(ctx, offset, limit) } return s.platformAppRepo.SearchByName(ctx, nameQuery, offset, limit) } // SearchByAccount searches PlatformApps by name scoped to an account. func (s *PlatformAppService) SearchByAccount(ctx context.Context, accountID uint, nameQuery string, offset, limit int) ([]model.PlatformApp, int64, error) { if nameQuery == "" { return s.platformAppRepo.FindByAccountID(ctx, accountID, offset, limit) } return s.platformAppRepo.SearchByNameByAccount(ctx, accountID, nameQuery, offset, limit) } // --- AccessToken lifecycle methods (Chatwoot AccessTokenable concern) --- // RegenerateAccessToken generates a new AccessToken for an existing PlatformApp, // soft-deleting the old one. This is the key rotation/refresh operation. // Reference: Chatwoot PlatformAppsController#regenerate_api_key // Returns the plaintext token (shown only once, never stored). func (s *PlatformAppService) RegenerateAccessToken(ctx context.Context, id uint) (string, error) { app, err := s.platformAppRepo.GetByID(ctx, id) if err != nil { applogger.L().Errorf("RegenerateAccessToken: get platform app %d: %v", id, err) return "", err } // Generate new plaintext token plainToken, err := generatePlatformAccessToken() if err != nil { applogger.L().Errorf("RegenerateAccessToken: generate token for app %d: %v", id, err) return "", err } // Soft-delete existing tokens for this app tokens, err := s.accessTokenRepo.FindByOwner(ctx, model.AccessTokenOwnerTypePlatformApp, app.ID) if err != nil { applogger.L().Warnf("RegenerateAccessToken: find old tokens: %v", err) } for _, token := range tokens { if delErr := s.accessTokenRepo.Delete(ctx, token.ID); delErr != nil { applogger.L().Warnf("RegenerateAccessToken: delete old token %d: %v", token.ID, delErr) } } // Create new AccessToken tokenHash := hashTokenSHA256(plainToken) prefix := tokenPrefix(plainToken) accessToken := &model.AccessToken{ OwnerType: model.AccessTokenOwnerTypePlatformApp, OwnerID: app.ID, Token: tokenHash, TokenPrefix: prefix, Name: fmt.Sprintf("PlatformApp: %s (regenerated)", app.Name), } if err := s.accessTokenRepo.Create(ctx, accessToken); err != nil { applogger.L().Errorf("RegenerateAccessToken: create new token for app %d: %v", id, err) return "", fmt.Errorf("failed to create new access token: %w", err) } applogger.L().Infof("RegenerateAccessToken: platform app %d token regenerated", id) return plainToken, nil } // ListAccessTokens retrieves all AccessTokens for a PlatformApp. func (s *PlatformAppService) ListAccessTokens(ctx context.Context, platformAppID uint) ([]model.AccessToken, error) { return s.accessTokenRepo.FindByOwner(ctx, model.AccessTokenOwnerTypePlatformApp, platformAppID) } // --- Permissible management methods (Chatwoot PlatformAppPermissible) --- // AddPermissible grants a PlatformApp access to a resource (Account, User, or AgentBot). // Reference: Chatwoot PlatformAppsController#add_permissible func (s *PlatformAppService) AddPermissible(ctx context.Context, platformAppID uint, permissibleType string, permissibleID uint) (*model.Permissible, error) { // Validate permissibleType if !isValidPermissibleType(permissibleType) { return nil, fmt.Errorf("invalid permissible_type: %s (must be Account, User, or AgentBot)", permissibleType) } // Verify PlatformApp exists app, err := s.platformAppRepo.GetByID(ctx, platformAppID) if err != nil { return nil, fmt.Errorf("platform app %d not found: %w", platformAppID, err) } // Check for duplicate permission existing, err := s.permissibleRepo.FindByPlatformAppAndResource(ctx, app.ID, permissibleType, permissibleID) if err == nil && existing != nil { return nil, fmt.Errorf("permission already exists for platform_app %d on %s %d", app.ID, permissibleType, permissibleID) } permissible := &model.Permissible{ PlatformAppID: app.ID, PermissibleType: permissibleType, PermissibleID: permissibleID, } if err := s.permissibleRepo.Create(ctx, permissible); err != nil { applogger.L().Errorf("AddPermissible: create failed: %v", err) return nil, err } applogger.L().Infof("AddPermissible: platform app %d granted access to %s %d", app.ID, permissibleType, permissibleID) return permissible, nil } // RemovePermissible revokes a PlatformApp's access to a resource. // Reference: Chatwoot PlatformAppsController#remove_permissible func (s *PlatformAppService) RemovePermissible(ctx context.Context, platformAppID uint, permissibleType string, permissibleID uint) error { if !isValidPermissibleType(permissibleType) { return fmt.Errorf("invalid permissible_type: %s (must be Account, User, or AgentBot)", permissibleType) } if err := s.permissibleRepo.DeleteByPlatformAppAndResource(ctx, platformAppID, permissibleType, permissibleID); err != nil { applogger.L().Errorf("RemovePermissible: delete failed: %v", err) return err } applogger.L().Infof("RemovePermissible: platform app %d revoked access to %s %d", platformAppID, permissibleType, permissibleID) return nil } // ListPermissibles retrieves all Permissibles for a PlatformApp. func (s *PlatformAppService) ListPermissibles(ctx context.Context, platformAppID uint) ([]model.Permissible, error) { return s.permissibleRepo.FindByPlatformAppID(ctx, platformAppID) } // --- Helper functions --- func isValidPermissibleType(t string) bool { return t == model.PermissibleTypeAccount || t == model.PermissibleTypeUser || t == model.PermissibleTypeAgentBot } // hashTokenSHA256 hashes a plaintext token using SHA-256 for secure storage. func hashTokenSHA256(plain string) string { h := sha256.Sum256([]byte(plain)) return hex.EncodeToString(h[:]) } // tokenPrefix extracts the first 8 characters of a plaintext token for DB lookup. func tokenPrefix(plain string) string { if len(plain) >= 8 { return plain[:8] } return plain } // generatePlatformAccessToken creates a random platform access token. // Format: "gochat_pat_" + 32 random bytes as hex string (64 hex chars). func generatePlatformAccessToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("crypto/rand failed: %w", err) } return "gochat_pat_" + hex.EncodeToString(b), nil }