Add admin backoffice routes and dashboard

This commit is contained in:
Tiago Yamamoto 2025-12-22 16:37:05 -03:00
parent 6d54fa3367
commit 58cfd76675
20 changed files with 1393 additions and 14 deletions

View file

@ -0,0 +1,297 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/rede5/gohorsejobs/backend/internal/dto"
"github.com/rede5/gohorsejobs/backend/internal/services"
)
type AdminHandlers struct {
adminService *services.AdminService
auditService *services.AuditService
jobService *services.JobService
}
type RoleAccess struct {
Role string `json:"role"`
Description string `json:"description"`
Actions []string `json:"actions"`
}
type UpdateCompanyStatusRequest struct {
Active *bool `json:"active,omitempty"`
Verified *bool `json:"verified,omitempty"`
}
type UpdateJobStatusRequest struct {
Status string `json:"status"`
}
type CreateTagRequest struct {
Name string `json:"name"`
Category string `json:"category"`
}
type UpdateTagRequest struct {
Name *string `json:"name,omitempty"`
Active *bool `json:"active,omitempty"`
}
func NewAdminHandlers(adminService *services.AdminService, auditService *services.AuditService, jobService *services.JobService) *AdminHandlers {
return &AdminHandlers{
adminService: adminService,
auditService: auditService,
jobService: jobService,
}
}
func (h *AdminHandlers) ListAccessRoles(w http.ResponseWriter, r *http.Request) {
roles := []RoleAccess{
{
Role: "admin",
Description: "Administrador geral da plataforma",
Actions: []string{
"criar/editar/excluir usuários",
"aprovar empresas",
"moderar vagas",
"gerir tags e categorias",
},
},
{
Role: "moderador",
Description: "Moderação de vagas e conteúdo",
Actions: []string{
"aprovar, recusar ou pausar vagas",
"marcar vagas denunciadas",
"revisar empresas pendentes",
},
},
{
Role: "suporte",
Description: "Suporte ao usuário",
Actions: []string{
"acessar perfil de usuário",
"resetar senhas",
"analisar logs de acesso",
},
},
{
Role: "financeiro",
Description: "Gestão financeira e faturamento",
Actions: []string{
"ver planos e pagamentos",
"exportar relatórios financeiros",
"controlar notas e cobranças",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(roles)
}
func (h *AdminHandlers) ListLoginAudits(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
entries, err := h.auditService.ListLogins(r.Context(), limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
func (h *AdminHandlers) ListCompanies(w http.ResponseWriter, r *http.Request) {
var verified *bool
if verifiedParam := r.URL.Query().Get("verified"); verifiedParam != "" {
value := verifiedParam == "true"
verified = &value
}
companies, err := h.adminService.ListCompanies(r.Context(), verified)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(companies)
}
func (h *AdminHandlers) UpdateCompanyStatus(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid company ID", http.StatusBadRequest)
return
}
var req UpdateCompanyStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid Request", http.StatusBadRequest)
return
}
company, err := h.adminService.UpdateCompanyStatus(r.Context(), id, req.Active, req.Verified)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(company)
}
func (h *AdminHandlers) ListJobs(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
status := r.URL.Query().Get("status")
filter := dto.JobFilterQuery{
PaginationQuery: dto.PaginationQuery{
Page: page,
Limit: limit,
},
}
if status != "" {
filter.Status = &status
}
jobs, total, err := h.jobService.GetJobs(filter)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := dto.PaginatedResponse{
Data: jobs,
Pagination: dto.Pagination{
Page: page,
Limit: limit,
Total: total,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *AdminHandlers) UpdateJobStatus(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid job ID", http.StatusBadRequest)
return
}
var req UpdateJobStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid Request", http.StatusBadRequest)
return
}
if req.Status == "" {
http.Error(w, "Status is required", http.StatusBadRequest)
return
}
status := req.Status
job, err := h.jobService.UpdateJob(id, dto.UpdateJobRequest{Status: &status})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job)
}
func (h *AdminHandlers) DuplicateJob(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid job ID", http.StatusBadRequest)
return
}
job, err := h.adminService.DuplicateJob(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(job)
}
func (h *AdminHandlers) ListTags(w http.ResponseWriter, r *http.Request) {
category := r.URL.Query().Get("category")
if category == "" {
category = ""
}
var categoryFilter *string
if category != "" {
categoryFilter = &category
}
tags, err := h.adminService.ListTags(r.Context(), categoryFilter)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tags)
}
func (h *AdminHandlers) CreateTag(w http.ResponseWriter, r *http.Request) {
var req CreateTagRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid Request", http.StatusBadRequest)
return
}
if req.Name == "" || req.Category == "" {
http.Error(w, "Name and category are required", http.StatusBadRequest)
return
}
tag, err := h.adminService.CreateTag(r.Context(), req.Name, req.Category)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(tag)
}
func (h *AdminHandlers) UpdateTag(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid tag ID", http.StatusBadRequest)
return
}
var req UpdateTagRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid Request", http.StatusBadRequest)
return
}
tag, err := h.adminService.UpdateTag(r.Context(), id, req.Name, req.Active)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tag)
}

