gohorsejobs/backend/internal/services/job_service.go
Tiago Yamamoto b23393bf35 feat: implement stripe subscriptions, google analytics, and user crud
- Backend:
  - Add Stripe subscription fields to companies (migration 019)
  - Implement Stripe Checkout and Webhook handlers
  - Add Metrics API (view count, recording)
  - Update Company and Job models
- Frontend:
  - Add Google Analytics component
  - Implement User CRUD in Backoffice (Dashboard)
  - Add 'Featured' badge to JobCard
- Docs: Update Roadmap and artifacts
2025-12-27 12:06:54 -03:00

332 lines
9.9 KiB
Go

package services
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/rede5/gohorsejobs/backend/internal/dto"
"github.com/rede5/gohorsejobs/backend/internal/models"
)
type JobService struct {
DB *sql.DB
}
func NewJobService(db *sql.DB) *JobService {
return &JobService{DB: db}
}
func (s *JobService) CreateJob(req dto.CreateJobRequest) (*models.Job, error) {
query := `
INSERT INTO jobs (
company_id, title, description, salary_min, salary_max, salary_type,
employment_type, working_hours, location, region_id, city_id,
requirements, benefits, visa_support, language_level, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
RETURNING id, created_at, updated_at
`
job := &models.Job{
CompanyID: req.CompanyID,
Title: req.Title,
Description: req.Description,
SalaryMin: req.SalaryMin,
SalaryMax: req.SalaryMax,
SalaryType: req.SalaryType,
EmploymentType: req.EmploymentType,
WorkingHours: req.WorkingHours,
Location: req.Location,
RegionID: req.RegionID,
CityID: req.CityID,
Requirements: req.Requirements,
Benefits: req.Benefits,
VisaSupport: req.VisaSupport,
LanguageLevel: req.LanguageLevel,
Status: req.Status,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := s.DB.QueryRow(
query,
job.CompanyID, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType,
job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID,
job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt,
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
return nil, err
}
return job, nil
}
func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) {
baseQuery := `
SELECT
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.featured_until, j.view_count, j.created_at, j.updated_at,
c.name as company_name, c.logo_url as company_logo_url,
r.name as region_name, ci.name as city_name
FROM jobs j
LEFT JOIN companies c ON j.company_id = c.id
LEFT JOIN regions r ON j.region_id = r.id
LEFT JOIN cities ci ON j.city_id = ci.id
WHERE 1=1`
countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1`
var args []interface{}
argId := 1
// Full-text search on title and description
if filter.Search != nil && *filter.Search != "" {
searchClause := fmt.Sprintf(` AND (
to_tsvector('portuguese', COALESCE(j.title, '') || ' ' || COALESCE(j.description, ''))
@@ plainto_tsquery('portuguese', $%d)
OR j.title ILIKE '%%' || $%d || '%%'
OR j.description ILIKE '%%' || $%d || '%%'
)`, argId, argId, argId)
baseQuery += searchClause
countQuery += searchClause
args = append(args, *filter.Search)
argId++
}
// Company filter
if filter.CompanyID != nil {
baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
countQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
args = append(args, *filter.CompanyID)
argId++
}
// Region filter
if filter.RegionID != nil {
baseQuery += fmt.Sprintf(" AND j.region_id = $%d", argId)
countQuery += fmt.Sprintf(" AND j.region_id = $%d", argId)
args = append(args, *filter.RegionID)
argId++
}
// City filter
if filter.CityID != nil {
baseQuery += fmt.Sprintf(" AND j.city_id = $%d", argId)
countQuery += fmt.Sprintf(" AND j.city_id = $%d", argId)
args = append(args, *filter.CityID)
argId++
}
// Employment type filter
if filter.EmploymentType != nil && *filter.EmploymentType != "" {
baseQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId)
countQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId)
args = append(args, *filter.EmploymentType)
argId++
}
// Work mode filter (onsite, hybrid, remote)
if filter.WorkMode != nil && *filter.WorkMode != "" {
baseQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId)
countQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId)
args = append(args, *filter.WorkMode)
argId++
}
// Status filter
if filter.Status != nil && *filter.Status != "" {
baseQuery += fmt.Sprintf(" AND j.status = $%d", argId)
countQuery += fmt.Sprintf(" AND j.status = $%d", argId)
args = append(args, *filter.Status)
argId++
}
// Featured filter
if filter.IsFeatured != nil {
baseQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
countQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
args = append(args, *filter.IsFeatured)
argId++
}
// Visa support filter
if filter.VisaSupport != nil {
baseQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
countQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
args = append(args, *filter.VisaSupport)
argId++
}
// Salary range filters
if filter.SalaryMin != nil {
baseQuery += fmt.Sprintf(" AND (j.salary_max >= $%d OR j.salary_min >= $%d)", argId, argId)
countQuery += fmt.Sprintf(" AND (j.salary_max >= $%d OR j.salary_min >= $%d)", argId, argId)
args = append(args, *filter.SalaryMin)
argId++
}
if filter.SalaryMax != nil {
baseQuery += fmt.Sprintf(" AND (j.salary_min <= $%d OR j.salary_min IS NULL)", argId)
countQuery += fmt.Sprintf(" AND (j.salary_min <= $%d OR j.salary_min IS NULL)", argId)
args = append(args, *filter.SalaryMax)
argId++
}
if filter.SalaryType != nil && *filter.SalaryType != "" {
baseQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId)
countQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId)
args = append(args, *filter.SalaryType)
argId++
}
// Location text search
if filter.LocationSearch != nil && *filter.LocationSearch != "" {
baseQuery += fmt.Sprintf(" AND j.location ILIKE '%%' || $%d || '%%'", argId)
countQuery += fmt.Sprintf(" AND j.location ILIKE '%%' || $%d || '%%'", argId)
args = append(args, *filter.LocationSearch)
argId++
}
// Sorting
orderClause := " ORDER BY "
switch filter.SortBy {
case "salary":
orderClause += "COALESCE(j.salary_max, j.salary_min, 0)"
case "relevance":
if filter.Search != nil && *filter.Search != "" {
orderClause += fmt.Sprintf("ts_rank(to_tsvector('portuguese', COALESCE(j.title, '') || ' ' || COALESCE(j.description, '')), plainto_tsquery('portuguese', '%s'))", *filter.Search)
} else {
orderClause += "j.is_featured DESC, j.created_at"
}
default: // date
orderClause += "j.is_featured DESC, j.created_at"
}
if filter.SortOrder == "asc" {
orderClause += " ASC"
} else {
orderClause += " DESC"
}
baseQuery += orderClause
// Pagination
limit := filter.Limit
if limit == 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
offset := (filter.Page - 1) * limit
if offset < 0 {
offset = 0
}
paginationQuery := baseQuery + fmt.Sprintf(" LIMIT $%d OFFSET $%d", argId, argId+1)
paginationArgs := append(args, limit, offset)
rows, err := s.DB.Query(paginationQuery, paginationArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var jobs []models.JobWithCompany
for rows.Next() {
var j models.JobWithCompany
if err := rows.Scan(
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.WorkMode, &j.Location, &j.Status, &j.IsFeatured, &j.FeaturedUntil, &j.ViewCount, &j.CreatedAt, &j.UpdatedAt,
&j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName,
); err != nil {
return nil, 0, err
}
jobs = append(jobs, j)
}
var total int
err = s.DB.QueryRow(countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
return jobs, total, nil
}
func (s *JobService) GetJobByID(id int) (*models.Job, error) {
var j models.Job
query := `
SELECT id, company_id, title, description, salary_min, salary_max, salary_type,
employment_type, working_hours, location, region_id, city_id,
requirements, benefits, visa_support, language_level, status, is_featured, featured_until, view_count, created_at, updated_at
FROM jobs WHERE id = $1
`
err := s.DB.QueryRow(query, id).Scan(
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID,
&j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.IsFeatured, &j.FeaturedUntil, &j.ViewCount, &j.CreatedAt, &j.UpdatedAt,
)
if err != nil {
return nil, err
}
return &j, nil
}
func (s *JobService) UpdateJob(id int, req dto.UpdateJobRequest) (*models.Job, error) {
var setClauses []string
var args []interface{}
argId := 1
if req.Title != nil {
setClauses = append(setClauses, fmt.Sprintf("title = $%d", argId))
args = append(args, *req.Title)
argId++
}
if req.Description != nil {
setClauses = append(setClauses, fmt.Sprintf("description = $%d", argId))
args = append(args, *req.Description)
argId++
}
// Add other fields...
if req.Status != nil {
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argId))
args = append(args, *req.Status)
argId++
}
if req.IsFeatured != nil {
setClauses = append(setClauses, fmt.Sprintf("is_featured = $%d", argId))
args = append(args, *req.IsFeatured)
argId++
}
if req.FeaturedUntil != nil {
setClauses = append(setClauses, fmt.Sprintf("featured_until = $%d", argId))
parsedTime, err := time.Parse(time.RFC3339, *req.FeaturedUntil)
if err == nil {
args = append(args, parsedTime)
} else {
args = append(args, nil) // Or handle error
}
argId++
}
if len(setClauses) == 0 {
return s.GetJobByID(id)
}
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE jobs SET %s WHERE id = $%d RETURNING id, updated_at", strings.Join(setClauses, ", "), argId)
args = append(args, id)
var j models.Job
err := s.DB.QueryRow(query, args...).Scan(&j.ID, &j.UpdatedAt)
if err != nil {
return nil, err
}
return s.GetJobByID(id)
}
func (s *JobService) DeleteJob(id int) error {
_, err := s.DB.Exec("DELETE FROM jobs WHERE id = $1", id)
return err
}