package service import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/pkg/types" ) // DatabaseService handles tenant and user database operations type DatabaseService struct { pool *pgxpool.Pool logger zerolog.Logger } // NewDatabaseService creates a new database service func NewDatabaseService(pool *pgxpool.Pool, logger zerolog.Logger) *DatabaseService { return &DatabaseService{ pool: pool, logger: logger.With().Str("service", "database").Logger(), } } // ListTenants lists tenants with optional filters func (s *DatabaseService) ListTenants(ctx context.Context, params types.ListParams) ([]types.Tenant, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM admin.tenants WHERE 1=1` listQuery := `SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color, max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at FROM admin.tenants WHERE 1=1` args := []interface{}{} argIdx := 1 if params.Status != "" { if params.Status == "active" { countQuery += " AND is_active = true" listQuery += " AND is_active = true" } else if params.Status == "inactive" { countQuery += " AND is_active = false" listQuery += " AND is_active = false" } } if params.Search != "" { countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx) listQuery += fmt.Sprintf(" AND (name ILIKE $%d OR code ILIKE $%d OR domain ILIKE $%d)", argIdx, argIdx, argIdx) args = append(args, "%"+params.Search+"%") argIdx++ } var total int64 if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit, params.Offset) rows, err := s.pool.Query(ctx, listQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() tenants := make([]types.Tenant, 0) for rows.Next() { var t types.Tenant var metadataJSON []byte if err := rows.Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain, &t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours, &t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } if len(metadataJSON) > 0 { json.Unmarshal(metadataJSON, &t.Metadata) } tenants = append(tenants, t) } return tenants, total, nil } // GetTenant gets a tenant by ID func (s *DatabaseService) GetTenant(ctx context.Context, id uuid.UUID) (*types.Tenant, error) { var t types.Tenant var metadataJSON []byte err := s.pool.QueryRow(ctx, `SELECT id, customer_id, code, name, display_name, domain, logo_url, primary_color, max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at FROM admin.tenants WHERE id = $1`, id). Scan(&t.ID, &t.CustomerID, &t.Code, &t.Name, &t.DisplayName, &t.Domain, &t.LogoURL, &t.PrimaryColor, &t.MaxUsers, &t.MaxStorageGB, &t.MaxRecordingHours, &t.IsActive, &metadataJSON, &t.CreatedAt, &t.UpdatedAt) if err != nil { return nil, err } if len(metadataJSON) > 0 { json.Unmarshal(metadataJSON, &t.Metadata) } return &t, nil } // CreateTenant creates a new tenant func (s *DatabaseService) CreateTenant(ctx context.Context, req *types.TenantCreate) (*types.Tenant, error) { id := uuid.New() now := time.Now().UTC() var metadataJSON []byte if req.Metadata != nil { var err error metadataJSON, err = json.Marshal(req.Metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } } _, err := s.pool.Exec(ctx, `INSERT INTO admin.tenants (id, customer_id, code, name, display_name, domain, logo_url, primary_color, max_users, max_storage_gb, max_recording_hours, is_active, metadata, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, true, $12, $13, $13)`, id, req.CustomerID, req.Code, req.Name, nilIfEmpty(req.DisplayName), nilIfEmpty(req.Domain), nilIfEmpty(req.LogoURL), nilIfEmpty(req.PrimaryColor), req.MaxUsers, req.MaxStorageGB, req.MaxRecordingHours, metadataJSON, now) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } return s.GetTenant(ctx, id) } // UpdateTenant updates a tenant func (s *DatabaseService) UpdateTenant(ctx context.Context, id uuid.UUID, req *types.TenantUpdate) (*types.Tenant, error) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Name != nil { setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) args = append(args, *req.Name) argIdx++ } if req.DisplayName != nil { setClauses = append(setClauses, fmt.Sprintf("display_name = $%d", argIdx)) args = append(args, *req.DisplayName) argIdx++ } if req.Domain != nil { setClauses = append(setClauses, fmt.Sprintf("domain = $%d", argIdx)) args = append(args, *req.Domain) argIdx++ } if req.LogoURL != nil { setClauses = append(setClauses, fmt.Sprintf("logo_url = $%d", argIdx)) args = append(args, *req.LogoURL) argIdx++ } if req.PrimaryColor != nil { setClauses = append(setClauses, fmt.Sprintf("primary_color = $%d", argIdx)) args = append(args, *req.PrimaryColor) argIdx++ } if req.MaxUsers != nil { setClauses = append(setClauses, fmt.Sprintf("max_users = $%d", argIdx)) args = append(args, *req.MaxUsers) argIdx++ } if req.MaxStorageGB != nil { setClauses = append(setClauses, fmt.Sprintf("max_storage_gb = $%d", argIdx)) args = append(args, *req.MaxStorageGB) argIdx++ } if req.MaxRecordingHours != nil { setClauses = append(setClauses, fmt.Sprintf("max_recording_hours = $%d", argIdx)) args = append(args, *req.MaxRecordingHours) argIdx++ } if req.IsActive != nil { setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx)) args = append(args, *req.IsActive) argIdx++ } if req.Metadata != nil { metadataJSON, err := json.Marshal(req.Metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx)) args = append(args, metadataJSON) argIdx++ } if len(setClauses) == 0 { return s.GetTenant(ctx, id) } setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx)) args = append(args, time.Now().UTC()) argIdx++ args = append(args, id) query := fmt.Sprintf("UPDATE admin.tenants SET %s WHERE id = $%d", join(setClauses, ", "), argIdx) _, err := s.pool.Exec(ctx, query, args...) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } return s.GetTenant(ctx, id) } // SoftDeleteTenant deactivates a tenant func (s *DatabaseService) SoftDeleteTenant(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, `UPDATE admin.tenants SET is_active = false, updated_at = $1 WHERE id = $2`, time.Now().UTC(), id) return err } // ListUsers lists users with optional filters func (s *DatabaseService) ListUsers(ctx context.Context, params types.ListParams) ([]types.DBUser, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM admin.users WHERE 1=1` listQuery := `SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status, last_login_at, last_activity_at, metadata, created_at, updated_at FROM admin.users WHERE 1=1` args := []interface{}{} argIdx := 1 if params.Status != "" { countQuery += fmt.Sprintf(" AND status = $%d", argIdx) listQuery += fmt.Sprintf(" AND status = $%d", argIdx) args = append(args, params.Status) argIdx++ } if params.Search != "" { countQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx) listQuery += fmt.Sprintf(" AND (gscsid ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx, argIdx) args = append(args, "%"+params.Search+"%") argIdx++ } var total int64 if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } listQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit, params.Offset) rows, err := s.pool.Query(ctx, listQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() users := make([]types.DBUser, 0) for rows.Next() { var u types.DBUser var metadataJSON []byte if err := rows.Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status, &u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } if len(metadataJSON) > 0 { json.Unmarshal(metadataJSON, &u.Metadata) } users = append(users, u) } return users, total, nil } // GetUser gets a user by ID func (s *DatabaseService) GetUser(ctx context.Context, id uuid.UUID) (*types.DBUser, error) { var u types.DBUser var metadataJSON []byte err := s.pool.QueryRow(ctx, `SELECT id, gscsid, first_name, last_name, display_name, email, timezone, locale, status, last_login_at, last_activity_at, metadata, created_at, updated_at FROM admin.users WHERE id = $1`, id). Scan(&u.ID, &u.GscSID, &u.FirstName, &u.LastName, &u.DisplayName, &u.Email, &u.Timezone, &u.Locale, &u.Status, &u.LastLoginAt, &u.LastActivityAt, &metadataJSON, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } if len(metadataJSON) > 0 { json.Unmarshal(metadataJSON, &u.Metadata) } return &u, nil } // CreateUser creates a new user record func (s *DatabaseService) CreateUser(ctx context.Context, req *types.DBUserCreate) (*types.DBUser, error) { id := uuid.New() now := time.Now().UTC() var metadataJSON []byte if req.Metadata != nil { var err error metadataJSON, err = json.Marshal(req.Metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } } _, err := s.pool.Exec(ctx, `INSERT INTO admin.users (id, gscsid, first_name, last_name, display_name, email, timezone, locale, status, metadata, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $10)`, id, req.GscSID, nilIfEmpty(req.FirstName), nilIfEmpty(req.LastName), nilIfEmpty(req.DisplayName), nilIfEmpty(req.Email), nilIfEmpty(req.Timezone), nilIfEmpty(req.Locale), metadataJSON, now) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } return s.GetUser(ctx, id) } // UpdateUser updates a user record func (s *DatabaseService) UpdateUser(ctx context.Context, id uuid.UUID, req *types.DBUserUpdate) (*types.DBUser, error) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Timezone != nil { setClauses = append(setClauses, fmt.Sprintf("timezone = $%d", argIdx)) args = append(args, *req.Timezone) argIdx++ } if req.Locale != nil { setClauses = append(setClauses, fmt.Sprintf("locale = $%d", argIdx)) args = append(args, *req.Locale) argIdx++ } if req.Status != nil { setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx)) args = append(args, *req.Status) argIdx++ } if req.LastLoginAt != nil { setClauses = append(setClauses, fmt.Sprintf("last_login_at = $%d", argIdx)) args = append(args, *req.LastLoginAt) argIdx++ } if req.LastActivityAt != nil { setClauses = append(setClauses, fmt.Sprintf("last_activity_at = $%d", argIdx)) args = append(args, *req.LastActivityAt) argIdx++ } if req.Metadata != nil { metadataJSON, err := json.Marshal(req.Metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } setClauses = append(setClauses, fmt.Sprintf("metadata = $%d", argIdx)) args = append(args, metadataJSON) argIdx++ } if len(setClauses) == 0 { return s.GetUser(ctx, id) } setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx)) args = append(args, time.Now().UTC()) argIdx++ args = append(args, id) query := fmt.Sprintf("UPDATE admin.users SET %s WHERE id = $%d", join(setClauses, ", "), argIdx) _, err := s.pool.Exec(ctx, query, args...) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } return s.GetUser(ctx, id) } // DeactivateUser deactivates a user func (s *DatabaseService) DeactivateUser(ctx context.Context, id uuid.UUID) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, `UPDATE admin.users SET status = 'inactive', updated_at = $1 WHERE id = $2`, now, id) return err } func nilIfEmpty(s string) *string { if s == "" { return nil } return &s } func join(strs []string, sep string) string { result := "" for i, s := range strs { if i > 0 { result += sep } result += s } return result }