View file

@ -2,13 +2,16 @@ package handlers
import (
"encoding/json"
"net"
"net/http"
"strings"
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
"github.com/rede5/gohorsejobs/backend/internal/services"
)
type CoreHandlers struct {
@ -18,9 +21,10 @@ type CoreHandlers struct {
listUsersUC *user.ListUsersUseCase
deleteUserUC *user.DeleteUserUseCase
listCompaniesUC *tenant.ListCompaniesUseCase
auditService *services.AuditService
}
func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase) *CoreHandlers {
func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService) *CoreHandlers {
return &CoreHandlers{
loginUC: l,
createCompanyUC: c,
@ -28,6 +32,7 @@ func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *us
listUsersUC: list,
deleteUserUC: del,
listCompaniesUC: lc,
auditService: auditService,
}
}
@ -55,6 +60,23 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) {
return
}
if h.auditService != nil {
ipAddress := extractClientIP(r)
userAgent := r.UserAgent()
var userAgentPtr *string
if userAgent != "" {
userAgentPtr = &userAgent
}
_ = h.auditService.RecordLogin(r.Context(), services.LoginAuditInput{
UserID: resp.User.ID,
Identifier: resp.User.Email,
Roles: resp.User.Roles,
IPAddress: ipAddress,
UserAgent: userAgentPtr,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
@ -210,3 +232,30 @@ func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"})
}
func extractClientIP(r *http.Request) *string {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {
ip := strings.TrimSpace(parts[0])
if ip != "" {
return &ip
}
}
}
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return &realIP
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
return &host
}
if r.RemoteAddr != "" {
return &r.RemoteAddr
}
return nil
}

View file

@ -54,6 +54,58 @@ func (m *Middleware) HeaderAuthGuard(next http.Handler) http.Handler {
})
}
// RequireRoles ensures the authenticated user has at least one of the required roles.
func (m *Middleware) RequireRoles(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleValues := extractRoles(r.Context().Value(ContextRoles))
if len(roleValues) == 0 {
http.Error(w, "Roles not found", http.StatusForbidden)
return
}
if hasRole(roleValues, roles) {
next.ServeHTTP(w, r)
return
}
http.Error(w, "Forbidden: insufficient permissions", http.StatusForbidden)
})
}
}
func extractRoles(value interface{}) []string {
switch roles := value.(type) {
case []string:
return roles
case []interface{}:
result := make([]string, 0, len(roles))
for _, role := range roles {
if text, ok := role.(string); ok {
result = append(result, text)
}
}
return result
default:
return []string{}
}
}
func hasRole(userRoles []string, allowedRoles []string) bool {
roleSet := make(map[string]struct{}, len(userRoles))
for _, role := range userRoles {
roleSet[strings.ToLower(role)] = struct{}{}
}
for _, role := range allowedRoles {
if _, ok := roleSet[strings.ToLower(role)]; ok {
return true
}
}
return false
}
// TenantGuard ensures that the request is made by a user belonging to the prompt tenant
// Note: In this architecture, the token *defines* the tenant. So HeaderAuthGuard implicitly guards the tenant.
// This middleware is for extra checks if URL params conflict with Token tenant.

View file

