Add admin backoffice routes and dashboard
This commit is contained in:
parent
6d54fa3367
commit
58cfd76675
20 changed files with 1393 additions and 14 deletions
297
backend/internal/api/handlers/admin_handlers.go
Normal file
297
backend/internal/api/handlers/admin_handlers.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
backend/internal/models/login_audit.go
Normal file
14
backend/internal/models/login_audit.go
Normal 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"`
|
||||
}
|
||||
13
backend/internal/models/tag.go
Normal file
13
backend/internal/models/tag.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
292
backend/internal/services/admin_service.go
Normal file
292
backend/internal/services/admin_service.go
Normal 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
|
||||
}
|
||||
72
backend/internal/services/audit_service.go
Normal file
72
backend/internal/services/audit_service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
27
backend/migrations/013_create_backoffice_tables.sql
Normal file
27
backend/migrations/013_create_backoffice_tables.sql
Normal 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);
|
||||
18
backend/migrations/014_update_job_status_constraint.sql
Normal file
18
backend/migrations/014_update_job_status_constraint.sql
Normal 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'
|
||||
));
|
||||
418
frontend/src/app/dashboard/backoffice/page.tsx
Normal file
418
frontend/src/app/dashboard/backoffice/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue