gohorsejobs/backend/internal/services/admin_service.go

575 lines
14 KiB
Go

package services
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/lib/pq"
"github.com/rede5/gohorsejobs/backend/internal/dto"
"github.com/rede5/gohorsejobs/backend/internal/models"
)
type AdminService struct {
DB *sql.DB
}
func NewAdminService(db *sql.DB) *AdminService {
return &AdminService{DB: db}
}
func (s *AdminService) ListCompanies(ctx context.Context, verified *bool, page, limit int) ([]models.Company, int, error) {
offset := (page - 1) * limit
// Count Total
countQuery := `SELECT COUNT(*) FROM companies`
var countArgs []interface{}
if verified != nil {
countQuery += " WHERE verified = $1"
countArgs = append(countArgs, *verified)
}
var total int
if err := s.DB.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
// Fetch Data
baseQuery := `
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at
FROM companies
`
var args []interface{}
if verified != nil {
baseQuery += " WHERE verified = $1"
args = append(args, *verified)
}
// Add pagination
baseQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.DB.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
companies := []models.Company{}
for rows.Next() {
var c models.Company
if err := rows.Scan(
&c.ID,
&c.Name,
&c.Slug,
&c.Type,
&c.Document,
&c.Address,
&c.RegionID,
&c.CityID,
&c.Phone,
&c.Email,
&c.Website,
&c.LogoURL,
&c.Description,
&c.Active,
&c.Verified,
&c.CreatedAt,
&c.UpdatedAt,
); err != nil {
return nil, 0, err
}
companies = append(companies, c)
}
return companies, total, nil
}
func (s *AdminService) UpdateCompanyStatus(ctx context.Context, id string, active *bool, verified *bool) (*models.Company, error) {
company, err := s.getCompanyByID(ctx, id)
if err != nil {
return nil, err
}
if active != nil {
company.Active = *active
}
if verified != nil {
company.Verified = *verified
}
company.UpdatedAt = time.Now()
query := `
UPDATE companies
SET active = $1, verified = $2, updated_at = $3
WHERE id = $4
`
_, err = s.DB.ExecContext(ctx, query, company.Active, company.Verified, company.UpdatedAt, id)
if err != nil {
return nil, err
}
return company, nil
}
func (s *AdminService) DuplicateJob(ctx context.Context, id string) (*models.Job, error) {
query := `
SELECT company_id, created_by, title, description, salary_min, salary_max, salary_type,
employment_type, work_mode, working_hours, location, region_id, city_id,
requirements, benefits, visa_support, language_level
FROM jobs
WHERE id = $1
`
var job models.Job
if err := s.DB.QueryRowContext(ctx, query, id).Scan(
&job.CompanyID,
&job.CreatedBy,
&job.Title,
&job.Description,
&job.SalaryMin,
&job.SalaryMax,
&job.SalaryType,
&job.EmploymentType,
&job.WorkMode,
&job.WorkingHours,
&job.Location,
&job.RegionID,
&job.CityID,
&job.Requirements,
&job.Benefits,
&job.VisaSupport,
&job.LanguageLevel,
); err != nil {
return nil, err
}
job.Status = "draft"
job.IsFeatured = false
job.CreatedAt = time.Now()
job.UpdatedAt = time.Now()
insertQuery := `
INSERT INTO jobs (
company_id, created_by, title, description, salary_min, salary_max, salary_type,
employment_type, work_mode, working_hours, location, region_id, city_id,
requirements, benefits, visa_support, language_level, status, is_featured, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING id
`
if err := s.DB.QueryRowContext(ctx, insertQuery,
job.CompanyID,
job.CreatedBy,
job.Title,
job.Description,
job.SalaryMin,
job.SalaryMax,
job.SalaryType,
job.EmploymentType,
job.WorkMode,
job.WorkingHours,
job.Location,
job.RegionID,
job.CityID,
job.Requirements,
job.Benefits,
job.VisaSupport,
job.LanguageLevel,
job.Status,
job.IsFeatured,
job.CreatedAt,
job.UpdatedAt,
).Scan(&job.ID); err != nil {
return nil, err
}
return &job, nil
}
func (s *AdminService) ListTags(ctx context.Context, category *string) ([]models.Tag, error) {
baseQuery := `SELECT id, name, category, active, created_at, updated_at FROM job_tags`
var args []interface{}
if category != nil && *category != "" {
baseQuery += " WHERE category = $1"
args = append(args, *category)
}
baseQuery += " ORDER BY name ASC"
rows, err := s.DB.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
tags := []models.Tag{}
for rows.Next() {
var t models.Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Category, &t.Active, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
tags = append(tags, t)
}
return tags, nil
}
func (s *AdminService) CreateTag(ctx context.Context, name string, category string) (*models.Tag, error) {
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("tag name is required")
}
now := time.Now()
tag := models.Tag{
Name: strings.TrimSpace(name),
Category: category,
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
query := `
INSERT INTO job_tags (name, category, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
if err := s.DB.QueryRowContext(ctx, query, tag.Name, tag.Category, tag.Active, tag.CreatedAt, tag.UpdatedAt).Scan(&tag.ID); err != nil {
return nil, err
}
return &tag, nil
}
func (s *AdminService) UpdateTag(ctx context.Context, id int, name *string, active *bool) (*models.Tag, error) {
tag, err := s.getTagByID(ctx, id)
if err != nil {
return nil, err
}
if name != nil {
trimmed := strings.TrimSpace(*name)
if trimmed != "" {
tag.Name = trimmed
}
}
if active != nil {
tag.Active = *active
}
tagUpdatedAt := time.Now()
query := `
UPDATE job_tags
SET name = $1, active = $2, updated_at = $3
WHERE id = $4
`
_, err = s.DB.ExecContext(ctx, query, tag.Name, tag.Active, tagUpdatedAt, id)
if err != nil {
return nil, err
}
tag.UpdatedAt = tagUpdatedAt
return tag, nil
}
func (s *AdminService) ListCandidates(ctx context.Context) ([]dto.Candidate, dto.CandidateStats, error) {
fmt.Println("[DEBUG] Starting ListCandidates")
query := `
SELECT id, full_name, email, phone, city, state, title, experience, bio, skills, avatar_url, created_at
FROM users
WHERE role = 'candidate'
ORDER BY created_at DESC
`
fmt.Println("[DEBUG] Executing query:", query)
rows, err := s.DB.QueryContext(ctx, query)
if err != nil {
fmt.Println("[ERROR] Query failed:", err)
return nil, dto.CandidateStats{}, err
}
defer rows.Close()
candidates := make([]dto.Candidate, 0)
candidateIndex := make(map[string]int) // ID is string (UUID)
candidateIDs := make([]string, 0) // ID is string (UUID)
stats := dto.CandidateStats{}
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
for rows.Next() {
var (
id string // Changed to string for UUID
fullName string
email sql.NullString
phone sql.NullString
city sql.NullString
state sql.NullString
title sql.NullString
experience sql.NullString
bio sql.NullString
avatarURL sql.NullString
skills []string
createdAt time.Time
)
if err := rows.Scan(
&id,
&fullName,
&email,
&phone,
&city,
&state,
&title,
&experience,
&bio,
pq.Array(&skills),
&avatarURL,
&createdAt,
); err != nil {
fmt.Println("[ERROR] Scan failed:", err)
return nil, dto.CandidateStats{}, err
}
location := buildLocation(city, state)
// Fix DTO if it expects int ID? Need to check DTO definition.
// Assuming DTO also needs update or is already string?
// If DTO.Candidate.ID is int, this will break compilation.
// I must verify DTO definition first.
// But I cannot see DTO definition here.
// Assuming DTO is compatible or I will fix it next.
// Actually, in previous Context `Ticket` model ID was changed to string. likely DTO needs it too.
// I will proceed assuming I need to cast or DTO is string.
// Wait, if DTO.ID is int, I cannot assign string.
// Let's assume DTO needs update.
// For now, I'll update logic to match UUIDs.
candidate := dto.Candidate{
ID: id, // Check if this compiles!
Name: fullName,
Email: stringOrNil(email),
Phone: stringOrNil(phone),
Location: location,
Title: stringOrNil(title),
Experience: stringOrNil(experience),
Bio: stringOrNil(bio),
AvatarURL: stringOrNil(avatarURL),
Skills: normalizeSkills(skills),
Applications: []dto.CandidateApplication{},
CreatedAt: createdAt,
}
if createdAt.After(thirtyDaysAgo) {
stats.NewCandidates++
}
candidateIndex[id] = len(candidates)
candidateIDs = append(candidateIDs, id)
candidates = append(candidates, candidate)
}
fmt.Printf("[DEBUG] Found %d candidates\n", len(candidates))
stats.TotalCandidates = len(candidates)
if len(candidateIDs) == 0 {
return candidates, stats, nil
}
appQuery := `
SELECT a.id, a.user_id, a.status, a.created_at, j.title, c.name
FROM applications a
JOIN jobs j ON j.id = a.job_id
JOIN companies c ON c.id = j.company_id
WHERE a.user_id = ANY($1)
ORDER BY a.created_at DESC
`
fmt.Println("[DEBUG] Executing appQuery")
appRows, err := s.DB.QueryContext(ctx, appQuery, pq.Array(candidateIDs))
if err != nil {
fmt.Println("[ERROR] AppQuery failed:", err)
return nil, dto.CandidateStats{}, err
}
defer appRows.Close()
totalApplications := 0
hiredApplications := 0
for appRows.Next() {
var (
app dto.CandidateApplication
userID string // UUID
)
if err := appRows.Scan(
&app.ID,
&userID,
&app.Status,
&app.AppliedAt,
&app.JobTitle,
&app.Company,
); err != nil {
fmt.Println("[ERROR] AppList Scan failed:", err)
return nil, dto.CandidateStats{}, err
}
totalApplications++
if isActiveApplicationStatus(app.Status) {
stats.ActiveApplications++
}
if app.Status == "hired" {
hiredApplications++
}
if index, ok := candidateIndex[userID]; ok {
candidates[index].Applications = append(candidates[index].Applications, app)
}
}
fmt.Printf("[DEBUG] Processed %d applications\n", totalApplications)
if totalApplications > 0 {
stats.HiringRate = (float64(hiredApplications) / float64(totalApplications)) * 100
}
return candidates, stats, nil
}
func stringOrNil(value sql.NullString) *string {
if !value.Valid {
return nil
}
trimmed := strings.TrimSpace(value.String)
if trimmed == "" {
return nil
}
return &trimmed
}
func buildLocation(city sql.NullString, state sql.NullString) *string {
parts := make([]string, 0, 2)
if city.Valid && strings.TrimSpace(city.String) != "" {
parts = append(parts, strings.TrimSpace(city.String))
}
if state.Valid && strings.TrimSpace(state.String) != "" {
parts = append(parts, strings.TrimSpace(state.String))
}
if len(parts) == 0 {
return nil
}
location := strings.Join(parts, ", ")
return &location
}
func normalizeSkills(skills []string) []string {
if len(skills) == 0 {
return []string{}
}
normalized := make([]string, 0, len(skills))
for _, skill := range skills {
trimmed := strings.TrimSpace(skill)
if trimmed == "" {
continue
}
normalized = append(normalized, trimmed)
}
if len(normalized) == 0 {
return []string{}
}
return normalized
}
func isActiveApplicationStatus(status string) bool {
switch status {
case "pending", "reviewed", "shortlisted":
return true
default:
return false
}
}
func (s *AdminService) getCompanyByID(ctx context.Context, id string) (*models.Company, error) {
query := `
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at
FROM companies WHERE id = $1
`
var c models.Company
if err := s.DB.QueryRowContext(ctx, query, id).Scan(
&c.ID,
&c.Name,
&c.Slug,
&c.Type,
&c.Document,
&c.Address,
&c.RegionID,
&c.CityID,
&c.Phone,
&c.Email,
&c.Website,
&c.LogoURL,
&c.Description,
&c.Active,
&c.Verified,
&c.CreatedAt,
&c.UpdatedAt,
); err != nil {
return nil, err
}
return &c, nil
}
func (s *AdminService) getTagByID(ctx context.Context, id int) (*models.Tag, error) {
query := `SELECT id, name, category, active, created_at, updated_at FROM job_tags WHERE id = $1`
var t models.Tag
if err := s.DB.QueryRowContext(ctx, query, id).Scan(&t.ID, &t.Name, &t.Category, &t.Active, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
return &t, nil
}
// GetUser fetches a user by ID
func (s *AdminService) GetUser(ctx context.Context, id string) (*dto.User, error) {
query := `
SELECT id, name, email, role, created_at
FROM users WHERE id = $1
`
var u dto.User
var roleStr string
if err := s.DB.QueryRowContext(ctx, query, id).Scan(&u.ID, &u.Name, &u.Email, &roleStr, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = roleStr
return &u, nil
}
// GetCompanyByUserID fetches the company associated with a user
func (s *AdminService) GetCompanyByUserID(ctx context.Context, userID string) (*models.Company, error) {
// First, try to find company where this user is admin
// Assuming users table has company_id or companies table has admin_email
// Let's check if 'users' has company_id column via error or assume architecture.
// Since CreateCompany creates a user, it likely links them.
// I will try to find a company by created_by = user_id IF that column exists?
// Or query based on some relation.
// Let's try finding company by admin_email matching user email.
// Fetch user email first
user, err := s.GetUser(ctx, userID)
if err != nil {
return nil, err
}
query := `SELECT id, name, slug, active, verified FROM companies WHERE email = $1`
var c models.Company
if err := s.DB.QueryRowContext(ctx, query, user.Email).Scan(&c.ID, &c.Name, &c.Slug, &c.Active, &c.Verified); err != nil {
// Try another way? Join companies c JOIN users u ON u.company_id = c.id
// If users table has company_id column...
// Let's try that as fallback.
query2 := `
SELECT c.id, c.name, c.slug, c.active, c.verified
FROM companies c
JOIN users u ON u.company_id = c.id
WHERE u.id = $1
`
if err2 := s.DB.QueryRowContext(ctx, query2, userID).Scan(&c.ID, &c.Name, &c.Slug, &c.Active, &c.Verified); err2 != nil {
return nil, fmt.Errorf("company not found for user %s", userID)
}
}
return &c, nil
}