@ -17,7 +17,7 @@ type CreateJobRequest struct {
Benefits map[string]interface{} `json:"benefits,omitempty"`
VisaSupport bool `json:"visaSupport"`
LanguageLevel *string `json:"languageLevel,omitempty"`
Status string `json:"status" validate:"oneof=draft open closed"`
Status string `json:"status" validate:"oneof=draft open closed review published paused expired archived reported"`
}
// UpdateJobRequest represents the request to update a job
@ -36,7 +36,7 @@ type UpdateJobRequest struct {
Benefits map[string]interface{} `json:"benefits,omitempty"`
VisaSupport *bool `json:"visaSupport,omitempty"`
LanguageLevel *string `json:"languageLevel,omitempty"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft open closed review published paused expired archived reported"`
}
// CreateApplicationRequest represents a job application (guest or logged user)

View file

@ -36,7 +36,7 @@ type Job struct {
LanguageLevel *string `json:"languageLevel,omitempty" db:"language_level"` // N5-N1, beginner, none
// Status
Status string `json:"status" db:"status"` // open, closed, draft
Status string `json:"status" db:"status"` // draft, review, published, paused, expired, archived, reported, open, closed
IsFeatured bool `json:"isFeatured" db:"is_featured"` // Featured job flag
// Metadata

View file

@ -0,0 +1,14 @@
package models
import "time"
// LoginAudit captures authentication access history.
type LoginAudit struct {
ID int `json:"id" db:"id"`
UserID string `json:"userId" db:"user_id"`
Identifier string `json:"identifier" db:"identifier"`
Roles string `json:"roles" db:"roles"`
IPAddress *string `json:"ipAddress,omitempty" db:"ip_address"`
UserAgent *string `json:"userAgent,omitempty" db:"user_agent"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}

View file

@ -0,0 +1,13 @@
package models
import "time"
// Tag represents a backoffice-managed job tag/category.
type Tag struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Category string `json:"category" db:"category"`
Active bool `json:"active" db:"active"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}

View file

@ -57,8 +57,11 @@ func NewRouter() http.Handler {
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
// Handlers & Middleware
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC)
auditService := services.NewAuditService(database.DB)
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC, auditService)
authMiddleware := middleware.NewMiddleware(authService)
adminService := services.NewAdminService(database.DB)
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService)
// Initialize Legacy Handlers
jobHandler := handlers.NewJobHandler(jobService)
@ -137,6 +140,19 @@ func NewRouter() http.Handler {
mux.HandleFunc("PUT /api/v1/jobs/{id}", jobHandler.UpdateJob)
mux.HandleFunc("DELETE /api/v1/jobs/{id}", jobHandler.DeleteJob)
// --- ADMIN ROUTES ---
adminOnly := authMiddleware.RequireRoles("ADMIN", "SUPERADMIN", "admin", "superadmin")
mux.Handle("GET /api/v1/admin/access/roles", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListAccessRoles))))
mux.Handle("GET /api/v1/admin/audit/logins", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListLoginAudits))))
mux.Handle("GET /api/v1/admin/companies", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListCompanies))))
mux.Handle("PATCH /api/v1/admin/companies/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateCompanyStatus))))
mux.Handle("GET /api/v1/admin/jobs", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListJobs))))
mux.Handle("PATCH /api/v1/admin/jobs/{id}/status", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateJobStatus))))
mux.Handle("POST /api/v1/admin/jobs/{id}/duplicate", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DuplicateJob))))
mux.Handle("GET /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListTags))))
mux.Handle("POST /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateTag))))
mux.Handle("PATCH /api/v1/admin/tags/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateTag))))
// Application Routes
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications)

View file

@ -0,0 +1,292 @@
package services
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"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) ([]models.Company, error) {
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)
}
baseQuery += " ORDER BY created_at DESC"
rows, err := s.DB.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var 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, err
}
companies = append(companies, c)
}
return companies, nil
}
func (s *AdminService) UpdateCompanyStatus(ctx context.Context, id int, 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 int) (*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()
var 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) getCompanyByID(ctx context.Context, id int) (*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
}

View file

