Merge pull request #19 from rede5/codex/add-backoffice-features-for-gohorse-jobs
Add admin backoffice: routes, audit logging, job workflow and dashboard UI
This commit is contained in:
commit
749a83efa4
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
"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/auth"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
|
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
|
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CoreHandlers struct {
|
type CoreHandlers struct {
|
||||||
|
|
@ -18,9 +21,10 @@ type CoreHandlers struct {
|
||||||
listUsersUC *user.ListUsersUseCase
|
listUsersUC *user.ListUsersUseCase
|
||||||
deleteUserUC *user.DeleteUserUseCase
|
deleteUserUC *user.DeleteUserUseCase
|
||||||
listCompaniesUC *tenant.ListCompaniesUseCase
|
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{
|
return &CoreHandlers{
|
||||||
loginUC: l,
|
loginUC: l,
|
||||||
createCompanyUC: c,
|
createCompanyUC: c,
|
||||||
|
|
@ -28,6 +32,7 @@ func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *us
|
||||||
listUsersUC: list,
|
listUsersUC: list,
|
||||||
deleteUserUC: del,
|
deleteUserUC: del,
|
||||||
listCompaniesUC: lc,
|
listCompaniesUC: lc,
|
||||||
|
auditService: auditService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +60,23 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
@ -210,3 +232,30 @@ func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"})
|
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
|
// 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.
|
// 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.
|
// 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"`
|
Benefits map[string]interface{} `json:"benefits,omitempty"`
|
||||||
VisaSupport bool `json:"visaSupport"`
|
VisaSupport bool `json:"visaSupport"`
|
||||||
LanguageLevel *string `json:"languageLevel,omitempty"`
|
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
|
// UpdateJobRequest represents the request to update a job
|
||||||
|
|
@ -36,7 +36,7 @@ type UpdateJobRequest struct {
|
||||||
Benefits map[string]interface{} `json:"benefits,omitempty"`
|
Benefits map[string]interface{} `json:"benefits,omitempty"`
|
||||||
VisaSupport *bool `json:"visaSupport,omitempty"`
|
VisaSupport *bool `json:"visaSupport,omitempty"`
|
||||||
LanguageLevel *string `json:"languageLevel,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)
|
// 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
|
LanguageLevel *string `json:"languageLevel,omitempty" db:"language_level"` // N5-N1, beginner, none
|
||||||
|
|
||||||
// Status
|
// 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
|
IsFeatured bool `json:"isFeatured" db:"is_featured"` // Featured job flag
|
||||||
|
|
||||||
// Metadata
|
// 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)
|
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
||||||
|
|
||||||
// Handlers & Middleware
|
// 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)
|
authMiddleware := middleware.NewMiddleware(authService)
|
||||||
|
adminService := services.NewAdminService(database.DB)
|
||||||
|
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService)
|
||||||
|
|
||||||
// Initialize Legacy Handlers
|
// Initialize Legacy Handlers
|
||||||
jobHandler := handlers.NewJobHandler(jobService)
|
jobHandler := handlers.NewJobHandler(jobService)
|
||||||
|
|
@ -137,6 +140,19 @@ func NewRouter() http.Handler {
|
||||||
mux.HandleFunc("PUT /api/v1/jobs/{id}", jobHandler.UpdateJob)
|
mux.HandleFunc("PUT /api/v1/jobs/{id}", jobHandler.UpdateJob)
|
||||||
mux.HandleFunc("DELETE /api/v1/jobs/{id}", jobHandler.DeleteJob)
|
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
|
// Application Routes
|
||||||
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
|
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
|
||||||
mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications)
|
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++
|
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...
|
// Add more filters as needed...
|
||||||
|
|
||||||
// Pagination
|
// 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 { Label } from "@/components/ui/label"
|
||||||
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle } from "lucide-react"
|
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle } from "lucide-react"
|
||||||
import { companiesApi, type ApiCompany } from "@/lib/api"
|
import { companiesApi, type ApiCompany } from "@/lib/api"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function AdminCompaniesPage() {
|
export default function AdminCompaniesPage() {
|
||||||
|
|
@ -37,7 +37,7 @@ export default function AdminCompaniesPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const user = getCurrentUser()
|
const user = getCurrentUser()
|
||||||
if (!user || (!user.roles?.includes("superadmin") && user.role !== "admin")) {
|
if (!isAdminUser(user)) {
|
||||||
router.push("/dashboard")
|
router.push("/dashboard")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
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 { AdminDashboardContent } from "@/components/dashboard-contents/admin-dashboard"
|
||||||
import { CompanyDashboardContent } from "@/components/dashboard-contents/company-dashboard"
|
import { CompanyDashboardContent } from "@/components/dashboard-contents/company-dashboard"
|
||||||
import { CandidateDashboardContent } from "@/components/dashboard-contents/candidate-dashboard"
|
import { CandidateDashboardContent } from "@/components/dashboard-contents/candidate-dashboard"
|
||||||
|
|
@ -29,7 +29,7 @@ export default function DashboardPage() {
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
// Role-based rendering
|
// Role-based rendering
|
||||||
if (user.role === "admin" || user.roles?.includes("superadmin")) {
|
if (isAdminUser(user)) {
|
||||||
return <AdminDashboardContent />
|
return <AdminDashboardContent />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Plus, Search, Trash2, Loader2, RefreshCw } from "lucide-react"
|
import { Plus, Search, Trash2, Loader2, RefreshCw } from "lucide-react"
|
||||||
import { usersApi, type ApiUser } from "@/lib/api"
|
import { usersApi, type ApiUser } from "@/lib/api"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
|
|
@ -39,7 +39,7 @@ export default function AdminUsersPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const user = getCurrentUser()
|
const user = getCurrentUser()
|
||||||
if (!user || (!user.roles?.includes("superadmin") && user.role !== "admin")) {
|
if (!isAdminUser(user)) {
|
||||||
router.push("/dashboard")
|
router.push("/dashboard")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import Image from "next/image"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText } from "lucide-react"
|
import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText } from "lucide-react"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||||
|
|
||||||
const adminItems = [
|
const adminItems = [
|
||||||
{
|
{
|
||||||
|
|
@ -33,6 +33,11 @@ const adminItems = [
|
||||||
href: "/dashboard/companies",
|
href: "/dashboard/companies",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Backoffice",
|
||||||
|
href: "/dashboard/backoffice",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Messages",
|
title: "Messages",
|
||||||
href: "/dashboard/messages",
|
href: "/dashboard/messages",
|
||||||
|
|
@ -81,7 +86,7 @@ export function Sidebar() {
|
||||||
const user = getCurrentUser()
|
const user = getCurrentUser()
|
||||||
|
|
||||||
let items = candidateItems
|
let items = candidateItems
|
||||||
if (user?.role === "admin" || user?.roles?.includes("superadmin")) {
|
if (isAdminUser(user)) {
|
||||||
items = adminItems
|
items = adminItems
|
||||||
} else if (user?.role === "company") {
|
} else if (user?.role === "company") {
|
||||||
items = companyItems
|
items = companyItems
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,93 @@ export const jobsApi = {
|
||||||
getById: (id: number) => apiRequest<ApiJob>(`/jobs/${id}`),
|
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
|
// Transform API job to frontend Job format
|
||||||
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
|
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
|
||||||
// Format salary
|
// 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.
|
// 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.
|
// For now we map the first role or main role to the 'role' field.
|
||||||
let userRole: "candidate" | "admin" | "company" = "candidate";
|
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";
|
userRole = "admin";
|
||||||
} else if (data.user.roles.includes("companyAdmin") || data.user.roles.includes("recruiter")) {
|
} else if (data.user.roles.includes("companyAdmin") || data.user.roles.includes("recruiter")) {
|
||||||
userRole = "company";
|
userRole = "company";
|
||||||
|
|
@ -86,6 +86,18 @@ export function getCurrentUser(): User | null {
|
||||||
return 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 {
|
export function isAuthenticated(): boolean {
|
||||||
return getCurrentUser() !== null;
|
return getCurrentUser() !== null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue