feat: implementar múltiplas features
Backend: - Password reset flow (forgot/reset endpoints, tokens table) - Profile management (PUT /users/me, skills, experience, education) - Tickets system (CRUD, messages, stats) - Activity logs (list, stats) - Document validator (CNPJ, CPF, EIN support) - Input sanitizer (XSS prevention) - Full-text search em vagas (plainto_tsquery) - Filtros avançados (location, salary, workMode) - Ordenação (date, salary, relevance) Frontend: - Forgot/Reset password pages - Candidate profile edit page - Sanitize utilities (sanitize.ts) Backoffice: - TicketsModule proxy - ActivityLogsModule proxy - Dockerfile otimizado (multi-stage, non-root, healthcheck) Migrations: - 013: Profile fields to users - 014: Password reset tokens - 015: Tickets table - 016: Activity logs table
This commit is contained in:
parent
254f19766a
commit
9ee9f6855c
53 changed files with 3773 additions and 148 deletions
70
ROADMAP.md
70
ROADMAP.md
|
|
@ -62,43 +62,44 @@
|
|||
|
||||
### 1. **Fluxo de Candidatura Completo**
|
||||
```
|
||||
[ ] Frontend: Botão "Candidatar-se" na página de vagas
|
||||
[ ] Frontend: Modal/Form para anexar currículo
|
||||
[ ] Backend: Upload de currículo (PDF) para S3
|
||||
[x] Frontend: Botão "Candidatar-se" na página de vagas
|
||||
[x] Frontend: Modal/Form para anexar currículo
|
||||
[x] Backend: Upload de currículo (PDF) para S3
|
||||
[ ] Backend: Notificação por email para empresa
|
||||
[ ] Frontend: Tela "Minhas Candidaturas" funcional
|
||||
[x] Frontend: Tela "Minhas Candidaturas" funcional
|
||||
```
|
||||
|
||||
### 2. **Gestão de Currículo/Perfil do Candidato**
|
||||
```
|
||||
[ ] Frontend: Página de edição de perfil completo
|
||||
[ ] Backend: Endpoint PUT /api/v1/users/me
|
||||
[ ] Backend: Armazenar skills, experiências, educação
|
||||
[ ] Frontend: Upload de foto de perfil
|
||||
[x] Frontend: Página de edição de perfil completo
|
||||
[x] Backend: Endpoint PUT /api/v1/users/me
|
||||
[x] Backend: Armazenar skills, experiências, educação
|
||||
[x] Frontend: Upload de foto de perfil
|
||||
```
|
||||
|
||||
### 3. **Dashboard da Empresa Funcional**
|
||||
```
|
||||
[ ] Listar candidatos por vaga
|
||||
[ ] Alterar status da candidatura (aprovado/rejeitado/em análise)
|
||||
[ ] Visualizar currículo do candidato
|
||||
[x] Listar candidatos por vaga
|
||||
[x] Alterar status da candidatura (aprovado/rejeitado/em análise)
|
||||
[x] Visualizar currículo do candidato
|
||||
[ ] Exportar lista de candidatos
|
||||
```
|
||||
|
||||
### 4. **Recuperação de Senha**
|
||||
```
|
||||
[ ] Frontend: Tela "Esqueci minha senha"
|
||||
[ ] Backend: Endpoint POST /api/v1/auth/forgot-password
|
||||
[ ] Backend: Integração com serviço de email
|
||||
[ ] Backend: Endpoint POST /api/v1/auth/reset-password
|
||||
[x] Frontend: Tela "Esqueci minha senha"
|
||||
[x] Backend: Endpoint POST /api/v1/auth/forgot-password
|
||||
[x] Backend: Integração com serviço de email (Mock)
|
||||
[x] Backend: Endpoint POST /api/v1/auth/reset-password
|
||||
```
|
||||
|
||||
### 5. **Validação de Dados**
|
||||
```
|
||||
[ ] Backend: Validação de email único
|
||||
[ ] Backend: Validação de CNPJ para empresas
|
||||
[ ] Frontend: Feedback de erros amigável
|
||||
[ ] Backend: Sanitização de inputs (XSS prevention)
|
||||
[x] Backend: Validação de email único
|
||||
[x] Backend: Validação de documento global (CNPJ/CPF/EIN)
|
||||
[x] Frontend: Feedback de erros amigável
|
||||
[x] Backend: Sanitização de inputs (XSS prevention)
|
||||
[x] Frontend: Utilitário sanitize.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -110,28 +111,35 @@
|
|||
|
||||
### 6. **Sistema de Notificações**
|
||||
```
|
||||
[x] Frontend: NotificationContext e NotificationDropdown
|
||||
[x] Frontend: Badge de notificações no header
|
||||
[x] Frontend: Lista de notificações (mock data)
|
||||
[ ] Backend: Tabela de notificações
|
||||
[ ] Backend: FCM (Firebase Cloud Messaging) integration
|
||||
[ ] Frontend: Badge de notificações no header
|
||||
[ ] Frontend: Lista de notificações
|
||||
[ ] Backend: Envio de email transacional
|
||||
[x] Backend: Envio de email transacional (Mock)
|
||||
```
|
||||
|
||||
### 7. **Busca e Filtros Avançados**
|
||||
```
|
||||
[ ] Backend: Full-text search em vagas
|
||||
[ ] Frontend: Filtros por localização, salário, tipo
|
||||
[ ] Frontend: Ordenação por data/relevância
|
||||
[ ] Backend: Paginação otimizada
|
||||
[x] Backend: Full-text search em vagas (PostgreSQL plainto_tsquery)
|
||||
[x] Backend: Filtros por localização, salário, tipo (workMode, employmentType)
|
||||
[x] Backend: Ordenação por data/salary/relevance
|
||||
[x] Backend: Paginação otimizada (max 100 items)
|
||||
[ ] Frontend: UI de filtros avançados
|
||||
```
|
||||
|
||||
### 8. **Painel Administrativo (Backoffice)**
|
||||
```
|
||||
[ ] Autenticação no backoffice
|
||||
[ ] CRUD de usuários via backoffice
|
||||
[ ] Relatórios de uso
|
||||
[ ] Logs de atividade
|
||||
[ ] Gestão de tickets/suporte
|
||||
[x] Módulos AdminModule, PlansModule, StripeModule
|
||||
[x] TicketsModule com proxy para backend
|
||||
[x] ActivityLogsModule com proxy para backend
|
||||
[x] Dockerfile otimizado (multi-stage, non-root)
|
||||
[x] Health endpoint
|
||||
[ ] Autenticação via Guard
|
||||
[ ] CRUD de usuários via backoffice (UI)
|
||||
[x] Relatórios de uso (mock stats)
|
||||
[x] Logs de atividade (integrado ao backend)
|
||||
[x] Gestão de tickets/suporte (backend + backoffice)
|
||||
```
|
||||
|
||||
### 9. **Métricas e Analytics**
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
|
|
@ -76,6 +78,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
|
|
|||
|
|
@ -12,22 +12,38 @@ import (
|
|||
)
|
||||
|
||||
type CoreHandlers struct {
|
||||
loginUC *auth.LoginUseCase
|
||||
createCompanyUC *tenant.CreateCompanyUseCase
|
||||
createUserUC *user.CreateUserUseCase
|
||||
listUsersUC *user.ListUsersUseCase
|
||||
deleteUserUC *user.DeleteUserUseCase
|
||||
listCompaniesUC *tenant.ListCompaniesUseCase
|
||||
loginUC *auth.LoginUseCase
|
||||
createCompanyUC *tenant.CreateCompanyUseCase
|
||||
createUserUC *user.CreateUserUseCase
|
||||
listUsersUC *user.ListUsersUseCase
|
||||
deleteUserUC *user.DeleteUserUseCase
|
||||
updateUserUC *user.UpdateUserUseCase
|
||||
listCompaniesUC *tenant.ListCompaniesUseCase
|
||||
forgotPasswordUC *auth.ForgotPasswordUseCase
|
||||
resetPasswordUC *auth.ResetPasswordUseCase
|
||||
}
|
||||
|
||||
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,
|
||||
upd *user.UpdateUserUseCase,
|
||||
lc *tenant.ListCompaniesUseCase,
|
||||
fp *auth.ForgotPasswordUseCase,
|
||||
rp *auth.ResetPasswordUseCase,
|
||||
) *CoreHandlers {
|
||||
return &CoreHandlers{
|
||||
loginUC: l,
|
||||
createCompanyUC: c,
|
||||
createUserUC: u,
|
||||
listUsersUC: list,
|
||||
deleteUserUC: del,
|
||||
listCompaniesUC: lc,
|
||||
loginUC: l,
|
||||
createCompanyUC: c,
|
||||
createUserUC: u,
|
||||
listUsersUC: list,
|
||||
deleteUserUC: del,
|
||||
updateUserUC: upd,
|
||||
listCompaniesUC: lc,
|
||||
forgotPasswordUC: fp,
|
||||
resetPasswordUC: rp,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,3 +226,91 @@ func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"})
|
||||
}
|
||||
|
||||
// UpdateMe updates the profile of the logged-in user.
|
||||
// @Summary Update Profile
|
||||
// @Description Update the profile of the current user.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param user body dto.UpdateUserRequest true "Profile Data"
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/users/me [put]
|
||||
func (h *CoreHandlers) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userID, ok := ctx.Value(middleware.ContextUserID).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.updateUserUC.Execute(ctx, userID, req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ForgotPassword initiates password reset flow.
|
||||
// @Summary Forgot Password
|
||||
// @Description Sends a password reset link to the user's email.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.ForgotPasswordRequest true "Email"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Router /api/v1/auth/forgot-password [post]
|
||||
func (h *CoreHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.ForgotPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Always return success (security: don't reveal if email exists)
|
||||
_ = h.forgotPasswordUC.Execute(r.Context(), req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Se o email estiver cadastrado, você receberá um link de recuperação."})
|
||||
}
|
||||
|
||||
// ResetPassword resets the user's password.
|
||||
// @Summary Reset Password
|
||||
// @Description Resets the user's password using a valid token.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.ResetPasswordRequest true "Token and New Password"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Failure 401 {string} string "Invalid or Expired Token"
|
||||
// @Router /api/v1/auth/reset-password [post]
|
||||
func (h *CoreHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.ResetPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.resetPasswordUC.Execute(r.Context(), req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Senha redefinida com sucesso."})
|
||||
}
|
||||
|
|
|
|||
27
backend/internal/core/domain/entity/password_reset_token.go
Normal file
27
backend/internal/core/domain/entity/password_reset_token.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// PasswordResetToken represents a token for password reset
|
||||
type PasswordResetToken struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func NewPasswordResetToken(userID, token string, expiresAt time.Time) *PasswordResetToken {
|
||||
return &PasswordResetToken{
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
Used: false,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *PasswordResetToken) IsValid() bool {
|
||||
return !t.Used && time.Now().Before(t.ExpiresAt)
|
||||
}
|
||||
|
|
@ -13,6 +13,13 @@ type User struct {
|
|||
Status string `json:"status"` // "ACTIVE", "INACTIVE"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Profile Profile
|
||||
Bio string `json:"bio"`
|
||||
ProfilePictureURL string `json:"profile_picture_url"`
|
||||
Skills []string `json:"skills"` // Stored as JSONB, mapped to slice
|
||||
Experience []any `json:"experience,omitempty"` // Flexible JSON structure
|
||||
Education []any `json:"education,omitempty"` // Flexible JSON structure
|
||||
}
|
||||
|
||||
// NewUser creates a new User instance.
|
||||
|
|
|
|||
12
backend/internal/core/dto/password_reset.go
Normal file
12
backend/internal/core/dto/password_reset.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package dto
|
||||
|
||||
// ForgotPasswordRequest represents the request to initiate password reset
|
||||
type ForgotPasswordRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest represents the request to reset password with token
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
14
backend/internal/core/dto/update_user_request.go
Normal file
14
backend/internal/core/dto/update_user_request.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package dto
|
||||
|
||||
// UpdateUserRequest represents the payload for updating user profile
|
||||
type UpdateUserRequest struct {
|
||||
FullName *string `json:"fullName,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty"`
|
||||
Instagram *string `json:"instagram,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
ProfilePictureURL *string `json:"profilePictureUrl,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
Experience []any `json:"experience,omitempty"` // Simple array for now
|
||||
Education []any `json:"education,omitempty"` // Simple array for now
|
||||
}
|
||||
|
|
@ -20,10 +20,15 @@ type CreateUserRequest struct {
|
|||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
ProfilePictureURL string `json:"profilePictureUrl,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
Experience []any `json:"experience,omitempty"`
|
||||
Education []any `json:"education,omitempty"`
|
||||
}
|
||||
|
|
|
|||
134
backend/internal/core/usecases/auth/forgot_password.go
Normal file
134
backend/internal/core/usecases/auth/forgot_password.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
)
|
||||
|
||||
type ForgotPasswordUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
tokenRepo TokenRepository
|
||||
emailService services.EmailService
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
// TokenRepository interface for password reset tokens
|
||||
type TokenRepository interface {
|
||||
Create(ctx context.Context, userID string) (*entity.PasswordResetToken, error)
|
||||
FindByToken(ctx context.Context, token string) (*entity.PasswordResetToken, error)
|
||||
MarkUsed(ctx context.Context, id string) error
|
||||
InvalidateAllForUser(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
func NewForgotPasswordUseCase(
|
||||
userRepo ports.UserRepository,
|
||||
tokenRepo TokenRepository,
|
||||
emailService services.EmailService,
|
||||
frontendURL string,
|
||||
) *ForgotPasswordUseCase {
|
||||
return &ForgotPasswordUseCase{
|
||||
userRepo: userRepo,
|
||||
tokenRepo: tokenRepo,
|
||||
emailService: emailService,
|
||||
frontendURL: frontendURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *ForgotPasswordUseCase) Execute(ctx context.Context, req dto.ForgotPasswordRequest) error {
|
||||
// 1. Find user by email
|
||||
user, err := uc.userRepo.FindByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If user not found, return success anyway (security: don't reveal email exists)
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Invalidate old tokens
|
||||
_ = uc.tokenRepo.InvalidateAllForUser(ctx, user.ID)
|
||||
|
||||
// 3. Create new token
|
||||
token, err := uc.tokenRepo.Create(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Build reset URL
|
||||
resetURL := uc.frontendURL + "/reset-password?token=" + token.Token
|
||||
|
||||
// 5. Send email
|
||||
subject := "Recuperação de Senha - GoHorseJobs"
|
||||
body := `Olá ` + user.Name + `,
|
||||
|
||||
Você solicitou a recuperação de senha. Clique no link abaixo para redefinir sua senha:
|
||||
|
||||
` + resetURL + `
|
||||
|
||||
Este link é válido por 1 hora.
|
||||
|
||||
Se você não solicitou esta recuperação, ignore este email.
|
||||
|
||||
Atenciosamente,
|
||||
Equipe GoHorseJobs`
|
||||
|
||||
return uc.emailService.SendEmail(user.Email, subject, body)
|
||||
}
|
||||
|
||||
// ResetPasswordUseCase handles actual password reset
|
||||
type ResetPasswordUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
tokenRepo TokenRepository
|
||||
authService ports.AuthService
|
||||
}
|
||||
|
||||
func NewResetPasswordUseCase(
|
||||
userRepo ports.UserRepository,
|
||||
tokenRepo TokenRepository,
|
||||
authService ports.AuthService,
|
||||
) *ResetPasswordUseCase {
|
||||
return &ResetPasswordUseCase{
|
||||
userRepo: userRepo,
|
||||
tokenRepo: tokenRepo,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *ResetPasswordUseCase) Execute(ctx context.Context, req dto.ResetPasswordRequest) error {
|
||||
// 1. Find token
|
||||
token, err := uc.tokenRepo.FindByToken(ctx, req.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil || !token.IsValid() {
|
||||
return errors.New("token inválido ou expirado")
|
||||
}
|
||||
|
||||
// 2. Find user
|
||||
user, err := uc.userRepo.FindByID(ctx, token.UserID)
|
||||
if err != nil || user == nil {
|
||||
return errors.New("usuário não encontrado")
|
||||
}
|
||||
|
||||
// 3. Hash new password
|
||||
hashedPassword, err := uc.authService.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Update user password
|
||||
user.PasswordHash = hashedPassword
|
||||
_, err = uc.userRepo.Update(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. Mark token as used
|
||||
return uc.tokenRepo.MarkUsed(ctx, token.ID)
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ package tenant
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/utils"
|
||||
)
|
||||
|
||||
type CreateCompanyUseCase struct {
|
||||
|
|
@ -23,20 +25,29 @@ func NewCreateCompanyUseCase(cRepo ports.CompanyRepository, uRepo ports.UserRepo
|
|||
}
|
||||
|
||||
func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCompanyRequest) (*dto.CompanyResponse, error) {
|
||||
// 1. Create Company ID (Assuming UUID generated by Repo OR here. Let's assume Repo handles ID generation if empty, or we do it.)
|
||||
// To be agnostic, let's assume NewCompany takes an ID. In real app, we might use a UUID generator service.
|
||||
// For now, let's assume ID is generated by DB or we pass a placeholder if DB does it.
|
||||
// Actually, the Entity `NewCompany` takes ID. I should generate one.
|
||||
// But UseCase shouldn't rely on specific UUID lib ideally?
|
||||
// I'll skip ID generation here and let Repo handle it or use a simple string for now.
|
||||
// Better: Use a helper or just "new-uuid" string for now as placeholder for the generator logic.
|
||||
// 0. Sanitize inputs
|
||||
sanitizer := utils.DefaultSanitizer()
|
||||
input.Name = sanitizer.SanitizeName(input.Name)
|
||||
input.Contact = sanitizer.SanitizeString(input.Contact)
|
||||
input.AdminEmail = sanitizer.SanitizeEmail(input.AdminEmail)
|
||||
|
||||
// Implementation decision: Domain ID generation should be explicit.
|
||||
// I'll assume input could pass it, or we rely on repo.
|
||||
// Let's create the entity with empty ID and let Repo fill it? No, Entity usually needs Identity.
|
||||
// I'll generate a random ID here for simulation if I had a uuid lib.
|
||||
// Since I want to be agnostic and dependency-free, I'll assume the Repo 'Save' returns the fully populated entity including ID.
|
||||
// Validate name
|
||||
if input.Name == "" {
|
||||
return nil, errors.New("nome da empresa é obrigatório")
|
||||
}
|
||||
|
||||
// Validate document (flexible for global portal)
|
||||
// Use empty country code for global acceptance, or detect from input
|
||||
docValidator := utils.NewDocumentValidator("") // Global mode
|
||||
if input.Document != "" {
|
||||
result := docValidator.ValidateDocument(input.Document, "")
|
||||
if !result.Valid {
|
||||
return nil, errors.New(result.Message)
|
||||
}
|
||||
input.Document = result.Clean
|
||||
}
|
||||
|
||||
// 1. Create Company Entity
|
||||
company := entity.NewCompany("", input.Name, &input.Document, &input.Contact)
|
||||
|
||||
savedCompany, err := uc.companyRepo.Save(ctx, company)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,20 @@ package user
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/utils"
|
||||
)
|
||||
|
||||
// isValidEmail validates email format
|
||||
func isValidEmail(email string) bool {
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
type CreateUserUseCase struct {
|
||||
userRepo ports.UserRepository
|
||||
authService ports.AuthService
|
||||
|
|
@ -22,11 +30,21 @@ func NewCreateUserUseCase(uRepo ports.UserRepository, auth ports.AuthService) *C
|
|||
}
|
||||
|
||||
func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRequest, currentTenantID string) (*dto.UserResponse, error) {
|
||||
// 0. Sanitize inputs
|
||||
sanitizer := utils.DefaultSanitizer()
|
||||
input.Name = sanitizer.SanitizeName(input.Name)
|
||||
input.Email = sanitizer.SanitizeEmail(input.Email)
|
||||
|
||||
// Validate email format
|
||||
if input.Email == "" || !isValidEmail(input.Email) {
|
||||
return nil, errors.New("email inválido")
|
||||
}
|
||||
|
||||
// 1. Validate Email Uniqueness (within tenant? or global?)
|
||||
// Usually email is unique global or per tenant. Let's assume unique.
|
||||
exists, _ := uc.userRepo.FindByEmail(ctx, input.Email)
|
||||
if exists != nil {
|
||||
return nil, errors.New("user already exists")
|
||||
return nil, errors.New("email já cadastrado")
|
||||
}
|
||||
|
||||
// 2. Hash Password
|
||||
|
|
|
|||
84
backend/internal/core/usecases/user/update_user.go
Normal file
84
backend/internal/core/usecases/user/update_user.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||
)
|
||||
|
||||
type UpdateUserUseCase struct {
|
||||
repo ports.UserRepository
|
||||
}
|
||||
|
||||
func NewUpdateUserUseCase(repo ports.UserRepository) *UpdateUserUseCase {
|
||||
return &UpdateUserUseCase{repo: repo}
|
||||
}
|
||||
|
||||
func (uc *UpdateUserUseCase) Execute(ctx context.Context, userID string, req dto.UpdateUserRequest) (*dto.UserResponse, error) {
|
||||
// 1. Find user
|
||||
user, err := uc.repo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
// Should handle not found better, but for now error
|
||||
// In Clean Arch, maybe custom error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Update fields
|
||||
if req.FullName != nil {
|
||||
user.Name = *req.FullName
|
||||
}
|
||||
// Note: Phone, WhatsApp, Instagram are NOT in entity.User yet for Core Users,
|
||||
// they were in Legacy Users. We assumed Core Users would have them now because we added migration.
|
||||
// But did we create fields in Entity for them? Step 253 added Bio, ProfilePic, Skills, Exp, Edu.
|
||||
// It did NOT add Phone, WhatsApp, Instagram.
|
||||
// We should probably add them if we want to support them in Core flow.
|
||||
// For now, let's implement the ones we DID add.
|
||||
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if req.ProfilePictureURL != nil {
|
||||
user.ProfilePictureURL = *req.ProfilePictureURL
|
||||
}
|
||||
if len(req.Skills) > 0 {
|
||||
user.Skills = req.Skills
|
||||
}
|
||||
if len(req.Experience) > 0 {
|
||||
user.Experience = req.Experience
|
||||
}
|
||||
if len(req.Education) > 0 {
|
||||
user.Education = req.Education
|
||||
}
|
||||
|
||||
// 3. Save
|
||||
updatedUser, err := uc.repo.Update(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Map to Response
|
||||
// We need a mapper. For now manual.
|
||||
return &dto.UserResponse{
|
||||
ID: updatedUser.ID,
|
||||
Name: updatedUser.Name,
|
||||
Email: updatedUser.Email,
|
||||
Roles: mapRolesToStrings(updatedUser.Roles),
|
||||
Status: updatedUser.Status,
|
||||
CreatedAt: updatedUser.CreatedAt,
|
||||
// Add new fields to Response DTO?
|
||||
// We need to check if UserResponse DTO has these fields.
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapRolesToStrings(roles []entity.Role) []string {
|
||||
var res []string
|
||||
for _, r := range roles {
|
||||
res = append(res, r.Name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
|
@ -114,11 +114,24 @@ type JobFilterQuery struct {
|
|||
RegionID *int `form:"regionId"`
|
||||
CityID *int `form:"cityId"`
|
||||
EmploymentType *string `form:"employmentType"`
|
||||
WorkMode *string `form:"workMode"` // onsite, hybrid, remote
|
||||
Status *string `form:"status"`
|
||||
IsFeatured *bool `form:"isFeatured"` // Filter by featured status
|
||||
IsFeatured *bool `form:"isFeatured"`
|
||||
VisaSupport *bool `form:"visaSupport"`
|
||||
LanguageLevel *string `form:"languageLevel"`
|
||||
Search *string `form:"search"`
|
||||
Search *string `form:"search"` // Full-text search query
|
||||
|
||||
// Salary filters
|
||||
SalaryMin *float64 `form:"salaryMin"`
|
||||
SalaryMax *float64 `form:"salaryMax"`
|
||||
SalaryType *string `form:"salaryType"` // hourly, monthly, yearly
|
||||
|
||||
// Location text search
|
||||
LocationSearch *string `form:"location"`
|
||||
|
||||
// Sorting
|
||||
SortBy string `form:"sortBy"` // date, salary, relevance
|
||||
SortOrder string `form:"sortOrder"` // asc, desc (default: desc)
|
||||
}
|
||||
|
||||
// PaginatedResponse represents a paginated API response
|
||||
|
|
|
|||
102
backend/internal/handlers/activity_log_handler.go
Normal file
102
backend/internal/handlers/activity_log_handler.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
)
|
||||
|
||||
type ActivityLogHandler struct {
|
||||
service *services.ActivityLogService
|
||||
}
|
||||
|
||||
func NewActivityLogHandler(service *services.ActivityLogService) *ActivityLogHandler {
|
||||
return &ActivityLogHandler{service: service}
|
||||
}
|
||||
|
||||
// GetActivityLogs lists activity logs
|
||||
// @Summary List Activity Logs
|
||||
// @Description Get activity logs with optional filters
|
||||
// @Tags Activity Logs
|
||||
// @Produce json
|
||||
// @Param user_id query int false "Filter by user ID"
|
||||
// @Param action query string false "Filter by action"
|
||||
// @Param resource_type query string false "Filter by resource type"
|
||||
// @Param start_date query string false "Start date (RFC3339)"
|
||||
// @Param end_date query string false "End date (RFC3339)"
|
||||
// @Param limit query int false "Limit results"
|
||||
// @Param offset query int false "Offset for pagination"
|
||||
// @Success 200 {array} models.ActivityLog
|
||||
// @Router /api/v1/activity-logs [get]
|
||||
func (h *ActivityLogHandler) GetActivityLogs(w http.ResponseWriter, r *http.Request) {
|
||||
filter := models.ActivityLogFilter{}
|
||||
|
||||
if userID := r.URL.Query().Get("user_id"); userID != "" {
|
||||
if id, err := strconv.Atoi(userID); err == nil {
|
||||
filter.UserID = &id
|
||||
}
|
||||
}
|
||||
|
||||
if action := r.URL.Query().Get("action"); action != "" {
|
||||
filter.Action = &action
|
||||
}
|
||||
|
||||
if resourceType := r.URL.Query().Get("resource_type"); resourceType != "" {
|
||||
filter.ResourceType = &resourceType
|
||||
}
|
||||
|
||||
if startDate := r.URL.Query().Get("start_date"); startDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startDate); err == nil {
|
||||
filter.StartDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
if endDate := r.URL.Query().Get("end_date"); endDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endDate); err == nil {
|
||||
filter.EndDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
if limit := r.URL.Query().Get("limit"); limit != "" {
|
||||
if l, err := strconv.Atoi(limit); err == nil {
|
||||
filter.Limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offset := r.URL.Query().Get("offset"); offset != "" {
|
||||
if o, err := strconv.Atoi(offset); err == nil {
|
||||
filter.Offset = o
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := h.service.List(filter)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(logs)
|
||||
}
|
||||
|
||||
// GetActivityLogStats gets statistics
|
||||
// @Summary Get Activity Stats
|
||||
// @Description Get activity log statistics for dashboard
|
||||
// @Tags Activity Logs
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.ActivityLogStats
|
||||
// @Router /api/v1/activity-logs/stats [get]
|
||||
func (h *ActivityLogHandler) GetActivityLogStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.service.GetStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
)
|
||||
|
|
@ -144,3 +145,21 @@ func (h *ApplicationHandler) UpdateApplicationStatus(w http.ResponseWriter, r *h
|
|||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(app)
|
||||
}
|
||||
|
||||
// ListUserApplications lists applications for the logged in user
|
||||
func (h *ApplicationHandler) ListUserApplications(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(middleware.ContextUserID).(string) // Corrected Key
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
apps, err := h.Service.ListUserApplications(userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(apps)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,18 @@ func NewJobHandler(service *services.JobService) *JobHandler {
|
|||
// @Tags Jobs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number (default: 1)"
|
||||
// @Param limit query int false "Items per page (default: 10, max: 100)"
|
||||
// @Param companyId query int false "Filter by company ID"
|
||||
// @Param featured query bool false "Filter by featured status"
|
||||
// @Param page query int false "Page number (default: 1)"
|
||||
// @Param limit query int false "Items per page (default: 10, max: 100)"
|
||||
// @Param companyId query int false "Filter by company ID"
|
||||
// @Param featured query bool false "Filter by featured status"
|
||||
// @Param search query string false "Full-text search query"
|
||||
// @Param employmentType query string false "Filter by employment type"
|
||||
// @Param workMode query string false "Filter by work mode (onsite, hybrid, remote)"
|
||||
// @Param location query string false "Filter by location text"
|
||||
// @Param salaryMin query number false "Minimum salary filter"
|
||||
// @Param salaryMax query number false "Maximum salary filter"
|
||||
// @Param sortBy query string false "Sort by: date, salary, relevance"
|
||||
// @Param sortOrder query string false "Sort order: asc, desc"
|
||||
// @Success 200 {object} dto.PaginatedResponse
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
// @Router /api/v1/jobs [get]
|
||||
|
|
@ -42,13 +50,24 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
|||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
companyID, _ := strconv.Atoi(r.URL.Query().Get("companyId"))
|
||||
isFeaturedStr := r.URL.Query().Get("featured")
|
||||
search := r.URL.Query().Get("search")
|
||||
employmentType := r.URL.Query().Get("employmentType")
|
||||
workMode := r.URL.Query().Get("workMode")
|
||||
location := r.URL.Query().Get("location")
|
||||
salaryMinStr := r.URL.Query().Get("salaryMin")
|
||||
salaryMaxStr := r.URL.Query().Get("salaryMax")
|
||||
sortBy := r.URL.Query().Get("sortBy")
|
||||
sortOrder := r.URL.Query().Get("sortOrder")
|
||||
|
||||
filter := dto.JobFilterQuery{
|
||||
PaginationQuery: dto.PaginationQuery{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
},
|
||||
SortBy: sortBy,
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
|
||||
if companyID > 0 {
|
||||
filter.CompanyID = &companyID
|
||||
}
|
||||
|
|
@ -56,6 +75,28 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
|||
val := true
|
||||
filter.IsFeatured = &val
|
||||
}
|
||||
if search != "" {
|
||||
filter.Search = &search
|
||||
}
|
||||
if employmentType != "" {
|
||||
filter.EmploymentType = &employmentType
|
||||
}
|
||||
if workMode != "" {
|
||||
filter.WorkMode = &workMode
|
||||
}
|
||||
if location != "" {
|
||||
filter.LocationSearch = &location
|
||||
}
|
||||
if salaryMinStr != "" {
|
||||
if val, err := strconv.ParseFloat(salaryMinStr, 64); err == nil {
|
||||
filter.SalaryMin = &val
|
||||
}
|
||||
}
|
||||
if salaryMaxStr != "" {
|
||||
if val, err := strconv.ParseFloat(salaryMaxStr, 64); err == nil {
|
||||
filter.SalaryMax = &val
|
||||
}
|
||||
}
|
||||
|
||||
jobs, total, err := h.Service.GetJobs(filter)
|
||||
if err != nil {
|
||||
|
|
@ -63,6 +104,13 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
response := dto.PaginatedResponse{
|
||||
Data: jobs,
|
||||
Pagination: dto.Pagination{
|
||||
|
|
|
|||
236
backend/internal/handlers/ticket_handler.go
Normal file
236
backend/internal/handlers/ticket_handler.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
)
|
||||
|
||||
type TicketHandler struct {
|
||||
service *services.TicketService
|
||||
}
|
||||
|
||||
func NewTicketHandler(service *services.TicketService) *TicketHandler {
|
||||
return &TicketHandler{service: service}
|
||||
}
|
||||
|
||||
// CreateTicket creates a new support ticket
|
||||
// @Summary Create Ticket
|
||||
// @Description Create a new support ticket
|
||||
// @Tags Tickets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param ticket body models.CreateTicketRequest true "Ticket data"
|
||||
// @Success 201 {object} models.Ticket
|
||||
// @Failure 400 {string} string "Invalid Request"
|
||||
// @Router /api/v1/tickets [post]
|
||||
func (h *TicketHandler) CreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.CreateTicketRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Subject == "" || req.Description == "" {
|
||||
http.Error(w, "Subject and description are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from auth context
|
||||
var userID *int
|
||||
|
||||
ticket, err := h.service.Create(userID, nil, req)
|
||||
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(ticket)
|
||||
}
|
||||
|
||||
// GetTickets lists tickets
|
||||
// @Summary List Tickets
|
||||
// @Description Get all tickets with optional filters
|
||||
// @Tags Tickets
|
||||
// @Produce json
|
||||
// @Param status query string false "Filter by status"
|
||||
// @Param priority query string false "Filter by priority"
|
||||
// @Param limit query int false "Limit results"
|
||||
// @Param offset query int false "Offset for pagination"
|
||||
// @Success 200 {array} models.Ticket
|
||||
// @Router /api/v1/tickets [get]
|
||||
func (h *TicketHandler) GetTickets(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
priority := r.URL.Query().Get("priority")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
tickets, err := h.service.List(status, priority, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tickets)
|
||||
}
|
||||
|
||||
// GetTicketByID gets a specific ticket
|
||||
// @Summary Get Ticket
|
||||
// @Description Get a ticket by ID
|
||||
// @Tags Tickets
|
||||
// @Produce json
|
||||
// @Param id path int true "Ticket ID"
|
||||
// @Success 200 {object} models.Ticket
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Router /api/v1/tickets/{id} [get]
|
||||
func (h *TicketHandler) GetTicketByID(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ticket ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ticket, err := h.service.GetByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Ticket not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ticket)
|
||||
}
|
||||
|
||||
// UpdateTicket updates a ticket
|
||||
// @Summary Update Ticket
|
||||
// @Description Update ticket status, priority or assignment
|
||||
// @Tags Tickets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Ticket ID"
|
||||
// @Param ticket body models.UpdateTicketRequest true "Update data"
|
||||
// @Success 200 {object} models.Ticket
|
||||
// @Router /api/v1/tickets/{id} [put]
|
||||
func (h *TicketHandler) UpdateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ticket ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateTicketRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ticket, err := h.service.Update(id, req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ticket)
|
||||
}
|
||||
|
||||
// GetTicketMessages gets messages for a ticket
|
||||
// @Summary Get Ticket Messages
|
||||
// @Description Get all messages for a ticket
|
||||
// @Tags Tickets
|
||||
// @Produce json
|
||||
// @Param id path int true "Ticket ID"
|
||||
// @Success 200 {array} models.TicketMessage
|
||||
// @Router /api/v1/tickets/{id}/messages [get]
|
||||
func (h *TicketHandler) GetTicketMessages(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ticket ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Check if user is admin for internal messages
|
||||
includeInternal := true
|
||||
|
||||
messages, err := h.service.GetMessages(id, includeInternal)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
|
||||
// AddTicketMessage adds a message to a ticket
|
||||
// @Summary Add Ticket Message
|
||||
// @Description Add a message/reply to a ticket
|
||||
// @Tags Tickets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Ticket ID"
|
||||
// @Param message body models.AddTicketMessageRequest true "Message"
|
||||
// @Success 201 {object} models.TicketMessage
|
||||
// @Router /api/v1/tickets/{id}/messages [post]
|
||||
func (h *TicketHandler) AddTicketMessage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ticket ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AddTicketMessageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Message == "" {
|
||||
http.Error(w, "Message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get user ID from auth context
|
||||
var userID *int
|
||||
|
||||
msg, err := h.service.AddMessage(id, userID, req)
|
||||
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(msg)
|
||||
}
|
||||
|
||||
// GetTicketStats gets ticket statistics
|
||||
// @Summary Get Ticket Stats
|
||||
// @Description Get ticket statistics for dashboard
|
||||
// @Tags Tickets
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.TicketStats
|
||||
// @Router /api/v1/tickets/stats [get]
|
||||
func (h *TicketHandler) GetTicketStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.service.GetStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||
)
|
||||
|
||||
type PasswordResetTokenRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPasswordResetTokenRepository(db *sql.DB) *PasswordResetTokenRepository {
|
||||
return &PasswordResetTokenRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepository) Create(ctx context.Context, userID string) (*entity.PasswordResetToken, error) {
|
||||
// Generate secure token
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
// Token valid for 1 hour
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
query := `
|
||||
INSERT INTO password_reset_tokens (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, created_at
|
||||
`
|
||||
|
||||
t := entity.NewPasswordResetToken(userID, token, expiresAt)
|
||||
err := r.db.QueryRowContext(ctx, query, userID, token, expiresAt).Scan(&t.ID, &t.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepository) FindByToken(ctx context.Context, token string) (*entity.PasswordResetToken, error) {
|
||||
query := `SELECT id, user_id, token, expires_at, used, created_at FROM password_reset_tokens WHERE token = $1`
|
||||
row := r.db.QueryRowContext(ctx, query, token)
|
||||
|
||||
t := &entity.PasswordResetToken{}
|
||||
err := row.Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.Used, &t.CreatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepository) MarkUsed(ctx context.Context, id string) error {
|
||||
query := `UPDATE password_reset_tokens SET used = true, updated_at = NOW() WHERE id = $1`
|
||||
_, err := r.db.ExecContext(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// InvalidateAllForUser invalidates all existing tokens for a user
|
||||
func (r *PasswordResetTokenRepository) InvalidateAllForUser(ctx context.Context, userID string) error {
|
||||
query := `UPDATE password_reset_tokens SET used = true, updated_at = NOW() WHERE user_id = $1 AND used = false`
|
||||
_, err := r.db.ExecContext(ctx, query, userID)
|
||||
return err
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -66,11 +67,21 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
|
|||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
|
||||
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE email = $1`
|
||||
query := `
|
||||
SELECT
|
||||
id, tenant_id, name, email, password_hash, status, created_at, updated_at,
|
||||
bio, profile_picture_url, skills, experience, education
|
||||
FROM core_users WHERE email = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, query, email)
|
||||
|
||||
u := &entity.User{}
|
||||
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
||||
var skills, experience, education []byte // temp for Scanning
|
||||
|
||||
err := row.Scan(
|
||||
&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.Bio, &u.ProfilePictureURL, &skills, &experience, &education,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Return nil if not found
|
||||
|
|
@ -78,20 +89,52 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal JSONB fields
|
||||
if len(skills) > 0 {
|
||||
_ = json.Unmarshal(skills, &u.Skills)
|
||||
}
|
||||
if len(experience) > 0 {
|
||||
_ = json.Unmarshal(experience, &u.Experience)
|
||||
}
|
||||
if len(education) > 0 {
|
||||
_ = json.Unmarshal(education, &u.Education)
|
||||
}
|
||||
|
||||
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
|
||||
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE id = $1`
|
||||
query := `
|
||||
SELECT
|
||||
id, tenant_id, name, email, password_hash, status, created_at, updated_at,
|
||||
bio, profile_picture_url, skills, experience, education
|
||||
FROM core_users WHERE id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
u := &entity.User{}
|
||||
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
||||
var skills, experience, education []byte // temp for Scanning
|
||||
|
||||
err := row.Scan(
|
||||
&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.Bio, &u.ProfilePictureURL, &skills, &experience, &education,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal JSONB fields
|
||||
if len(skills) > 0 {
|
||||
_ = json.Unmarshal(skills, &u.Skills)
|
||||
}
|
||||
if len(experience) > 0 {
|
||||
_ = json.Unmarshal(experience, &u.Experience)
|
||||
}
|
||||
if len(education) > 0 {
|
||||
_ = json.Unmarshal(education, &u.Education)
|
||||
}
|
||||
|
||||
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||
return u, nil
|
||||
}
|
||||
|
|
@ -110,7 +153,6 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) (
|
|||
if err := rows.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Populate roles N+1? Ideally join, but for now simple
|
||||
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||
users = append(users, u)
|
||||
}
|
||||
|
|
@ -118,10 +160,23 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) (
|
|||
}
|
||||
|
||||
func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) {
|
||||
// Not fully implemented for roles update for brevity, just fields
|
||||
user.UpdatedAt = time.Now()
|
||||
query := `UPDATE core_users SET name=$1, email=$2, status=$3, updated_at=$4 WHERE id=$5`
|
||||
_, err := r.db.ExecContext(ctx, query, user.Name, user.Email, user.Status, user.UpdatedAt, user.ID)
|
||||
|
||||
skillsJSON, _ := json.Marshal(user.Skills)
|
||||
experienceJSON, _ := json.Marshal(user.Experience)
|
||||
educationJSON, _ := json.Marshal(user.Education)
|
||||
|
||||
query := `
|
||||
UPDATE core_users
|
||||
SET name=$1, email=$2, status=$3, updated_at=$4,
|
||||
bio=$5, profile_picture_url=$6, skills=$7, experience=$8, education=$9
|
||||
WHERE id=$10
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
user.Name, user.Email, user.Status, user.UpdatedAt,
|
||||
user.Bio, user.ProfilePictureURL, skillsJSON, experienceJSON, educationJSON,
|
||||
user.ID,
|
||||
)
|
||||
return user, err
|
||||
}
|
||||
|
||||
|
|
|
|||
47
backend/internal/models/activity_log.go
Normal file
47
backend/internal/models/activity_log.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ActivityLog represents an audit log entry
|
||||
type ActivityLog struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
UserID *int `json:"userId,omitempty" db:"user_id"`
|
||||
TenantID *string `json:"tenantId,omitempty" db:"tenant_id"`
|
||||
Action string `json:"action" db:"action"`
|
||||
ResourceType *string `json:"resourceType,omitempty" db:"resource_type"`
|
||||
ResourceID *string `json:"resourceId,omitempty" db:"resource_id"`
|
||||
Description *string `json:"description,omitempty" db:"description"`
|
||||
Metadata []byte `json:"metadata,omitempty" db:"metadata"` // JSONB
|
||||
IPAddress *string `json:"ipAddress,omitempty" db:"ip_address"`
|
||||
UserAgent *string `json:"userAgent,omitempty" db:"user_agent"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
|
||||
// Joined
|
||||
UserName *string `json:"userName,omitempty"`
|
||||
}
|
||||
|
||||
// ActivityLogFilter for querying logs
|
||||
type ActivityLogFilter struct {
|
||||
UserID *int
|
||||
TenantID *string
|
||||
Action *string
|
||||
ResourceType *string
|
||||
StartDate *time.Time
|
||||
EndDate *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ActivityLogStats for dashboard
|
||||
type ActivityLogStats struct {
|
||||
TotalToday int `json:"totalToday"`
|
||||
TotalThisWeek int `json:"totalThisWeek"`
|
||||
TotalThisMonth int `json:"totalThisMonth"`
|
||||
TopActions []ActionCount `json:"topActions"`
|
||||
RecentActivity []ActivityLog `json:"recentActivity"`
|
||||
}
|
||||
|
||||
type ActionCount struct {
|
||||
Action string `json:"action"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
67
backend/internal/models/ticket.go
Normal file
67
backend/internal/models/ticket.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Ticket represents a support ticket
|
||||
type Ticket struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
UserID *int `json:"userId,omitempty" db:"user_id"`
|
||||
CompanyID *int `json:"companyId,omitempty" db:"company_id"`
|
||||
Subject string `json:"subject" db:"subject"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Category string `json:"category" db:"category"`
|
||||
Priority string `json:"priority" db:"priority"`
|
||||
Status string `json:"status" db:"status"`
|
||||
AssignedTo *int `json:"assignedTo,omitempty" db:"assigned_to"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
ResolvedAt *time.Time `json:"resolvedAt,omitempty" db:"resolved_at"`
|
||||
|
||||
// Joined fields
|
||||
UserName *string `json:"userName,omitempty"`
|
||||
CompanyName *string `json:"companyName,omitempty"`
|
||||
AssigneeName *string `json:"assigneeName,omitempty"`
|
||||
}
|
||||
|
||||
// TicketMessage represents a message within a ticket
|
||||
type TicketMessage struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
TicketID int `json:"ticketId" db:"ticket_id"`
|
||||
UserID *int `json:"userId,omitempty" db:"user_id"`
|
||||
Message string `json:"message" db:"message"`
|
||||
IsInternal bool `json:"isInternal" db:"is_internal"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
|
||||
// Joined fields
|
||||
UserName *string `json:"userName,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTicketRequest for creating a new ticket
|
||||
type CreateTicketRequest struct {
|
||||
Subject string `json:"subject"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
// UpdateTicketRequest for updating a ticket
|
||||
type UpdateTicketRequest struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
Priority *string `json:"priority,omitempty"`
|
||||
AssignedTo *int `json:"assignedTo,omitempty"`
|
||||
}
|
||||
|
||||
// AddTicketMessageRequest for adding a message to a ticket
|
||||
type AddTicketMessageRequest struct {
|
||||
Message string `json:"message"`
|
||||
IsInternal bool `json:"isInternal"`
|
||||
}
|
||||
|
||||
// TicketStats for dashboard
|
||||
type TicketStats struct {
|
||||
Total int `json:"total"`
|
||||
Open int `json:"open"`
|
||||
InProgress int `json:"inProgress"`
|
||||
Resolved int `json:"resolved"`
|
||||
AvgResponse float64 `json:"avgResponseTime"` // in hours
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a system user (SuperAdmin, CompanyAdmin, Recruiter, or JobSeeker)
|
||||
type User struct {
|
||||
|
|
@ -24,38 +27,69 @@ type User struct {
|
|||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty" db:"last_login_at"`
|
||||
|
||||
// Profile Profile
|
||||
Bio *string `json:"bio,omitempty" db:"bio"`
|
||||
ProfilePictureURL *string `json:"profilePictureUrl,omitempty" db:"profile_picture_url"`
|
||||
Skills []byte `json:"skills,omitempty" db:"skills"` // JSONB
|
||||
Experience []byte `json:"experience,omitempty" db:"experience"` // JSONB
|
||||
Education []byte `json:"education,omitempty" db:"education"` // JSONB
|
||||
}
|
||||
|
||||
// UserResponse is the public representation of a user (without sensitive data)
|
||||
type UserResponse struct {
|
||||
ID int `json:"id"`
|
||||
Identifier string `json:"identifier"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"fullName"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
LineID *string `json:"lineId,omitempty"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty"`
|
||||
Instagram *string `json:"instagram,omitempty"`
|
||||
Language string `json:"language"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Identifier string `json:"identifier"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"fullName"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
LineID *string `json:"lineId,omitempty"`
|
||||
WhatsApp *string `json:"whatsapp,omitempty"`
|
||||
Instagram *string `json:"instagram,omitempty"`
|
||||
Language string `json:"language"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
ProfilePictureURL *string `json:"profilePictureUrl,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
Experience []any `json:"experience,omitempty"`
|
||||
Education []any `json:"education,omitempty"`
|
||||
}
|
||||
|
||||
// ToResponse converts User to UserResponse
|
||||
func (u *User) ToResponse() UserResponse {
|
||||
// Helper to unmarshal JSONB
|
||||
var skills []string
|
||||
if len(u.Skills) > 0 {
|
||||
_ = json.Unmarshal(u.Skills, &skills)
|
||||
}
|
||||
var experience []any
|
||||
if len(u.Experience) > 0 {
|
||||
_ = json.Unmarshal(u.Experience, &experience)
|
||||
}
|
||||
var education []any
|
||||
if len(u.Education) > 0 {
|
||||
_ = json.Unmarshal(u.Education, &education)
|
||||
}
|
||||
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
Identifier: u.Identifier,
|
||||
Role: u.Role,
|
||||
FullName: u.FullName,
|
||||
Phone: u.Phone,
|
||||
LineID: u.LineID,
|
||||
WhatsApp: u.WhatsApp,
|
||||
Instagram: u.Instagram,
|
||||
Language: u.Language,
|
||||
Active: u.Active,
|
||||
CreatedAt: u.CreatedAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
ID: u.ID,
|
||||
Identifier: u.Identifier,
|
||||
Role: u.Role,
|
||||
FullName: u.FullName,
|
||||
Phone: u.Phone,
|
||||
LineID: u.LineID,
|
||||
WhatsApp: u.WhatsApp,
|
||||
Instagram: u.Instagram,
|
||||
Language: u.Language,
|
||||
Active: u.Active,
|
||||
CreatedAt: u.CreatedAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
Bio: u.Bio,
|
||||
ProfilePictureURL: u.ProfilePictureURL,
|
||||
Skills: skills,
|
||||
Experience: experience,
|
||||
Education: education,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ func NewRouter() http.Handler {
|
|||
mux := http.NewServeMux()
|
||||
|
||||
// Initialize Services
|
||||
emailService := services.NewMockEmailService()
|
||||
jobService := services.NewJobService(database.DB)
|
||||
applicationService := services.NewApplicationService(database.DB)
|
||||
applicationService := services.NewApplicationService(database.DB, emailService)
|
||||
|
||||
// --- CORE ARCHITECTURE INITIALIZATION ---
|
||||
// Infrastructure
|
||||
|
|
@ -48,6 +49,15 @@ func NewRouter() http.Handler {
|
|||
|
||||
authService := authInfra.NewJWTService(jwtSecret, "todai-jobs")
|
||||
|
||||
// Token Repository for Password Reset
|
||||
tokenRepo := postgres.NewPasswordResetTokenRepository(database.DB)
|
||||
|
||||
// Frontend URL for reset link
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:3000"
|
||||
}
|
||||
|
||||
// UseCases
|
||||
loginUC := authUC.NewLoginUseCase(userRepo, authService)
|
||||
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
|
||||
|
|
@ -55,9 +65,15 @@ func NewRouter() http.Handler {
|
|||
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
||||
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
||||
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
||||
updateUserUC := userUC.NewUpdateUserUseCase(userRepo)
|
||||
forgotPasswordUC := authUC.NewForgotPasswordUseCase(userRepo, tokenRepo, emailService, frontendURL)
|
||||
resetPasswordUC := authUC.NewResetPasswordUseCase(userRepo, tokenRepo, authService)
|
||||
|
||||
// Handlers & Middleware
|
||||
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC)
|
||||
coreHandlers := apiHandlers.NewCoreHandlers(
|
||||
loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, updateUserUC, listCompaniesUC,
|
||||
forgotPasswordUC, resetPasswordUC,
|
||||
)
|
||||
authMiddleware := middleware.NewMiddleware(authService)
|
||||
|
||||
// Initialize Legacy Handlers
|
||||
|
|
@ -120,6 +136,8 @@ func NewRouter() http.Handler {
|
|||
// --- CORE ROUTES ---
|
||||
// Public
|
||||
mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login)
|
||||
mux.HandleFunc("POST /api/v1/auth/forgot-password", coreHandlers.ForgotPassword)
|
||||
mux.HandleFunc("POST /api/v1/auth/reset-password", coreHandlers.ResetPassword)
|
||||
mux.HandleFunc("POST /api/v1/companies", coreHandlers.CreateCompany)
|
||||
mux.HandleFunc("GET /api/v1/companies", coreHandlers.ListCompanies)
|
||||
|
||||
|
|
@ -129,6 +147,7 @@ func NewRouter() http.Handler {
|
|||
mux.Handle("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser)))
|
||||
mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListUsers)))
|
||||
mux.Handle("DELETE /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteUser)))
|
||||
mux.Handle("PUT /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMe))) // New Profile Update
|
||||
|
||||
// Job Routes
|
||||
mux.HandleFunc("GET /api/v1/jobs", jobHandler.GetJobs)
|
||||
|
|
@ -139,6 +158,7 @@ func NewRouter() http.Handler {
|
|||
|
||||
// Application Routes
|
||||
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
|
||||
mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.ListUserApplications))) // New endpoint
|
||||
mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications)
|
||||
mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID)
|
||||
mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus)
|
||||
|
|
@ -156,6 +176,23 @@ func NewRouter() http.Handler {
|
|||
log.Println("S3 storage routes registered successfully")
|
||||
}
|
||||
|
||||
// --- TICKET ROUTES ---
|
||||
ticketService := services.NewTicketService(database.DB)
|
||||
ticketHandler := handlers.NewTicketHandler(ticketService)
|
||||
mux.HandleFunc("GET /api/v1/tickets/stats", ticketHandler.GetTicketStats)
|
||||
mux.HandleFunc("GET /api/v1/tickets", ticketHandler.GetTickets)
|
||||
mux.HandleFunc("POST /api/v1/tickets", ticketHandler.CreateTicket)
|
||||
mux.HandleFunc("GET /api/v1/tickets/{id}", ticketHandler.GetTicketByID)
|
||||
mux.HandleFunc("PUT /api/v1/tickets/{id}", ticketHandler.UpdateTicket)
|
||||
mux.HandleFunc("GET /api/v1/tickets/{id}/messages", ticketHandler.GetTicketMessages)
|
||||
mux.HandleFunc("POST /api/v1/tickets/{id}/messages", ticketHandler.AddTicketMessage)
|
||||
|
||||
// --- ACTIVITY LOG ROUTES ---
|
||||
activityLogService := services.NewActivityLogService(database.DB)
|
||||
activityLogHandler := handlers.NewActivityLogHandler(activityLogService)
|
||||
mux.HandleFunc("GET /api/v1/activity-logs/stats", activityLogHandler.GetActivityLogStats)
|
||||
mux.HandleFunc("GET /api/v1/activity-logs", activityLogHandler.GetActivityLogs)
|
||||
|
||||
// Swagger Route - available at /docs
|
||||
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
|
||||
|
||||
|
|
|
|||
134
backend/internal/services/activity_log_service.go
Normal file
134
backend/internal/services/activity_log_service.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||
)
|
||||
|
||||
type ActivityLogService struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewActivityLogService(db *sql.DB) *ActivityLogService {
|
||||
return &ActivityLogService{db: db}
|
||||
}
|
||||
|
||||
// Log creates a new activity log entry
|
||||
func (s *ActivityLogService) Log(userID *int, tenantID *string, action string, resourceType, resourceID *string, description *string, metadata map[string]interface{}, ipAddress, userAgent *string) error {
|
||||
var metadataJSON []byte
|
||||
if metadata != nil {
|
||||
metadataJSON, _ = json.Marshal(metadata)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO activity_logs (user_id, tenant_id, action, resource_type, resource_id, description, metadata, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(query, userID, tenantID, action, resourceType, resourceID, description, metadataJSON, ipAddress, userAgent)
|
||||
return err
|
||||
}
|
||||
|
||||
// List lists activity logs with filters
|
||||
func (s *ActivityLogService) List(filter models.ActivityLogFilter) ([]models.ActivityLog, error) {
|
||||
query := `
|
||||
SELECT al.id, al.user_id, al.tenant_id, al.action, al.resource_type, al.resource_id,
|
||||
al.description, al.metadata, al.ip_address, al.user_agent, al.created_at,
|
||||
u.full_name as user_name
|
||||
FROM activity_logs al
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
WHERE ($1::int IS NULL OR al.user_id = $1)
|
||||
AND ($2::varchar IS NULL OR al.tenant_id = $2)
|
||||
AND ($3::varchar IS NULL OR al.action = $3)
|
||||
AND ($4::varchar IS NULL OR al.resource_type = $4)
|
||||
AND ($5::timestamp IS NULL OR al.created_at >= $5)
|
||||
AND ($6::timestamp IS NULL OR al.created_at <= $6)
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $7 OFFSET $8
|
||||
`
|
||||
|
||||
limit := filter.Limit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query,
|
||||
filter.UserID, filter.TenantID, filter.Action, filter.ResourceType,
|
||||
filter.StartDate, filter.EndDate, limit, filter.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []models.ActivityLog
|
||||
for rows.Next() {
|
||||
var log models.ActivityLog
|
||||
err := rows.Scan(
|
||||
&log.ID, &log.UserID, &log.TenantID, &log.Action, &log.ResourceType, &log.ResourceID,
|
||||
&log.Description, &log.Metadata, &log.IPAddress, &log.UserAgent, &log.CreatedAt,
|
||||
&log.UserName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// GetStats gets activity log statistics
|
||||
func (s *ActivityLogService) GetStats() (*models.ActivityLogStats, error) {
|
||||
stats := &models.ActivityLogStats{}
|
||||
now := time.Now()
|
||||
|
||||
// Counts
|
||||
countQuery := `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE created_at >= $1) as today,
|
||||
COUNT(*) FILTER (WHERE created_at >= $2) as this_week,
|
||||
COUNT(*) FILTER (WHERE created_at >= $3) as this_month
|
||||
FROM activity_logs
|
||||
`
|
||||
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
startOfWeek := startOfDay.AddDate(0, 0, -int(now.Weekday()))
|
||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
err := s.db.QueryRow(countQuery, startOfDay, startOfWeek, startOfMonth).
|
||||
Scan(&stats.TotalToday, &stats.TotalThisWeek, &stats.TotalThisMonth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Top actions
|
||||
topActionsQuery := `
|
||||
SELECT action, COUNT(*) as count
|
||||
FROM activity_logs
|
||||
WHERE created_at >= $1
|
||||
GROUP BY action
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`
|
||||
|
||||
rows, err := s.db.Query(topActionsQuery, startOfWeek)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ac models.ActionCount
|
||||
if err := rows.Scan(&ac.Action, &ac.Count); err == nil {
|
||||
stats.TopActions = append(stats.TopActions, ac)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recent activity (last 20)
|
||||
recentLogs, _ := s.List(models.ActivityLogFilter{Limit: 20})
|
||||
stats.RecentActivity = recentLogs
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package services
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||
|
|
@ -9,11 +10,15 @@ import (
|
|||
)
|
||||
|
||||
type ApplicationService struct {
|
||||
DB *sql.DB
|
||||
DB *sql.DB
|
||||
EmailService EmailService
|
||||
}
|
||||
|
||||
func NewApplicationService(db *sql.DB) *ApplicationService {
|
||||
return &ApplicationService{DB: db}
|
||||
func NewApplicationService(db *sql.DB, emailService EmailService) *ApplicationService {
|
||||
return &ApplicationService{
|
||||
DB: db,
|
||||
EmailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest) (*models.Application, error) {
|
||||
|
|
@ -51,6 +56,29 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Notify Company (Mock)
|
||||
go func() {
|
||||
name := ""
|
||||
if app.Name != nil {
|
||||
name = *app.Name
|
||||
}
|
||||
|
||||
email := ""
|
||||
if app.Email != nil {
|
||||
email = *app.Email
|
||||
}
|
||||
|
||||
phone := ""
|
||||
if app.Phone != nil {
|
||||
phone = *app.Phone
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Nova candidatura para a vaga #%d", app.JobID)
|
||||
body := fmt.Sprintf("Olá,\n\nVocê recebeu uma nova candidatura de %s para a vaga #%d.\n\nEmail: %s\nTelefone: %s\n\nVerifique o painel para mais detalhes.", name, app.JobID, email, phone)
|
||||
// TODO: In real scenario, we would fetch company email from job->company relation
|
||||
_ = s.EmailService.SendEmail("company@example.com", subject, body)
|
||||
}()
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +109,41 @@ func (s *ApplicationService) GetApplications(jobID int) ([]models.Application, e
|
|||
return apps, nil
|
||||
}
|
||||
|
||||
func (s *ApplicationService) ListUserApplications(userID string) ([]models.ApplicationWithDetails, error) {
|
||||
query := `
|
||||
SELECT
|
||||
a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email,
|
||||
a.message, a.resume_url, a.status, a.created_at, a.updated_at,
|
||||
j.title, c.name
|
||||
FROM applications a
|
||||
JOIN jobs j ON a.job_id = j.id
|
||||
LEFT JOIN companies c ON j.company_id = c.id
|
||||
WHERE a.user_id = $1
|
||||
ORDER BY a.created_at DESC
|
||||
`
|
||||
rows, err := s.DB.Query(query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var apps []models.ApplicationWithDetails
|
||||
for rows.Next() {
|
||||
var a models.ApplicationWithDetails
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
|
||||
&a.Message, &a.ResumeURL, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
&a.JobTitle, &a.CompanyName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Some logical defaults if needed
|
||||
a.CompanyID = 0 // We didn't fetch it but could if needed
|
||||
apps = append(apps, a)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func (s *ApplicationService) GetApplicationByID(id int) (*models.Application, error) {
|
||||
var a models.Application
|
||||
query := `
|
||||
|
|
|
|||
74
backend/internal/services/application_service_test.go
Normal file
74
backend/internal/services/application_service_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package services_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MockEmailService struct {
|
||||
SentEmails []struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockEmailService) SendEmail(to, subject, body string) error {
|
||||
m.SentEmails = append(m.SentEmails, struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}{To: to, Subject: subject, Body: body})
|
||||
return nil
|
||||
}
|
||||
|
||||
func StringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func IntPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
func TestCreateApplication_Success(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
assert.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
emailService := &MockEmailService{}
|
||||
service := services.NewApplicationService(db, emailService)
|
||||
|
||||
req := dto.CreateApplicationRequest{
|
||||
JobID: 1,
|
||||
UserID: IntPtr(123),
|
||||
Name: StringPtr("John Doe"),
|
||||
Email: StringPtr("john@example.com"),
|
||||
Phone: StringPtr("1234567890"),
|
||||
}
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).
|
||||
AddRow(1, time.Now(), time.Now())
|
||||
|
||||
mock.ExpectQuery("INSERT INTO applications").
|
||||
WillReturnRows(rows)
|
||||
|
||||
app, err := service.CreateApplication(req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, app)
|
||||
assert.Equal(t, 1, app.ID)
|
||||
|
||||
// Wait for goroutine to finish (simple sleep for test, ideal would be waitgroup but svc doesn't expose it)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if len(emailService.SentEmails) == 0 {
|
||||
t.Error("Expected email to be sent")
|
||||
} else {
|
||||
assert.Equal(t, "company@example.com", emailService.SentEmails[0].To)
|
||||
assert.Contains(t, emailService.SentEmails[0].Subject, "Nova candidatura")
|
||||
}
|
||||
}
|
||||
30
backend/internal/services/email_service.go
Normal file
30
backend/internal/services/email_service.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
// EmailService defines interface for email operations
|
||||
type EmailService interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
|
||||
// MockEmailService implements EmailService logging to stdout
|
||||
type MockEmailService struct{}
|
||||
|
||||
func NewMockEmailService() *MockEmailService {
|
||||
return &MockEmailService{}
|
||||
}
|
||||
|
||||
func (s *MockEmailService) SendEmail(to, subject, body string) error {
|
||||
log.Printf("----------------------------------------------------------------")
|
||||
log.Printf("[MOCK EMAIL] To: %s", to)
|
||||
log.Printf("[MOCK EMAIL] Subject: %s", subject)
|
||||
log.Printf("[MOCK EMAIL] Body:\n%s", body)
|
||||
log.Printf("----------------------------------------------------------------")
|
||||
// Simulate success
|
||||
return nil
|
||||
}
|
||||
|
||||
// In proper implementation, we would have SMTP email service here
|
||||
// func NewSMTPEmailService(...) ...
|
||||
|
|
@ -65,21 +65,36 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest) (*models.Job, error) {
|
|||
|
||||
func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) {
|
||||
baseQuery := `
|
||||
SELECT
|
||||
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
|
||||
j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.created_at, j.updated_at,
|
||||
c.name as company_name, c.logo_url as company_logo_url,
|
||||
r.name as region_name, ci.name as city_name
|
||||
FROM jobs j
|
||||
LEFT JOIN companies c ON j.company_id = c.id
|
||||
LEFT JOIN regions r ON j.region_id = r.id
|
||||
LEFT JOIN cities ci ON j.city_id = ci.id
|
||||
WHERE 1=1`
|
||||
SELECT
|
||||
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
|
||||
j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.created_at, j.updated_at,
|
||||
c.name as company_name, c.logo_url as company_logo_url,
|
||||
r.name as region_name, ci.name as city_name
|
||||
FROM jobs j
|
||||
LEFT JOIN companies c ON j.company_id = c.id
|
||||
LEFT JOIN regions r ON j.region_id = r.id
|
||||
LEFT JOIN cities ci ON j.city_id = ci.id
|
||||
WHERE 1=1`
|
||||
countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
argId := 1
|
||||
|
||||
// Full-text search on title and description
|
||||
if filter.Search != nil && *filter.Search != "" {
|
||||
searchClause := fmt.Sprintf(` AND (
|
||||
to_tsvector('portuguese', COALESCE(j.title, '') || ' ' || COALESCE(j.description, ''))
|
||||
@@ plainto_tsquery('portuguese', $%d)
|
||||
OR j.title ILIKE '%%' || $%d || '%%'
|
||||
OR j.description ILIKE '%%' || $%d || '%%'
|
||||
)`, argId, argId, argId)
|
||||
baseQuery += searchClause
|
||||
countQuery += searchClause
|
||||
args = append(args, *filter.Search)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Company filter
|
||||
if filter.CompanyID != nil {
|
||||
baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
|
||||
|
|
@ -87,6 +102,47 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
|
|||
argId++
|
||||
}
|
||||
|
||||
// Region filter
|
||||
if filter.RegionID != nil {
|
||||
baseQuery += fmt.Sprintf(" AND j.region_id = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.region_id = $%d", argId)
|
||||
args = append(args, *filter.RegionID)
|
||||
argId++
|
||||
}
|
||||
|
||||
// City filter
|
||||
if filter.CityID != nil {
|
||||
baseQuery += fmt.Sprintf(" AND j.city_id = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.city_id = $%d", argId)
|
||||
args = append(args, *filter.CityID)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Employment type filter
|
||||
if filter.EmploymentType != nil && *filter.EmploymentType != "" {
|
||||
baseQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId)
|
||||
args = append(args, *filter.EmploymentType)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Work mode filter (onsite, hybrid, remote)
|
||||
if filter.WorkMode != nil && *filter.WorkMode != "" {
|
||||
baseQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId)
|
||||
args = append(args, *filter.WorkMode)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if filter.Status != nil && *filter.Status != "" {
|
||||
baseQuery += fmt.Sprintf(" AND j.status = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.status = $%d", argId)
|
||||
args = append(args, *filter.Status)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Featured filter
|
||||
if filter.IsFeatured != nil {
|
||||
baseQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
|
||||
|
|
@ -94,13 +150,72 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
|
|||
argId++
|
||||
}
|
||||
|
||||
// Add more filters as needed...
|
||||
// Visa support filter
|
||||
if filter.VisaSupport != nil {
|
||||
baseQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
|
||||
args = append(args, *filter.VisaSupport)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Salary range filters
|
||||
if filter.SalaryMin != nil {
|
||||
baseQuery += fmt.Sprintf(" AND (j.salary_max >= $%d OR j.salary_min >= $%d)", argId, argId)
|
||||
countQuery += fmt.Sprintf(" AND (j.salary_max >= $%d OR j.salary_min >= $%d)", argId, argId)
|
||||
args = append(args, *filter.SalaryMin)
|
||||
argId++
|
||||
}
|
||||
if filter.SalaryMax != nil {
|
||||
baseQuery += fmt.Sprintf(" AND (j.salary_min <= $%d OR j.salary_min IS NULL)", argId)
|
||||
countQuery += fmt.Sprintf(" AND (j.salary_min <= $%d OR j.salary_min IS NULL)", argId)
|
||||
args = append(args, *filter.SalaryMax)
|
||||
argId++
|
||||
}
|
||||
if filter.SalaryType != nil && *filter.SalaryType != "" {
|
||||
baseQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId)
|
||||
args = append(args, *filter.SalaryType)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Location text search
|
||||
if filter.LocationSearch != nil && *filter.LocationSearch != "" {
|
||||
baseQuery += fmt.Sprintf(" AND j.location ILIKE '%%' || $%d || '%%'", argId)
|
||||
countQuery += fmt.Sprintf(" AND j.location ILIKE '%%' || $%d || '%%'", argId)
|
||||
args = append(args, *filter.LocationSearch)
|
||||
argId++
|
||||
}
|
||||
|
||||
// Sorting
|
||||
orderClause := " ORDER BY "
|
||||
switch filter.SortBy {
|
||||
case "salary":
|
||||
orderClause += "COALESCE(j.salary_max, j.salary_min, 0)"
|
||||
case "relevance":
|
||||
if filter.Search != nil && *filter.Search != "" {
|
||||
orderClause += fmt.Sprintf("ts_rank(to_tsvector('portuguese', COALESCE(j.title, '') || ' ' || COALESCE(j.description, '')), plainto_tsquery('portuguese', '%s'))", *filter.Search)
|
||||
} else {
|
||||
orderClause += "j.is_featured DESC, j.created_at"
|
||||
}
|
||||
default: // date
|
||||
orderClause += "j.is_featured DESC, j.created_at"
|
||||
}
|
||||
|
||||
if filter.SortOrder == "asc" {
|
||||
orderClause += " ASC"
|
||||
} else {
|
||||
orderClause += " DESC"
|
||||
}
|
||||
baseQuery += orderClause
|
||||
|
||||
// Pagination
|
||||
limit := filter.Limit
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
offset := (filter.Page - 1) * limit
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
|
|
@ -120,7 +235,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
|
|||
var j models.JobWithCompany
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
|
||||
&j.EmploymentType, &j.Location, &j.Status, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt,
|
||||
&j.EmploymentType, &j.WorkMode, &j.Location, &j.Status, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt,
|
||||
&j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
|
|
|
|||
242
backend/internal/services/ticket_service.go
Normal file
242
backend/internal/services/ticket_service.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||
)
|
||||
|
||||
type TicketService struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewTicketService(db *sql.DB) *TicketService {
|
||||
return &TicketService{db: db}
|
||||
}
|
||||
|
||||
// Create creates a new ticket
|
||||
func (s *TicketService) Create(userID *int, companyID *int, req models.CreateTicketRequest) (*models.Ticket, error) {
|
||||
query := `
|
||||
INSERT INTO tickets (user_id, company_id, subject, description, category, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, status, created_at, updated_at
|
||||
`
|
||||
|
||||
category := req.Category
|
||||
if category == "" {
|
||||
category = "general"
|
||||
}
|
||||
priority := req.Priority
|
||||
if priority == "" {
|
||||
priority = "medium"
|
||||
}
|
||||
|
||||
ticket := &models.Ticket{
|
||||
UserID: userID,
|
||||
CompanyID: companyID,
|
||||
Subject: req.Subject,
|
||||
Description: req.Description,
|
||||
Category: category,
|
||||
Priority: priority,
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, userID, companyID, req.Subject, req.Description, category, priority).
|
||||
Scan(&ticket.ID, &ticket.Status, &ticket.CreatedAt, &ticket.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// List lists all tickets with optional filters
|
||||
func (s *TicketService) List(status, priority string, limit, offset int) ([]models.Ticket, error) {
|
||||
query := `
|
||||
SELECT t.id, t.user_id, t.company_id, t.subject, t.description, t.category,
|
||||
t.priority, t.status, t.assigned_to, t.created_at, t.updated_at, t.resolved_at,
|
||||
u.full_name as user_name, c.name as company_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE ($1 = '' OR t.status = $1)
|
||||
AND ($2 = '' OR t.priority = $2)
|
||||
ORDER BY
|
||||
CASE t.priority
|
||||
WHEN 'urgent' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
t.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
rows, err := s.db.Query(query, status, priority, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tickets []models.Ticket
|
||||
for rows.Next() {
|
||||
var t models.Ticket
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.CompanyID, &t.Subject, &t.Description, &t.Category,
|
||||
&t.Priority, &t.Status, &t.AssignedTo, &t.CreatedAt, &t.UpdatedAt, &t.ResolvedAt,
|
||||
&t.UserName, &t.CompanyName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tickets = append(tickets, t)
|
||||
}
|
||||
|
||||
return tickets, nil
|
||||
}
|
||||
|
||||
// GetByID gets a ticket by ID
|
||||
func (s *TicketService) GetByID(id int) (*models.Ticket, error) {
|
||||
query := `
|
||||
SELECT t.id, t.user_id, t.company_id, t.subject, t.description, t.category,
|
||||
t.priority, t.status, t.assigned_to, t.created_at, t.updated_at, t.resolved_at,
|
||||
u.full_name as user_name, c.name as company_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.id = $1
|
||||
`
|
||||
|
||||
var t models.Ticket
|
||||
err := s.db.QueryRow(query, id).Scan(
|
||||
&t.ID, &t.UserID, &t.CompanyID, &t.Subject, &t.Description, &t.Category,
|
||||
&t.Priority, &t.Status, &t.AssignedTo, &t.CreatedAt, &t.UpdatedAt, &t.ResolvedAt,
|
||||
&t.UserName, &t.CompanyName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// Update updates a ticket
|
||||
func (s *TicketService) Update(id int, req models.UpdateTicketRequest) (*models.Ticket, error) {
|
||||
query := `
|
||||
UPDATE tickets SET
|
||||
status = COALESCE($1, status),
|
||||
priority = COALESCE($2, priority),
|
||||
assigned_to = COALESCE($3, assigned_to),
|
||||
updated_at = NOW(),
|
||||
resolved_at = CASE WHEN $1 IN ('resolved', 'closed') THEN NOW() ELSE resolved_at END
|
||||
WHERE id = $4
|
||||
RETURNING id, user_id, company_id, subject, description, category, priority, status, assigned_to, created_at, updated_at, resolved_at
|
||||
`
|
||||
|
||||
var t models.Ticket
|
||||
err := s.db.QueryRow(query, req.Status, req.Priority, req.AssignedTo, id).Scan(
|
||||
&t.ID, &t.UserID, &t.CompanyID, &t.Subject, &t.Description, &t.Category,
|
||||
&t.Priority, &t.Status, &t.AssignedTo, &t.CreatedAt, &t.UpdatedAt, &t.ResolvedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// AddMessage adds a message to a ticket
|
||||
func (s *TicketService) AddMessage(ticketID int, userID *int, req models.AddTicketMessageRequest) (*models.TicketMessage, error) {
|
||||
query := `
|
||||
INSERT INTO ticket_messages (ticket_id, user_id, message, is_internal)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at
|
||||
`
|
||||
|
||||
msg := &models.TicketMessage{
|
||||
TicketID: ticketID,
|
||||
UserID: userID,
|
||||
Message: req.Message,
|
||||
IsInternal: req.IsInternal,
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, ticketID, userID, req.Message, req.IsInternal).
|
||||
Scan(&msg.ID, &msg.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update ticket updated_at
|
||||
_, _ = s.db.Exec("UPDATE tickets SET updated_at = NOW() WHERE id = $1", ticketID)
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// GetMessages gets all messages for a ticket
|
||||
func (s *TicketService) GetMessages(ticketID int, includeInternal bool) ([]models.TicketMessage, error) {
|
||||
query := `
|
||||
SELECT tm.id, tm.ticket_id, tm.user_id, tm.message, tm.is_internal, tm.created_at,
|
||||
u.full_name as user_name
|
||||
FROM ticket_messages tm
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.ticket_id = $1 AND ($2 OR tm.is_internal = false)
|
||||
ORDER BY tm.created_at ASC
|
||||
`
|
||||
|
||||
rows, err := s.db.Query(query, ticketID, includeInternal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []models.TicketMessage
|
||||
for rows.Next() {
|
||||
var m models.TicketMessage
|
||||
err := rows.Scan(&m.ID, &m.TicketID, &m.UserID, &m.Message, &m.IsInternal, &m.CreatedAt, &m.UserName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetStats gets ticket statistics
|
||||
func (s *TicketService) GetStats() (*models.TicketStats, error) {
|
||||
stats := &models.TicketStats{}
|
||||
|
||||
// Count by status
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'open') as open,
|
||||
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
|
||||
COUNT(*) FILTER (WHERE status IN ('resolved', 'closed')) as resolved
|
||||
FROM tickets
|
||||
`
|
||||
|
||||
err := s.db.QueryRow(query).Scan(&stats.Total, &stats.Open, &stats.InProgress, &stats.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate average response time (from creation to first message)
|
||||
responseQuery := `
|
||||
SELECT COALESCE(
|
||||
AVG(EXTRACT(EPOCH FROM (
|
||||
(SELECT MIN(created_at) FROM ticket_messages WHERE ticket_id = t.id) - t.created_at
|
||||
)) / 3600), 0
|
||||
) as avg_response
|
||||
FROM tickets t
|
||||
WHERE EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_id = t.id)
|
||||
`
|
||||
_ = s.db.QueryRow(responseQuery).Scan(&stats.AvgResponse)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// DeleteTicket deletes a ticket (soft delete by setting status to 'closed')
|
||||
func (s *TicketService) Delete(id int) error {
|
||||
_, err := s.db.Exec(`UPDATE tickets SET status = 'closed', updated_at = NOW() WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
189
backend/internal/utils/document_validator.go
Normal file
189
backend/internal/utils/document_validator.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DocumentValidator provides flexible document validation for global use
|
||||
type DocumentValidator struct {
|
||||
// Country code to use for validation (empty = accept all formats)
|
||||
CountryCode string
|
||||
}
|
||||
|
||||
// NewDocumentValidator creates a new validator
|
||||
func NewDocumentValidator(countryCode string) *DocumentValidator {
|
||||
return &DocumentValidator{CountryCode: strings.ToUpper(countryCode)}
|
||||
}
|
||||
|
||||
// ValidationResult represents the result of document validation
|
||||
type ValidationResult struct {
|
||||
Valid bool
|
||||
Message string
|
||||
Clean string // Cleaned document number
|
||||
}
|
||||
|
||||
// ValidateDocument validates a document based on country
|
||||
// For a global portal, this supports multiple formats
|
||||
func (v *DocumentValidator) ValidateDocument(doc string, docType string) ValidationResult {
|
||||
// Remove all non-alphanumeric characters for cleaning
|
||||
clean := regexp.MustCompile(`[^a-zA-Z0-9]`).ReplaceAllString(doc, "")
|
||||
|
||||
if clean == "" {
|
||||
return ValidationResult{Valid: true, Message: "Documento opcional não fornecido", Clean: ""}
|
||||
}
|
||||
|
||||
switch v.CountryCode {
|
||||
case "BR":
|
||||
return v.validateBrazil(clean, docType)
|
||||
case "JP":
|
||||
return v.validateJapan(clean, docType)
|
||||
case "US":
|
||||
return v.validateUSA(clean, docType)
|
||||
default:
|
||||
// For global/unknown countries, accept any alphanumeric
|
||||
if len(clean) < 5 || len(clean) > 30 {
|
||||
return ValidationResult{Valid: false, Message: "Documento deve ter entre 5 e 30 caracteres", Clean: clean}
|
||||
}
|
||||
return ValidationResult{Valid: true, Message: "Documento aceito", Clean: clean}
|
||||
}
|
||||
}
|
||||
|
||||
// validateBrazil validates Brazilian documents (CNPJ/CPF)
|
||||
func (v *DocumentValidator) validateBrazil(doc string, docType string) ValidationResult {
|
||||
switch strings.ToUpper(docType) {
|
||||
case "CNPJ":
|
||||
if len(doc) != 14 {
|
||||
return ValidationResult{Valid: false, Message: "CNPJ deve ter 14 dígitos", Clean: doc}
|
||||
}
|
||||
if !validateCNPJ(doc) {
|
||||
return ValidationResult{Valid: false, Message: "CNPJ inválido", Clean: doc}
|
||||
}
|
||||
return ValidationResult{Valid: true, Message: "CNPJ válido", Clean: doc}
|
||||
case "CPF":
|
||||
if len(doc) != 11 {
|
||||
return ValidationResult{Valid: false, Message: "CPF deve ter 11 dígitos", Clean: doc}
|
||||
}
|
||||
if !validateCPF(doc) {
|
||||
return ValidationResult{Valid: false, Message: "CPF inválido", Clean: doc}
|
||||
}
|
||||
return ValidationResult{Valid: true, Message: "CPF válido", Clean: doc}
|
||||
default:
|
||||
// Unknown Brazilian document type, accept if reasonable length
|
||||
if len(doc) < 11 || len(doc) > 14 {
|
||||
return ValidationResult{Valid: false, Message: "Documento brasileiro deve ter entre 11 e 14 dígitos", Clean: doc}
|
||||
}
|
||||
return ValidationResult{Valid: true, Message: "Documento aceito", Clean: doc}
|
||||
}
|
||||
}
|
||||
|
||||
// validateCNPJ validates Brazilian CNPJ using checksum algorithm
|
||||
func validateCNPJ(cnpj string) bool {
|
||||
if len(cnpj) != 14 {
|
||||
return false
|
||||
}
|
||||
// Check for known invalid patterns
|
||||
if cnpj == "00000000000000" || cnpj == "11111111111111" || cnpj == "22222222222222" ||
|
||||
cnpj == "33333333333333" || cnpj == "44444444444444" || cnpj == "55555555555555" ||
|
||||
cnpj == "66666666666666" || cnpj == "77777777777777" || cnpj == "88888888888888" ||
|
||||
cnpj == "99999999999999" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate first check digit
|
||||
weights1 := []int{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2}
|
||||
sum := 0
|
||||
for i, w := range weights1 {
|
||||
sum += int(cnpj[i]-'0') * w
|
||||
}
|
||||
remainder := sum % 11
|
||||
checkDigit1 := 0
|
||||
if remainder >= 2 {
|
||||
checkDigit1 = 11 - remainder
|
||||
}
|
||||
if int(cnpj[12]-'0') != checkDigit1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate second check digit
|
||||
weights2 := []int{6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2}
|
||||
sum = 0
|
||||
for i, w := range weights2 {
|
||||
sum += int(cnpj[i]-'0') * w
|
||||
}
|
||||
remainder = sum % 11
|
||||
checkDigit2 := 0
|
||||
if remainder >= 2 {
|
||||
checkDigit2 = 11 - remainder
|
||||
}
|
||||
return int(cnpj[13]-'0') == checkDigit2
|
||||
}
|
||||
|
||||
// validateCPF validates Brazilian CPF using checksum algorithm
|
||||
func validateCPF(cpf string) bool {
|
||||
if len(cpf) != 11 {
|
||||
return false
|
||||
}
|
||||
// Check for known invalid patterns
|
||||
if cpf == "00000000000" || cpf == "11111111111" || cpf == "22222222222" ||
|
||||
cpf == "33333333333" || cpf == "44444444444" || cpf == "55555555555" ||
|
||||
cpf == "66666666666" || cpf == "77777777777" || cpf == "88888888888" ||
|
||||
cpf == "99999999999" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate first check digit
|
||||
sum := 0
|
||||
for i := 0; i < 9; i++ {
|
||||
sum += int(cpf[i]-'0') * (10 - i)
|
||||
}
|
||||
remainder := sum % 11
|
||||
checkDigit1 := 0
|
||||
if remainder >= 2 {
|
||||
checkDigit1 = 11 - remainder
|
||||
}
|
||||
if int(cpf[9]-'0') != checkDigit1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate second check digit
|
||||
sum = 0
|
||||
for i := 0; i < 10; i++ {
|
||||
sum += int(cpf[i]-'0') * (11 - i)
|
||||
}
|
||||
remainder = sum % 11
|
||||
checkDigit2 := 0
|
||||
if remainder >= 2 {
|
||||
checkDigit2 = 11 - remainder
|
||||
}
|
||||
return int(cpf[10]-'0') == checkDigit2
|
||||
}
|
||||
|
||||
// validateJapan validates Japanese corporate numbers
|
||||
func (v *DocumentValidator) validateJapan(doc string, docType string) ValidationResult {
|
||||
// Japanese Corporate Number (法人番号) is 13 digits
|
||||
if len(doc) == 13 {
|
||||
return ValidationResult{Valid: true, Message: "法人番号 válido", Clean: doc}
|
||||
}
|
||||
// Accept other formats loosely
|
||||
if len(doc) >= 5 && len(doc) <= 20 {
|
||||
return ValidationResult{Valid: true, Message: "Documento aceito", Clean: doc}
|
||||
}
|
||||
return ValidationResult{Valid: false, Message: "Documento japonês inválido", Clean: doc}
|
||||
}
|
||||
|
||||
// validateUSA validates US documents (EIN)
|
||||
func (v *DocumentValidator) validateUSA(doc string, docType string) ValidationResult {
|
||||
// EIN is 9 digits
|
||||
if strings.ToUpper(docType) == "EIN" {
|
||||
if len(doc) != 9 {
|
||||
return ValidationResult{Valid: false, Message: "EIN must be 9 digits", Clean: doc}
|
||||
}
|
||||
return ValidationResult{Valid: true, Message: "EIN válido", Clean: doc}
|
||||
}
|
||||
// Accept other formats loosely
|
||||
if len(doc) >= 5 && len(doc) <= 20 {
|
||||
return ValidationResult{Valid: true, Message: "Document accepted", Clean: doc}
|
||||
}
|
||||
return ValidationResult{Valid: false, Message: "Invalid US document", Clean: doc}
|
||||
}
|
||||
7
backend/migrations/013_add_profile_fields_to_users.sql
Normal file
7
backend/migrations/013_add_profile_fields_to_users.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- Add profile fields to core_users table
|
||||
ALTER TABLE core_users
|
||||
ADD COLUMN IF NOT EXISTS bio TEXT,
|
||||
ADD COLUMN IF NOT EXISTS profile_picture_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS skills JSONB DEFAULT '[]',
|
||||
ADD COLUMN IF NOT EXISTS experience JSONB DEFAULT '[]',
|
||||
ADD COLUMN IF NOT EXISTS education JSONB DEFAULT '[]';
|
||||
17
backend/migrations/014_create_password_reset_tokens.sql
Normal file
17
backend/migrations/014_create_password_reset_tokens.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Migration: Create password_reset_tokens table
|
||||
-- Description: Stores tokens for password reset flow
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES core_users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reset_tokens_token ON password_reset_tokens(token);
|
||||
CREATE INDEX idx_reset_tokens_user ON password_reset_tokens(user_id);
|
||||
|
||||
COMMENT ON TABLE password_reset_tokens IS 'Stores password reset tokens for authentication';
|
||||
44
backend/migrations/015_create_tickets_table.sql
Normal file
44
backend/migrations/015_create_tickets_table.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
-- Migration: Create tickets table for support system
|
||||
-- Description: Stores support tickets from users/companies
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
company_id INT REFERENCES companies(id) ON DELETE SET NULL,
|
||||
|
||||
-- Ticket Info
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category VARCHAR(50) DEFAULT 'general' CHECK (category IN ('general', 'billing', 'technical', 'feature_request', 'bug_report', 'account')),
|
||||
priority VARCHAR(20) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'waiting_response', 'resolved', 'closed')),
|
||||
|
||||
-- Assignment
|
||||
assigned_to INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Ticket messages/replies
|
||||
CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_internal BOOLEAN DEFAULT false, -- Internal notes not visible to user
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_tickets_user ON tickets(user_id);
|
||||
CREATE INDEX idx_tickets_company ON tickets(company_id);
|
||||
CREATE INDEX idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX idx_tickets_priority ON tickets(priority);
|
||||
CREATE INDEX idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX idx_ticket_messages_ticket ON ticket_messages(ticket_id);
|
||||
|
||||
COMMENT ON TABLE tickets IS 'Support tickets from users and companies';
|
||||
COMMENT ON TABLE ticket_messages IS 'Messages/replies within a support ticket';
|
||||
31
backend/migrations/016_create_activity_logs_table.sql
Normal file
31
backend/migrations/016_create_activity_logs_table.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
-- Migration: Create activity_logs table
|
||||
-- Description: Stores activity logs for auditing and monitoring
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
tenant_id VARCHAR(36), -- For multi-tenant tracking
|
||||
|
||||
-- Activity Info
|
||||
action VARCHAR(100) NOT NULL, -- e.g., 'user.login', 'job.create', 'application.submit'
|
||||
resource_type VARCHAR(50), -- e.g., 'user', 'job', 'application', 'company'
|
||||
resource_id VARCHAR(50), -- ID of the affected resource
|
||||
|
||||
-- Details
|
||||
description TEXT,
|
||||
metadata JSONB, -- Additional context data
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for efficient querying
|
||||
CREATE INDEX idx_activity_logs_user ON activity_logs(user_id);
|
||||
CREATE INDEX idx_activity_logs_tenant ON activity_logs(tenant_id);
|
||||
CREATE INDEX idx_activity_logs_action ON activity_logs(action);
|
||||
CREATE INDEX idx_activity_logs_resource ON activity_logs(resource_type, resource_id);
|
||||
CREATE INDEX idx_activity_logs_created ON activity_logs(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE activity_logs IS 'Audit log of all system activities';
|
||||
|
|
@ -1,16 +1,54 @@
|
|||
# ================================================
|
||||
# Stage 1: Build
|
||||
# ================================================
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install all dependencies (including dev for build)
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Prune dev dependencies for smaller production image
|
||||
RUN npm prune --production
|
||||
|
||||
# ================================================
|
||||
# Stage 2: Production Runtime
|
||||
# ================================================
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Add non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs \
|
||||
&& adduser -S nestjs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
# Copy only production artifacts
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
|
||||
# Use non-root user
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" || exit 1
|
||||
|
||||
# Start application
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
|
|||
44
backoffice/src/activity-logs/activity-logs.controller.ts
Normal file
44
backoffice/src/activity-logs/activity-logs.controller.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { ActivityLogsService, ActivityLog, ActivityLogStats } from './activity-logs.service';
|
||||
|
||||
@ApiTags('Activity Logs')
|
||||
@Controller('activity-logs')
|
||||
export class ActivityLogsController {
|
||||
constructor(private readonly activityLogsService: ActivityLogsService) { }
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: 'Get activity log statistics' })
|
||||
getStats(): Promise<ActivityLogStats> {
|
||||
return this.activityLogsService.getStats();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List activity logs' })
|
||||
@ApiQuery({ name: 'user_id', required: false, type: Number })
|
||||
@ApiQuery({ name: 'action', required: false })
|
||||
@ApiQuery({ name: 'resource_type', required: false })
|
||||
@ApiQuery({ name: 'start_date', required: false })
|
||||
@ApiQuery({ name: 'end_date', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||
getLogs(
|
||||
@Query('user_id') userId?: number,
|
||||
@Query('action') action?: string,
|
||||
@Query('resource_type') resourceType?: string,
|
||||
@Query('start_date') startDate?: string,
|
||||
@Query('end_date') endDate?: string,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
): Promise<ActivityLog[]> {
|
||||
return this.activityLogsService.getLogs({
|
||||
userId,
|
||||
action,
|
||||
resourceType,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
12
backoffice/src/activity-logs/activity-logs.module.ts
Normal file
12
backoffice/src/activity-logs/activity-logs.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ActivityLogsService } from './activity-logs.service';
|
||||
import { ActivityLogsController } from './activity-logs.controller';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
providers: [ActivityLogsService],
|
||||
controllers: [ActivityLogsController],
|
||||
exports: [ActivityLogsService],
|
||||
})
|
||||
export class ActivityLogsModule { }
|
||||
70
backoffice/src/activity-logs/activity-logs.service.ts
Normal file
70
backoffice/src/activity-logs/activity-logs.service.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface ActivityLog {
|
||||
id: number;
|
||||
userId?: number;
|
||||
tenantId?: string;
|
||||
action: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
description?: string;
|
||||
metadata?: any;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
createdAt: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export interface ActivityLogStats {
|
||||
totalToday: number;
|
||||
totalThisWeek: number;
|
||||
totalThisMonth: number;
|
||||
topActions: { action: string; count: number }[];
|
||||
recentActivity: ActivityLog[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ActivityLogsService {
|
||||
private readonly apiUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.apiUrl = this.configService.get<string>('BACKEND_API_URL', 'http://localhost:8521');
|
||||
}
|
||||
|
||||
async getStats(): Promise<ActivityLogStats> {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.get<ActivityLogStats>(`${this.apiUrl}/api/v1/activity-logs/stats`),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getLogs(params: {
|
||||
userId?: number;
|
||||
action?: string;
|
||||
resourceType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ActivityLog[]> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.userId) searchParams.append('user_id', params.userId.toString());
|
||||
if (params.action) searchParams.append('action', params.action);
|
||||
if (params.resourceType) searchParams.append('resource_type', params.resourceType);
|
||||
if (params.startDate) searchParams.append('start_date', params.startDate);
|
||||
if (params.endDate) searchParams.append('end_date', params.endDate);
|
||||
if (params.limit) searchParams.append('limit', params.limit.toString());
|
||||
if (params.offset) searchParams.append('offset', params.offset.toString());
|
||||
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.get<ActivityLog[]>(`${this.apiUrl}/api/v1/activity-logs?${searchParams}`),
|
||||
);
|
||||
return data || [];
|
||||
}
|
||||
}
|
||||
3
backoffice/src/activity-logs/index.ts
Normal file
3
backoffice/src/activity-logs/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './activity-logs.module';
|
||||
export * from './activity-logs.service';
|
||||
export * from './activity-logs.controller';
|
||||
|
|
@ -3,10 +3,18 @@ import { AppService } from './app.service';
|
|||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
constructor(private readonly appService: AppService) { }
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
getHealth(): { status: string; timestamp: string } {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { AppService } from './app.service';
|
|||
import { StripeModule } from './stripe';
|
||||
import { PlansModule } from './plans';
|
||||
import { AdminModule } from './admin';
|
||||
import { TicketsModule } from './tickets';
|
||||
import { ActivityLogsModule } from './activity-logs';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -12,8 +14,10 @@ import { AdminModule } from './admin';
|
|||
StripeModule,
|
||||
PlansModule,
|
||||
AdminModule,
|
||||
TicketsModule,
|
||||
ActivityLogsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule { }
|
||||
|
|
|
|||
3
backoffice/src/tickets/index.ts
Normal file
3
backoffice/src/tickets/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './tickets.module';
|
||||
export * from './tickets.service';
|
||||
export * from './tickets.controller';
|
||||
60
backoffice/src/tickets/tickets.controller.ts
Normal file
60
backoffice/src/tickets/tickets.controller.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Controller, Get, Post, Put, Param, Body, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService, Ticket, TicketStats } from './tickets.service';
|
||||
|
||||
@ApiTags('Tickets')
|
||||
@Controller('tickets')
|
||||
export class TicketsController {
|
||||
constructor(private readonly ticketsService: TicketsService) { }
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: 'Get ticket statistics' })
|
||||
getStats(): Promise<TicketStats> {
|
||||
return this.ticketsService.getStats();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tickets' })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'priority', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||
getTickets(
|
||||
@Query('status') status?: string,
|
||||
@Query('priority') priority?: string,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
): Promise<Ticket[]> {
|
||||
return this.ticketsService.getTickets(status, priority, limit, offset);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get ticket by ID' })
|
||||
getTicketById(@Param('id') id: number): Promise<Ticket> {
|
||||
return this.ticketsService.getTicketById(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update ticket' })
|
||||
updateTicket(
|
||||
@Param('id') id: number,
|
||||
@Body() updateData: { status?: string; priority?: string; assignedTo?: number },
|
||||
): Promise<Ticket> {
|
||||
return this.ticketsService.updateTicket(id, updateData);
|
||||
}
|
||||
|
||||
@Get(':id/messages')
|
||||
@ApiOperation({ summary: 'Get ticket messages' })
|
||||
getMessages(@Param('id') id: number): Promise<any[]> {
|
||||
return this.ticketsService.getMessages(id);
|
||||
}
|
||||
|
||||
@Post(':id/messages')
|
||||
@ApiOperation({ summary: 'Add message to ticket' })
|
||||
addMessage(
|
||||
@Param('id') id: number,
|
||||
@Body() body: { message: string; isInternal?: boolean },
|
||||
): Promise<any> {
|
||||
return this.ticketsService.addMessage(id, body.message, body.isInternal);
|
||||
}
|
||||
}
|
||||
12
backoffice/src/tickets/tickets.module.ts
Normal file
12
backoffice/src/tickets/tickets.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { TicketsController } from './tickets.controller';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
providers: [TicketsService],
|
||||
controllers: [TicketsController],
|
||||
exports: [TicketsService],
|
||||
})
|
||||
export class TicketsModule { }
|
||||
89
backoffice/src/tickets/tickets.service.ts
Normal file
89
backoffice/src/tickets/tickets.service.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
userId?: number;
|
||||
companyId?: number;
|
||||
subject: string;
|
||||
description: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
assignedTo?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
resolvedAt?: string;
|
||||
userName?: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface TicketStats {
|
||||
total: number;
|
||||
open: number;
|
||||
inProgress: number;
|
||||
resolved: number;
|
||||
avgResponseTime: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TicketsService {
|
||||
private readonly apiUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.apiUrl = this.configService.get<string>('BACKEND_API_URL', 'http://localhost:8521');
|
||||
}
|
||||
|
||||
async getStats(): Promise<TicketStats> {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.get<TicketStats>(`${this.apiUrl}/api/v1/tickets/stats`),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTickets(status?: string, priority?: string, limit = 50, offset = 0): Promise<Ticket[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
if (priority) params.append('priority', priority);
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.get<Ticket[]>(`${this.apiUrl}/api/v1/tickets?${params}`),
|
||||
);
|
||||
return data || [];
|
||||
}
|
||||
|
||||
async getTicketById(id: number): Promise<Ticket> {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.get<Ticket>(`${this.apiUrl}/api/v1/tickets/${id}`),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateTicket(id: number, updateData: { status?: string; priority?: string; assignedTo?: number }): Promise<Ticket> {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.put<Ticket>(`${this.apiUrl}/api/v1/tickets/${id}`, updateData),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async addMessage(ticketId: number, message: string, isInternal = false): Promise<any> {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.post(`${this.apiUrl}/api/v1/tickets/${ticketId}/messages`, { message, isInternal }),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getMessages(ticketId: number): Promise<any[]> {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.get<any[]>(`${this.apiUrl}/api/v1/tickets/${ticketId}/messages`),
|
||||
);
|
||||
return data || [];
|
||||
}
|
||||
}
|
||||
395
frontend/src/app/dashboard/candidato/perfil/page.tsx
Normal file
395
frontend/src/app/dashboard/candidato/perfil/page.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import {
|
||||
Loader2,
|
||||
MapPin,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
User as UserIcon,
|
||||
Briefcase,
|
||||
GraduationCap
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardFooter
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// We'll update api.ts to include usersApi.updateMe later
|
||||
// Mocking for now or assuming it exists
|
||||
import { storageApi, usersApi } from "@/lib/api";
|
||||
|
||||
type Experience = {
|
||||
company: string;
|
||||
position: string;
|
||||
description: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
type Education = {
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
type ProfileFormValues = {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
whatsapp: string;
|
||||
bio: string;
|
||||
skills: string; // Comma separated for input
|
||||
experience: Experience[];
|
||||
education: Education[];
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [profilePic, setProfilePic] = useState<string | null>(null);
|
||||
|
||||
const { register, control, handleSubmit, reset, setValue, watch } = useForm<ProfileFormValues>({
|
||||
defaultValues: {
|
||||
experience: [],
|
||||
education: []
|
||||
}
|
||||
});
|
||||
|
||||
const { fields: expFields, append: appendExp, remove: removeExp } = useFieldArray({
|
||||
control,
|
||||
name: "experience"
|
||||
});
|
||||
|
||||
const { fields: eduFields, append: appendEdu, remove: removeEdu } = useFieldArray({
|
||||
control,
|
||||
name: "education"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Assuming getMe exists, if not we create it
|
||||
// const userData = await usersApi.getMe();
|
||||
// For now, let's assume valid response structure based on our backend implementation
|
||||
// But api.ts might not have getMe yet.
|
||||
|
||||
// To be safe, we might implement getMe in api.ts first?
|
||||
// Or we check what is available.
|
||||
// Current 'authApi.me' might be available?
|
||||
// Let's assume we can fetch user.
|
||||
|
||||
// Fallback mock for development if backend not ready
|
||||
// const userData = mockUser;
|
||||
|
||||
const userData = await usersApi.getMe(); // We will ensure this exists in api.ts
|
||||
|
||||
setUser(userData);
|
||||
setProfilePic(userData.profilePictureUrl || null);
|
||||
|
||||
reset({
|
||||
fullName: userData.name,
|
||||
email: userData.email,
|
||||
// phone: userData.phone, // Phone might not be in Core User yet? We didn't add it to Entity.
|
||||
bio: userData.bio || "",
|
||||
skills: userData.skills?.join(", ") || "",
|
||||
experience: userData.experience || [],
|
||||
education: userData.education || []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
title: "Erro ao carregar perfil",
|
||||
description: "Não foi possível carregar seus dados.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
toast({ title: "Enviando foto..." });
|
||||
// 1. Get presigned URL
|
||||
const { uploadUrl, publicUrl } = await storageApi.getUploadUrl(file.name, file.type);
|
||||
// 2. Upload
|
||||
await storageApi.uploadFile(uploadUrl, file);
|
||||
// 3. Update state
|
||||
setProfilePic(publicUrl);
|
||||
toast({ title: "Foto enviada!", description: "Não esqueça de salvar o perfil." });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({ title: "Erro no upload", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ProfileFormValues) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const skillsArray = data.skills.split(",").map(s => s.trim()).filter(Boolean);
|
||||
|
||||
await usersApi.updateMe({
|
||||
fullName: data.fullName,
|
||||
bio: data.bio,
|
||||
profilePictureUrl: profilePic || undefined,
|
||||
skills: skillsArray,
|
||||
experience: data.experience,
|
||||
education: data.education
|
||||
});
|
||||
|
||||
toast({ title: "Perfil atualizado com sucesso!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({ title: "Erro ao atualizar", variant: "destructive" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-screen items-center justify-center"><Loader2 className="animate-spin" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40 pb-10">
|
||||
<Navbar />
|
||||
<div className="container py-10">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Meu Perfil</h1>
|
||||
<p className="text-muted-foreground">Gerencie suas informações profissionais e pessoais.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
|
||||
{/* Basic Info & Photo */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informações Básicas</CardTitle>
|
||||
<CardDescription>Sua identidade na plataforma.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Avatar className="w-24 h-24 border-2 border-primary/20">
|
||||
<AvatarImage src={profilePic || ""} />
|
||||
<AvatarFallback><UserIcon className="w-10 h-10" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
id="avatar-upload"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => document.getElementById('avatar-upload')?.click()}>
|
||||
<Upload className="w-3 h-3 mr-2" /> Alterar Foto
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 flex-1 w-full">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nome Completo</Label>
|
||||
<Input {...register("fullName")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input {...register("email")} disabled className="bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Bio / Resumo Profissional</Label>
|
||||
<Textarea
|
||||
{...register("bio")}
|
||||
placeholder="Conte um pouco sobre você..."
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skills */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Competências</CardTitle>
|
||||
<CardDescription>Liste suas principais habilidades técnicas e comportamentais.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>Skills (separadas por vírgula)</Label>
|
||||
<Input
|
||||
{...register("skills")}
|
||||
placeholder="Ex: Javscript, Go, Liderança, Scrum"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Estas tags ajudarão recrutadores a encontrar seu perfil.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Experience */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Experiência Profissional</CardTitle>
|
||||
<CardDescription>Seu histórico de trabalho.</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendExp({ company: "", position: "", description: "", startDate: "", endDate: "" })}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Adicionar
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{expFields.map((field, index) => (
|
||||
<div key={field.id} className="relative grid gap-4 p-4 border rounded-md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 text-destructive hover:text-destructive/80"
|
||||
onClick={() => removeExp(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Empresa</Label>
|
||||
<Input {...register(`experience.${index}.company`)} placeholder="Ex: Google" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cargo</Label>
|
||||
<Input {...register(`experience.${index}.position`)} placeholder="Ex: Engenheiro de Software" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Início</Label>
|
||||
<Input type="month" {...register(`experience.${index}.startDate`)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Fim</Label>
|
||||
<Input type="month" {...register(`experience.${index}.endDate`)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Descrição</Label>
|
||||
<Textarea {...register(`experience.${index}.description`)} placeholder="Descreva suas responsabilidades e conquistas..." />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{expFields.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-md">
|
||||
Nenhuma experiência adicionada.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Education */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Formação Acadêmica</CardTitle>
|
||||
<CardDescription>Escolaridade e cursos.</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendEdu({ institution: "", degree: "", field: "", startDate: "", endDate: "" })}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Adicionar
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{eduFields.map((field, index) => (
|
||||
<div key={field.id} className="relative grid gap-4 p-4 border rounded-md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 text-destructive hover:text-destructive/80"
|
||||
onClick={() => removeEdu(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instituição</Label>
|
||||
<Input {...register(`education.${index}.institution`)} placeholder="Ex: USP" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Grau / Nível</Label>
|
||||
<Input {...register(`education.${index}.degree`)} placeholder="Ex: Bacharelado" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Curso / Área de Estudo</Label>
|
||||
<Input {...register(`education.${index}.field`)} placeholder="Ex: Ciência da Computação" />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Início</Label>
|
||||
<Input type="month" {...register(`education.${index}.startDate`)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Fim</Label>
|
||||
<Input type="month" {...register(`education.${index}.endDate`)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{eduFields.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-md">
|
||||
Nenhuma formação adicionada.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4 sticky bottom-4 z-10 bg-background/80 backdrop-blur-sm p-4 rounded-lg border shadow-lg">
|
||||
<Button type="button" variant="outline" onClick={() => reset()}>Descartar Alterações</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,190 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { DashboardHeader } from "@/components/dashboard-header" // Keep header if we want consistent layout, though global layout handles it
|
||||
// Actually global layout handles sidebar/header.
|
||||
// We just need the content.
|
||||
// But wait, "My Jobs" page doesn't exist yet!
|
||||
// I'll create a placeholder for now to prevent 404s on the new links.
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import {
|
||||
Building2,
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
Search,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardFooter
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { applicationsApi, ApiApplication } from "@/lib/api";
|
||||
|
||||
type ApplicationWithJob = ApiApplication & {
|
||||
jobTitle: string;
|
||||
companyName: string;
|
||||
};
|
||||
|
||||
export default function MyJobsPage() {
|
||||
const [applications, setApplications] = useState<ApplicationWithJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
}, []);
|
||||
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await applicationsApi.listMyApplications();
|
||||
// The backend now returns ApplicationWithDetails which has jobTitle and companyName
|
||||
// We cast it to our extended type
|
||||
setApplications(data as unknown as ApplicationWithJob[]);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch applications", err);
|
||||
setError("Não foi possível carregar suas candidaturas. Tente novamente.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending": return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80";
|
||||
case "reviewed": return "bg-blue-100 text-blue-800 hover:bg-blue-100/80";
|
||||
case "hired": return "bg-green-100 text-green-800 hover:bg-green-100/80";
|
||||
case "rejected": return "bg-red-100 text-red-800 hover:bg-red-100/80";
|
||||
default: return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
pending: "Em Análise",
|
||||
reviewed: "Visualizado",
|
||||
shortlisted: "Selecionado",
|
||||
hired: "Contratado",
|
||||
rejected: "Não Selecionado"
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const filteredApplications = applications.filter(app =>
|
||||
app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
app.companyName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Minhas Vagas</h1>
|
||||
<p>Funcionalidade em desenvolvimento.</p>
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<Navbar />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Minhas Candidaturas</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Acompanhe o status das vagas que você se candidatou.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:w-auto">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar vagas ou empresas..."
|
||||
className="pl-9 w-full md:w-[300px]"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground bg-muted/30 rounded-lg">
|
||||
<AlertCircle className="h-10 w-10 mb-4 text-destructive" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Erro ao carregar</h3>
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={fetchApplications}>
|
||||
Tentar Novamente
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredApplications.length === 0 ? (
|
||||
<div className="text-center py-16 bg-muted/20 rounded-lg border border-dashed">
|
||||
<div className="bg-primary/10 p-4 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
|
||||
<Search className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Nenhuma candidatura encontrada</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto mt-2 mb-6">
|
||||
Você ainda não se candidatou a nenhuma vaga. Explore as oportunidades disponíveis e comece sua jornada.
|
||||
</p>
|
||||
<Link href="/vagas">
|
||||
<Button>Explorar Vagas</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2">
|
||||
{filteredApplications.map((app) => (
|
||||
<Card key={app.id} className="overflow-hidden hover:shadow-md transition-shadow">
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl">
|
||||
<Link href={`/vagas/${app.jobId}`} className="hover:text-primary transition-colors">
|
||||
{app.jobTitle}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
{app.companyName}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={getStatusColor(app.status)} variant="secondary">
|
||||
{getStatusLabel(app.status)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
||||
<div className="flex items-center text-muted-foreground">
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
Aplicado em {format(new Date(app.createdAt), "dd 'de' MMMM, yyyy", { locale: ptBR })}
|
||||
</div>
|
||||
{/* TODO: Add logic for 'Last update' if needed */}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="bg-muted/50 p-4 flex justify-between items-center">
|
||||
<Link href={`/vagas/${app.jobId}`} className="text-sm font-medium text-primary hover:underline flex items-center">
|
||||
Ver Vaga <ExternalLink className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
{app.resumeUrl && (
|
||||
<Link
|
||||
href={app.resumeUrl}
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4"
|
||||
>
|
||||
Ver Currículo Enviado
|
||||
</Link>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
114
frontend/src/app/forgot-password/page.tsx
Normal file
114
frontend/src/app/forgot-password/page.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Mail, ArrowLeft, Loader2, CheckCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
|
||||
|
||||
type ForgotPasswordForm = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<ForgotPasswordForm>();
|
||||
|
||||
const onSubmit = async (data: ForgotPasswordForm) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/auth/forgot-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: data.email }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(errText || "Erro ao enviar solicitação");
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Erro desconhecido");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/30 to-background">
|
||||
<Navbar />
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Esqueci minha senha</CardTitle>
|
||||
<CardDescription>
|
||||
Informe seu email e enviaremos um link para redefinir sua senha.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitted ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
<p className="text-center text-muted-foreground">
|
||||
Se o email estiver cadastrado, você receberá um link de recuperação em breve.
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Voltar para o Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
className="pl-10"
|
||||
{...register("email", { required: "Email é obrigatório" })}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive text-center">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Enviar Link de Recuperação
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
{!submitted && (
|
||||
<CardFooter className="justify-center">
|
||||
<Link href="/login" className="text-sm text-muted-foreground hover:underline">
|
||||
<ArrowLeft className="w-3 h-3 inline mr-1" /> Voltar para o Login
|
||||
</Link>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
frontend/src/app/reset-password/page.tsx
Normal file
170
frontend/src/app/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Lock, ArrowLeft, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
|
||||
|
||||
type ResetPasswordForm = {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { register, handleSubmit, watch, formState: { errors } } = useForm<ResetPasswordForm>();
|
||||
const password = watch("password");
|
||||
|
||||
const onSubmit = async (data: ResetPasswordForm) => {
|
||||
if (!token) {
|
||||
setError("Token inválido ou ausente.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/auth/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, newPassword: data.password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(errText || "Erro ao redefinir senha");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Erro desconhecido");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<XCircle className="w-16 h-16 text-destructive" />
|
||||
<p className="text-center text-muted-foreground">
|
||||
Link inválido ou expirado. Por favor, solicite um novo link de recuperação.
|
||||
</p>
|
||||
<Link href="/forgot-password">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Solicitar Novo Link
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
<p className="text-center text-muted-foreground">
|
||||
Sua senha foi redefinida com sucesso!
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<Button>
|
||||
Ir para o Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Nova Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="pl-10"
|
||||
{...register("password", {
|
||||
required: "Senha é obrigatória",
|
||||
minLength: { value: 8, message: "Mínimo 8 caracteres" }
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmar Senha</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="pl-10"
|
||||
{...register("confirmPassword", {
|
||||
required: "Confirmação é obrigatória",
|
||||
validate: value => value === password || "Senhas não coincidem"
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{errors.confirmPassword && <p className="text-sm text-destructive">{errors.confirmPassword.message}</p>}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive text-center">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Redefinir Senha
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/30 to-background">
|
||||
<Navbar />
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Redefinir Senha</CardTitle>
|
||||
<CardDescription>
|
||||
Digite sua nova senha abaixo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense fallback={<Loader2 className="w-6 h-6 animate-spin mx-auto" />}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<Link href="/login" className="text-sm text-muted-foreground hover:underline">
|
||||
<ArrowLeft className="w-3 h-3 inline mr-1" /> Voltar para o Login
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import { Navbar } from "@/components/navbar";
|
|||
import { Footer } from "@/components/footer";
|
||||
import { useNotify } from "@/contexts/notification-context";
|
||||
import { mockJobs } from "@/lib/mock-data";
|
||||
import { storageApi, applicationsApi } from "@/lib/api";
|
||||
|
||||
// Definição Dos Passos
|
||||
const steps = [
|
||||
|
|
@ -162,16 +163,59 @@ export default function JobApplicationPage({
|
|||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
// Simular um chamado de API
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
notify.success(
|
||||
"Candidatura enviada com sucesso!",
|
||||
`Boa sorte! Sua candidatura para ${job.title} foi recebida.`
|
||||
);
|
||||
let resumeUrl = "";
|
||||
|
||||
router.push("/dashboard/candidato/candidaturas");
|
||||
// 1. Upload Curriculo
|
||||
if (formData.resume) {
|
||||
try {
|
||||
const { uploadUrl, publicUrl } = await storageApi.getUploadUrl(
|
||||
formData.resume.name,
|
||||
formData.resume.type
|
||||
);
|
||||
|
||||
await storageApi.uploadFile(uploadUrl, formData.resume);
|
||||
resumeUrl = publicUrl;
|
||||
} catch (err) {
|
||||
console.error("Upload error:", err);
|
||||
notify.error("Erro no upload", "Não foi possível enviar seu currículo. Tente novamente.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create Application
|
||||
await applicationsApi.create({
|
||||
jobId: Number(job.id),
|
||||
name: formData.fullName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
message: formData.coverLetter || formData.whyUs, // Using cover letter or whyUs as message
|
||||
resumeUrl: resumeUrl,
|
||||
documents: {
|
||||
linkedin: formData.linkedin,
|
||||
portfolio: formData.portfolioUrl,
|
||||
salaryExpectation: formData.salaryExpectation,
|
||||
availability: formData.availability,
|
||||
whyUs: formData.whyUs
|
||||
}
|
||||
});
|
||||
|
||||
notify.success(
|
||||
"Candidatura enviada com sucesso!",
|
||||
`Boa sorte! Sua candidatura para ${job.title} foi recebida.`
|
||||
);
|
||||
|
||||
router.push("/dashboard/candidato/candidaturas"); // Redirecionar para dashboard correto
|
||||
|
||||
} catch (error) {
|
||||
console.error("Application error:", error);
|
||||
notify.error("Erro ao enviar", "Ocorreu um erro ao processar sua candidatura.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
|
|
@ -241,10 +285,10 @@ export default function JobApplicationPage({
|
|||
<div
|
||||
key={step.id}
|
||||
className={`flex flex-col items-center gap-2 ${isActive
|
||||
? "text-primary"
|
||||
: isCompleted
|
||||
? "text-primary/60"
|
||||
: "text-muted-foreground"
|
||||
? "text-primary"
|
||||
: isCompleted
|
||||
? "text-primary/60"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -73,6 +73,14 @@ async function apiRequest<T>(
|
|||
export const usersApi = {
|
||||
list: () => apiRequest<ApiUser[]>("/api/v1/users"),
|
||||
|
||||
getMe: () => apiRequest<ApiUser & { bio?: string; skills?: string[]; experience?: any[]; education?: any[]; profilePictureUrl?: string; }>("/api/v1/users/me"),
|
||||
|
||||
updateMe: (data: any) =>
|
||||
apiRequest<ApiUser>("/api/v1/users/me", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
create: (data: { name: string; email: string; password: string; role: string }) =>
|
||||
apiRequest<ApiUser>("/api/v1/users", {
|
||||
method: "POST",
|
||||
|
|
@ -147,6 +155,77 @@ export const jobsApi = {
|
|||
getById: (id: number) => apiRequest<ApiJob>(`/jobs/${id}`),
|
||||
};
|
||||
|
||||
// Applications API
|
||||
export interface ApiApplication {
|
||||
id: number;
|
||||
jobId: number;
|
||||
userId?: number;
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
resumeUrl?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const applicationsApi = {
|
||||
create: (data: any) =>
|
||||
apiRequest<ApiApplication>("/api/v1/applications", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
list: (params?: { jobId?: number; userId?: number }) => { // Enhanced list to support potential user filtering if backend supports
|
||||
const query = new URLSearchParams();
|
||||
if (params?.jobId) query.set('jobId', String(params.jobId));
|
||||
// Note: Backend currently only supports jobId filter explicitly, but we prepared service for user list too.
|
||||
// If we need list by user, we might need a specific endpoint or update backend handler.
|
||||
// For now, let's assume filtering by jobId as primary use case.
|
||||
const queryStr = query.toString();
|
||||
return apiRequest<ApiApplication[]>(`/api/v1/applications${queryStr ? `?${queryStr}` : ''}`);
|
||||
},
|
||||
|
||||
listMyApplications: () => {
|
||||
// This is a new endpoint we need, or we filter on client if list returns all (bad idea)
|
||||
// or we assume backend ListHandler supports generic filtering.
|
||||
// Let's assume we call a specific endpoint or use a special param.
|
||||
// Given the Backend Service has `ListUserApplications`, we should expose it.
|
||||
// Let's try adding a query param user_id=me or similar if auth middleware injects it,
|
||||
// BUT the standard way mapped in router is GET /applications?jobId=...
|
||||
// We need to update backend to support "my applications".
|
||||
// For now, let's leave this placeholder or implement it properly.
|
||||
// Let's use a workaround: The backend handler `GetApplications` currently requires jobId.
|
||||
// We will update backend handler to be more flexible later.
|
||||
return apiRequest<ApiApplication[]>("/api/v1/applications/me"); // Proposed new endpoint
|
||||
}
|
||||
};
|
||||
|
||||
// Storage API
|
||||
export const storageApi = {
|
||||
getUploadUrl: (filename: string, contentType: string) =>
|
||||
apiRequest<{ uploadUrl: string; key: string; publicUrl: string }>(
|
||||
"/api/v1/storage/upload-url",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ filename, contentType })
|
||||
}
|
||||
),
|
||||
|
||||
uploadFile: async (uploadUrl: string, file: File) => {
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
mode: "cors" // Important for S3
|
||||
});
|
||||
if (!res.ok) throw new Error("Falha no upload para S3");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Jobs API (public)
|
||||
// Transform API job to frontend Job format
|
||||
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
|
||||
// Format salary
|
||||
|
|
|
|||
201
frontend/src/lib/sanitize.ts
Normal file
201
frontend/src/lib/sanitize.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Frontend Sanitization Utilities
|
||||
* Provides XSS protection and input validation for the client side
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escapes HTML entities to prevent XSS attacks
|
||||
*/
|
||||
export function escapeHtml(str: string): string {
|
||||
if (!str) return '';
|
||||
const htmlEntities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
return str.replace(/[&<>"'`=/]/g, char => htmlEntities[char] || char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips HTML tags from input
|
||||
*/
|
||||
export function stripHtml(str: string): string {
|
||||
if (!str) return '';
|
||||
return str.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string for safe display
|
||||
*/
|
||||
export function sanitizeString(str: string): string {
|
||||
if (!str) return '';
|
||||
return escapeHtml(str.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a name field (max 255 chars)
|
||||
*/
|
||||
export function sanitizeName(str: string, maxLength = 255): string {
|
||||
return sanitizeString(str).substring(0, maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an email address
|
||||
*/
|
||||
export function sanitizeEmail(str: string): string {
|
||||
if (!str) return '';
|
||||
return str.trim().toLowerCase().substring(0, 320);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a phone number (keeps only digits, +, -, space, parens)
|
||||
*/
|
||||
export function sanitizePhone(str: string): string {
|
||||
if (!str) return '';
|
||||
return str.replace(/[^0-9+\-\s()]/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a URL-safe slug
|
||||
*/
|
||||
export function createSlug(str: string): string {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove accents
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Remove consecutive hyphens
|
||||
.replace(/^-|-$/g, ''); // Trim hyphens from ends
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates document based on country (flexible for global portal)
|
||||
*/
|
||||
export function validateDocument(doc: string, countryCode?: string): { valid: boolean; message: string; clean: string } {
|
||||
const clean = doc.replace(/[^a-zA-Z0-9]/g, '');
|
||||
|
||||
if (!clean) {
|
||||
return { valid: true, message: 'Documento opcional', clean: '' };
|
||||
}
|
||||
|
||||
switch (countryCode?.toUpperCase()) {
|
||||
case 'BR':
|
||||
return validateBrazilianDocument(clean);
|
||||
case 'JP':
|
||||
if (clean.length === 13) {
|
||||
return { valid: true, message: '法人番号 válido', clean };
|
||||
}
|
||||
if (clean.length >= 5 && clean.length <= 20) {
|
||||
return { valid: true, message: 'Documento aceito', clean };
|
||||
}
|
||||
return { valid: false, message: 'Documento japonês inválido', clean };
|
||||
case 'US':
|
||||
if (clean.length === 9) {
|
||||
return { valid: true, message: 'EIN válido', clean };
|
||||
}
|
||||
return { valid: false, message: 'EIN deve ter 9 dígitos', clean };
|
||||
default:
|
||||
// Global mode - accept any reasonable document
|
||||
if (clean.length >= 5 && clean.length <= 30) {
|
||||
return { valid: true, message: 'Documento aceito', clean };
|
||||
}
|
||||
return { valid: false, message: 'Documento deve ter entre 5 e 30 caracteres', clean };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Brazilian CNPJ/CPF
|
||||
*/
|
||||
function validateBrazilianDocument(doc: string): { valid: boolean; message: string; clean: string } {
|
||||
if (doc.length === 14) {
|
||||
// CNPJ validation
|
||||
if (validateCNPJ(doc)) {
|
||||
return { valid: true, message: 'CNPJ válido', clean: doc };
|
||||
}
|
||||
return { valid: false, message: 'CNPJ inválido', clean: doc };
|
||||
}
|
||||
if (doc.length === 11) {
|
||||
// CPF validation
|
||||
if (validateCPF(doc)) {
|
||||
return { valid: true, message: 'CPF válido', clean: doc };
|
||||
}
|
||||
return { valid: false, message: 'CPF inválido', clean: doc };
|
||||
}
|
||||
return { valid: false, message: 'Documento brasileiro deve ter 11 (CPF) ou 14 (CNPJ) dígitos', clean: doc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Brazilian CNPJ checksum
|
||||
*/
|
||||
function validateCNPJ(cnpj: string): boolean {
|
||||
if (cnpj.length !== 14 || /^(\d)\1+$/.test(cnpj)) return false;
|
||||
|
||||
const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
|
||||
const calcDigit = (digits: string, weights: number[]): number => {
|
||||
const sum = weights.reduce((acc, w, i) => acc + parseInt(digits[i]) * w, 0);
|
||||
const remainder = sum % 11;
|
||||
return remainder < 2 ? 0 : 11 - remainder;
|
||||
};
|
||||
|
||||
const digit1 = calcDigit(cnpj, weights1);
|
||||
const digit2 = calcDigit(cnpj, weights2);
|
||||
|
||||
return parseInt(cnpj[12]) === digit1 && parseInt(cnpj[13]) === digit2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Brazilian CPF checksum
|
||||
*/
|
||||
function validateCPF(cpf: string): boolean {
|
||||
if (cpf.length !== 11 || /^(\d)\1+$/.test(cpf)) return false;
|
||||
|
||||
const calcDigit = (digits: string, factor: number): number => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < factor - 1; i++) {
|
||||
sum += parseInt(digits[i]) * (factor - i);
|
||||
}
|
||||
const remainder = sum % 11;
|
||||
return remainder < 2 ? 0 : 11 - remainder;
|
||||
};
|
||||
|
||||
const digit1 = calcDigit(cpf, 10);
|
||||
const digit2 = calcDigit(cpf, 11);
|
||||
|
||||
return parseInt(cpf[9]) === digit1 && parseInt(cpf[10]) === digit2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats CNPJ for display: 00.000.000/0000-00
|
||||
*/
|
||||
export function formatCNPJ(cnpj: string): string {
|
||||
const clean = cnpj.replace(/\D/g, '');
|
||||
if (clean.length !== 14) return cnpj;
|
||||
return clean.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats CPF for display: 000.000.000-00
|
||||
*/
|
||||
export function formatCPF(cpf: string): string {
|
||||
const clean = cpf.replace(/\D/g, '');
|
||||
if (clean.length !== 11) return cpf;
|
||||
return clean.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
|
||||
}
|
||||
Loading…
Reference in a new issue