@ -0,0 +1,72 @@
package services
import (
"context"
"database/sql"
"strings"
"github.com/rede5/gohorsejobs/backend/internal/models"
)
type AuditService struct {
DB *sql.DB
}
func NewAuditService(db *sql.DB) *AuditService {
return &AuditService{DB: db}
}
type LoginAuditInput struct {
UserID string
Identifier string
Roles []string
IPAddress *string
UserAgent *string
}
func (s *AuditService) RecordLogin(ctx context.Context, input LoginAuditInput) error {
roles := strings.Join(input.Roles, ",")
query := `
INSERT INTO login_audit (user_id, identifier, roles, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5)
`
_, err := s.DB.ExecContext(ctx, query, input.UserID, input.Identifier, roles, input.IPAddress, input.UserAgent)
return err
}
func (s *AuditService) ListLogins(ctx context.Context, limit int) ([]models.LoginAudit, error) {
if limit <= 0 {
limit = 50
}
query := `
SELECT id, user_id, identifier, roles, ip_address, user_agent, created_at
FROM login_audit
ORDER BY created_at DESC
LIMIT $1
`
rows, err := s.DB.QueryContext(ctx, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var audits []models.LoginAudit
for rows.Next() {
var entry models.LoginAudit
if err := rows.Scan(
&entry.ID,
&entry.UserID,
&entry.Identifier,
&entry.Roles,
&entry.IPAddress,
&entry.UserAgent,
&entry.CreatedAt,
); err != nil {
return nil, err
}
audits = append(audits, entry)
}
return audits, nil
}

View file

@ -94,6 +94,13 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
argId++
}
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++
}
// Add more filters as needed...
// Pagination

View file

@ -0,0 +1,27 @@
-- Migration: Create backoffice support tables
-- Description: Tags for job categorization and login audit logs
CREATE TABLE IF NOT EXISTS job_tags (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
category VARCHAR(30) NOT NULL CHECK (category IN ('area', 'level', 'stack')),
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_job_tags_category ON job_tags(category);
CREATE INDEX IF NOT EXISTS idx_job_tags_active ON job_tags(active);
CREATE TABLE IF NOT EXISTS login_audit (
id SERIAL PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
identifier VARCHAR(255) NOT NULL,
roles TEXT NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_login_audit_user_id ON login_audit(user_id);
CREATE INDEX IF NOT EXISTS idx_login_audit_created_at ON login_audit(created_at DESC);

View file

@ -0,0 +1,18 @@
-- Migration: Update job status workflow
-- Description: Allow extended workflow statuses for moderation and lifecycle
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
ALTER TABLE jobs
ADD CONSTRAINT jobs_status_check
CHECK (status IN (
'open',
'closed',
'draft',
'review',
'published',
'paused',
'expired',
'archived',
'reported'
));

View file

@ -0,0 +1,418 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
adminAccessApi,
adminAuditApi,
adminCompaniesApi,
adminJobsApi,
adminTagsApi,
type AdminCompany,
type AdminJob,
type AdminLoginAudit,
type AdminRoleAccess,
type AdminTag,
} from "@/lib/api"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { toast } from "sonner"
import { Archive, CheckCircle, Copy, PauseCircle, Plus, RefreshCw, XCircle } from "lucide-react"
const jobStatusBadge: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
draft: { label: "Draft", variant: "outline" },
review: { label: "Review", variant: "secondary" },
published: { label: "Published", variant: "default" },
paused: { label: "Paused", variant: "outline" },
expired: { label: "Expired", variant: "destructive" },
archived: { label: "Archived", variant: "outline" },
reported: { label: "Reported", variant: "destructive" },
open: { label: "Open", variant: "default" },
closed: { label: "Closed", variant: "outline" },
}
export default function BackofficePage() {
const router = useRouter()
const [roles, setRoles] = useState<AdminRoleAccess[]>([])
const [audits, setAudits] = useState<AdminLoginAudit[]>([])
const [companies, setCompanies] = useState<AdminCompany[]>([])
const [jobs, setJobs] = useState<AdminJob[]>([])
const [tags, setTags] = useState<AdminTag[]>([])
const [loading, setLoading] = useState(true)
const [creatingTag, setCreatingTag] = useState(false)
const [tagForm, setTagForm] = useState({ name: "", category: "area" as "area" | "level" | "stack" })
useEffect(() => {
const user = getCurrentUser()
if (!isAdminUser(user)) {
router.push("/dashboard")
return
}
loadBackoffice()
}, [router])
const loadBackoffice = async () => {
try {
setLoading(true)
const [rolesData, auditData, companiesData, jobsData, tagsData] = await Promise.all([
adminAccessApi.listRoles(),
adminAuditApi.listLogins(20),
adminCompaniesApi.list(false),
adminJobsApi.list({ status: "review", limit: 10 }),
adminTagsApi.list(),
])
setRoles(rolesData)
setAudits(auditData)
setCompanies(companiesData)
setJobs(jobsData.data || [])
setTags(tagsData)
} catch (error) {
console.error("Error loading backoffice:", error)
toast.error("Failed to load backoffice data")
} finally {
setLoading(false)
}
}
const handleApproveCompany = async (companyId: number) => {
try {
await adminCompaniesApi.updateStatus(companyId, { verified: true })
toast.success("Company approved")
loadBackoffice()
} catch (error) {
console.error("Error approving company:", error)
toast.error("Failed to approve company")
}
}
const handleDeactivateCompany = async (companyId: number) => {
try {
await adminCompaniesApi.updateStatus(companyId, { active: false })
toast.success("Company deactivated")
loadBackoffice()
} catch (error) {
console.error("Error deactivating company:", error)
toast.error("Failed to deactivate company")
}
}
const handleJobStatus = async (jobId: number, status: string) => {
try {
await adminJobsApi.updateStatus(jobId, status)
toast.success("Job status updated")
loadBackoffice()
} catch (error) {
console.error("Error updating job status:", error)
toast.error("Failed to update job status")
}
}
const handleDuplicateJob = async (jobId: number) => {
try {
await adminJobsApi.duplicate(jobId)
toast.success("Job duplicated as draft")
loadBackoffice()
} catch (error) {
console.error("Error duplicating job:", error)
toast.error("Failed to duplicate job")
}
}
const handleCreateTag = async () => {
if (!tagForm.name.trim()) {
toast.error("Tag name is required")
return
}
try {
setCreatingTag(true)
await adminTagsApi.create({ name: tagForm.name.trim(), category: tagForm.category })
toast.success("Tag created")
setTagForm({ name: "", category: "area" })
loadBackoffice()
} catch (error) {
console.error("Error creating tag:", error)
toast.error("Failed to create tag")
} finally {
setCreatingTag(false)
}
}
const handleToggleTag = async (tag: AdminTag) => {
try {
await adminTagsApi.update(tag.id, { active: !tag.active })
toast.success("Tag updated")
loadBackoffice()
} catch (error) {
console.error("Error updating tag:", error)
toast.error("Failed to update tag")
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
return (
<div className="space-y-10">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Backoffice</h1>
<p className="text-muted-foreground mt-1">Controle administrativo do GoHorse Jobs</p>
</div>
<Button variant="outline" onClick={loadBackoffice} className="gap-2">
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Gestão de usuários & acesso</CardTitle>
<CardDescription>Perfis, permissões e ações disponíveis no RBAC.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Perfil</TableHead>
<TableHead>Descrição</TableHead>
<TableHead>Ações principais</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.role}>
<TableCell className="font-medium">{role.role}</TableCell>
<TableCell>{role.description}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{role.actions.map((action) => (
<Badge key={action} variant="secondary">{action}</Badge>
))}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Auditoria de login</CardTitle>
<CardDescription>Histórico recente de acessos ao painel administrativo.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Usuário</TableHead>
<TableHead>Roles</TableHead>
<TableHead>IP</TableHead>
<TableHead>Data</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{audits.map((audit) => (
<TableRow key={audit.id}>
<TableCell className="font-medium">{audit.identifier}</TableCell>
<TableCell>{audit.roles}</TableCell>
<TableCell>{audit.ipAddress || "-"}</TableCell>
<TableCell>{new Date(audit.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Empresas pendentes</CardTitle>
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
Nenhuma empresa pendente.
</TableCell>
</TableRow>
)}
{companies.map((company) => (
<TableRow key={company.id}>
<TableCell className="font-medium">{company.name}</TableCell>
<TableCell>{company.email || "-"}</TableCell>
<TableCell>
{company.verified ? (
<Badge className="bg-green-500">Verificada</Badge>
) : (
<Badge variant="secondary">Pendente</Badge>
)}
</TableCell>
<TableCell className="text-right space-x-2">
<Button size="sm" variant="outline" onClick={() => handleApproveCompany(Number(company.id))}>
<CheckCircle className="h-4 w-4 mr-2" />
Aprovar
</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeactivateCompany(Number(company.id))}>
<XCircle className="h-4 w-4 mr-2" />
Desativar
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Moderação de vagas</CardTitle>
<CardDescription>Fluxo: rascunho revisão publicada expirada/arquivada.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Título</TableHead>
<TableHead>Empresa</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
Nenhuma vaga aguardando revisão.
</TableCell>
</TableRow>
)}
{jobs.map((job) => {
const statusConfig = jobStatusBadge[job.status] || { label: job.status, variant: "outline" }
return (
<TableRow key={job.id}>
<TableCell className="font-medium">{job.title}</TableCell>
<TableCell>{job.companyName || "-"}</TableCell>
<TableCell>
<Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>
</TableCell>
<TableCell className="text-right space-x-2">
<Button size="sm" variant="outline" onClick={() => handleJobStatus(job.id, "published")}>
<CheckCircle className="h-4 w-4 mr-2" />
Publicar
</Button>
<Button size="sm" variant="outline" onClick={() => handleJobStatus(job.id, "paused")}>
<PauseCircle className="h-4 w-4 mr-2" />
Pausar
</Button>
<Button size="sm" variant="outline" onClick={() => handleJobStatus(job.id, "archived")}>
<Archive className="h-4 w-4 mr-2" />
Arquivar
</Button>
<Button size="sm" variant="ghost" onClick={() => handleDuplicateJob(job.id)}>
<Copy className="h-4 w-4 mr-2" />
Duplicar
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tags e categorias</CardTitle>
<CardDescription>Áreas, níveis e stacks customizáveis.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<Input
placeholder="Nova tag"
value={tagForm.name}
onChange={(event) => setTagForm({ ...tagForm, name: event.target.value })}
/>
<Select
value={tagForm.category}
onValueChange={(value: "area" | "level" | "stack") => setTagForm({ ...tagForm, category: value })}
>
<SelectTrigger className="md:w-48">
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="area">Área</SelectItem>
<SelectItem value="level">Nível</SelectItem>
<SelectItem value="stack">Stack</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateTag} disabled={creatingTag} className="gap-2">
<Plus className="h-4 w-4" />
Criar tag
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tag</TableHead>
<TableHead>Categoria</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tags.map((tag) => (
<TableRow key={tag.id}>
<TableCell className="font-medium">{tag.name}</TableCell>
<TableCell>{tag.category}</TableCell>
<TableCell>
{tag.active ? (
<Badge className="bg-green-500">Ativa</Badge>
) : (
<Badge variant="outline">Inativa</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" onClick={() => handleToggleTag(tag)}>
{tag.active ? "Desativar" : "Ativar"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View file

@ -19,7 +19,7 @@ import {
import { Label } from "@/components/ui/label"
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle } from "lucide-react"
import { companiesApi, type ApiCompany } from "@/lib/api"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { toast } from "sonner"
export default function AdminCompaniesPage() {
@ -37,7 +37,7 @@ export default function AdminCompaniesPage() {
useEffect(() => {
const user = getCurrentUser()
if (!user || (!user.roles?.includes("superadmin") && user.role !== "admin")) {
if (!isAdminUser(user)) {
router.push("/dashboard")
return
}

View file

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { AdminDashboardContent } from "@/components/dashboard-contents/admin-dashboard"
import { CompanyDashboardContent } from "@/components/dashboard-contents/company-dashboard"
import { CandidateDashboardContent } from "@/components/dashboard-contents/candidate-dashboard"
@ -29,7 +29,7 @@ export default function DashboardPage() {
if (!user) return null
// Role-based rendering
if (user.role === "admin" || user.roles?.includes("superadmin")) {
if (isAdminUser(user)) {
return <AdminDashboardContent />
}

View file

@ -20,7 +20,7 @@ import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, Search, Trash2, Loader2, RefreshCw } from "lucide-react"
import { usersApi, type ApiUser } from "@/lib/api"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { toast } from "sonner"
export default function AdminUsersPage() {
@ -39,7 +39,7 @@ export default function AdminUsersPage() {
useEffect(() => {
const user = getCurrentUser()
if (!user || (!user.roles?.includes("superadmin") && user.role !== "admin")) {
if (!isAdminUser(user)) {
router.push("/dashboard")
return
}

View file

@ -5,7 +5,7 @@ import Image from "next/image"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText } from "lucide-react"
import { getCurrentUser } from "@/lib/auth"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
const adminItems = [
{
@ -33,6 +33,11 @@ const adminItems = [
href: "/dashboard/companies",
icon: Building2,
},
{
title: "Backoffice",
href: "/dashboard/backoffice",
icon: FileText,
},
{
title: "Messages",
href: "/dashboard/messages",
@ -81,7 +86,7 @@ export function Sidebar() {
const user = getCurrentUser()
let items = candidateItems
if (user?.role === "admin" || user?.roles?.includes("superadmin")) {
if (isAdminUser(user)) {
items = adminItems
} else if (user?.role === "company") {
items = companyItems

View file

@ -147,6 +147,93 @@ export const jobsApi = {
getById: (id: number) => apiRequest<ApiJob>(`/jobs/${id}`),
};
// Admin Backoffice API
export interface AdminRoleAccess {
role: string;
description: string;
actions: string[];
}
export interface AdminLoginAudit {
id: number;
userId: string;
identifier: string;
roles: string;
ipAddress?: string;
userAgent?: string;
createdAt: string;
}
export interface AdminCompany extends ApiCompany {}
export interface AdminJob extends ApiJob {}
export interface AdminTag {
id: number;
name: string;
category: "area" | "level" | "stack";
active: boolean;
createdAt: string;
updatedAt: string;
}
export const adminAccessApi = {
listRoles: () => apiRequest<AdminRoleAccess[]>("/api/v1/admin/access/roles"),
};
export const adminAuditApi = {
listLogins: (limit = 50) => apiRequest<AdminLoginAudit[]>(`/api/v1/admin/audit/logins?limit=${limit}`),
};
export const adminCompaniesApi = {
list: (verified?: boolean) => {
const query = typeof verified === "boolean" ? `?verified=${verified}` : "";
return apiRequest<AdminCompany[]>(`/api/v1/admin/companies${query}`);
},
updateStatus: (id: number, data: { active?: boolean; verified?: boolean }) =>
apiRequest<AdminCompany>(`/api/v1/admin/companies/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
};
export const adminJobsApi = {
list: (params?: { page?: number; limit?: number; status?: string }) => {
const query = new URLSearchParams();
if (params?.page) query.set("page", String(params.page));
if (params?.limit) query.set("limit", String(params.limit));
if (params?.status) query.set("status", params.status);
const queryStr = query.toString();
return apiRequest<PaginatedResponse<AdminJob>>(`/api/v1/admin/jobs${queryStr ? `?${queryStr}` : ""}`);
},
updateStatus: (id: number, status: string) =>
apiRequest<AdminJob>(`/api/v1/admin/jobs/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
}),
duplicate: (id: number) =>
apiRequest<AdminJob>(`/api/v1/admin/jobs/${id}/duplicate`, {
method: "POST",
}),
};
export const adminTagsApi = {
list: (category?: "area" | "level" | "stack") => {
const query = category ? `?category=${category}` : "";
return apiRequest<AdminTag[]>(`/api/v1/admin/tags${query}`);
},
create: (data: { name: string; category: "area" | "level" | "stack" }) =>
apiRequest<AdminTag>("/api/v1/admin/tags", {
method: "POST",
body: JSON.stringify(data),
}),
update: (id: number, data: { name?: string; active?: boolean }) =>
apiRequest<AdminTag>(`/api/v1/admin/tags/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
};
// Transform API job to frontend Job format
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
// Format salary

View file

@ -42,7 +42,7 @@ export async function login(
// Note: The backend returns roles as an array of strings. The frontend expects a single 'role' or we need to adapt.
// For now we map the first role or main role to the 'role' field.
let userRole: "candidate" | "admin" | "company" = "candidate";
if (data.user.roles.includes("superadmin") || data.user.roles.includes("admin")) {
if (data.user.roles.includes("superadmin") || data.user.roles.includes("admin") || data.user.roles.includes("ADMIN") || data.user.roles.includes("SUPERADMIN")) {
userRole = "admin";
} else if (data.user.roles.includes("companyAdmin") || data.user.roles.includes("recruiter")) {
userRole = "company";
@ -86,6 +86,18 @@ export function getCurrentUser(): User | null {
return null;
}
export function isAdminUser(user: User | null): boolean {
if (!user) return false;
const roles = user.roles || [];
return (
user.role === "admin" ||
roles.includes("superadmin") ||
roles.includes("admin") ||
roles.includes("ADMIN") ||
roles.includes("SUPERADMIN")
);
}
export function isAuthenticated(): boolean {
return getCurrentUser() !== null;
}