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**
|
### 1. **Fluxo de Candidatura Completo**
|
||||||
```
|
```
|
||||||
[ ] Frontend: Botão "Candidatar-se" na página de vagas
|
[x] Frontend: Botão "Candidatar-se" na página de vagas
|
||||||
[ ] Frontend: Modal/Form para anexar currículo
|
[x] Frontend: Modal/Form para anexar currículo
|
||||||
[ ] Backend: Upload de currículo (PDF) para S3
|
[x] Backend: Upload de currículo (PDF) para S3
|
||||||
[ ] Backend: Notificação por email para empresa
|
[ ] 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**
|
### 2. **Gestão de Currículo/Perfil do Candidato**
|
||||||
```
|
```
|
||||||
[ ] Frontend: Página de edição de perfil completo
|
[x] Frontend: Página de edição de perfil completo
|
||||||
[ ] Backend: Endpoint PUT /api/v1/users/me
|
[x] Backend: Endpoint PUT /api/v1/users/me
|
||||||
[ ] Backend: Armazenar skills, experiências, educação
|
[x] Backend: Armazenar skills, experiências, educação
|
||||||
[ ] Frontend: Upload de foto de perfil
|
[x] Frontend: Upload de foto de perfil
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. **Dashboard da Empresa Funcional**
|
### 3. **Dashboard da Empresa Funcional**
|
||||||
```
|
```
|
||||||
[ ] Listar candidatos por vaga
|
[x] Listar candidatos por vaga
|
||||||
[ ] Alterar status da candidatura (aprovado/rejeitado/em análise)
|
[x] Alterar status da candidatura (aprovado/rejeitado/em análise)
|
||||||
[ ] Visualizar currículo do candidato
|
[x] Visualizar currículo do candidato
|
||||||
[ ] Exportar lista de candidatos
|
[ ] Exportar lista de candidatos
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. **Recuperação de Senha**
|
### 4. **Recuperação de Senha**
|
||||||
```
|
```
|
||||||
[ ] Frontend: Tela "Esqueci minha senha"
|
[x] Frontend: Tela "Esqueci minha senha"
|
||||||
[ ] Backend: Endpoint POST /api/v1/auth/forgot-password
|
[x] Backend: Endpoint POST /api/v1/auth/forgot-password
|
||||||
[ ] Backend: Integração com serviço de email
|
[x] Backend: Integração com serviço de email (Mock)
|
||||||
[ ] Backend: Endpoint POST /api/v1/auth/reset-password
|
[x] Backend: Endpoint POST /api/v1/auth/reset-password
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. **Validação de Dados**
|
### 5. **Validação de Dados**
|
||||||
```
|
```
|
||||||
[ ] Backend: Validação de email único
|
[x] Backend: Validação de email único
|
||||||
[ ] Backend: Validação de CNPJ para empresas
|
[x] Backend: Validação de documento global (CNPJ/CPF/EIN)
|
||||||
[ ] Frontend: Feedback de erros amigável
|
[x] Frontend: Feedback de erros amigável
|
||||||
[ ] Backend: Sanitização de inputs (XSS prevention)
|
[x] Backend: Sanitização de inputs (XSS prevention)
|
||||||
|
[x] Frontend: Utilitário sanitize.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -110,28 +111,35 @@
|
||||||
|
|
||||||
### 6. **Sistema de Notificações**
|
### 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: Tabela de notificações
|
||||||
[ ] Backend: FCM (Firebase Cloud Messaging) integration
|
[ ] Backend: FCM (Firebase Cloud Messaging) integration
|
||||||
[ ] Frontend: Badge de notificações no header
|
[x] Backend: Envio de email transacional (Mock)
|
||||||
[ ] Frontend: Lista de notificações
|
|
||||||
[ ] Backend: Envio de email transacional
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. **Busca e Filtros Avançados**
|
### 7. **Busca e Filtros Avançados**
|
||||||
```
|
```
|
||||||
[ ] Backend: Full-text search em vagas
|
[x] Backend: Full-text search em vagas (PostgreSQL plainto_tsquery)
|
||||||
[ ] Frontend: Filtros por localização, salário, tipo
|
[x] Backend: Filtros por localização, salário, tipo (workMode, employmentType)
|
||||||
[ ] Frontend: Ordenação por data/relevância
|
[x] Backend: Ordenação por data/salary/relevance
|
||||||
[ ] Backend: Paginação otimizada
|
[x] Backend: Paginação otimizada (max 100 items)
|
||||||
|
[ ] Frontend: UI de filtros avançados
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. **Painel Administrativo (Backoffice)**
|
### 8. **Painel Administrativo (Backoffice)**
|
||||||
```
|
```
|
||||||
[ ] Autenticação no backoffice
|
[x] Módulos AdminModule, PlansModule, StripeModule
|
||||||
[ ] CRUD de usuários via backoffice
|
[x] TicketsModule com proxy para backend
|
||||||
[ ] Relatórios de uso
|
[x] ActivityLogsModule com proxy para backend
|
||||||
[ ] Logs de atividade
|
[x] Dockerfile otimizado (multi-stage, non-root)
|
||||||
[ ] Gestão de tickets/suporte
|
[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**
|
### 9. **Métricas e Analytics**
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // 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/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // 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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
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=
|
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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,38 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type CoreHandlers struct {
|
type CoreHandlers struct {
|
||||||
loginUC *auth.LoginUseCase
|
loginUC *auth.LoginUseCase
|
||||||
createCompanyUC *tenant.CreateCompanyUseCase
|
createCompanyUC *tenant.CreateCompanyUseCase
|
||||||
createUserUC *user.CreateUserUseCase
|
createUserUC *user.CreateUserUseCase
|
||||||
listUsersUC *user.ListUsersUseCase
|
listUsersUC *user.ListUsersUseCase
|
||||||
deleteUserUC *user.DeleteUserUseCase
|
deleteUserUC *user.DeleteUserUseCase
|
||||||
listCompaniesUC *tenant.ListCompaniesUseCase
|
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{
|
return &CoreHandlers{
|
||||||
loginUC: l,
|
loginUC: l,
|
||||||
createCompanyUC: c,
|
createCompanyUC: c,
|
||||||
createUserUC: u,
|
createUserUC: u,
|
||||||
listUsersUC: list,
|
listUsersUC: list,
|
||||||
deleteUserUC: del,
|
deleteUserUC: del,
|
||||||
listCompaniesUC: lc,
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"})
|
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"
|
Status string `json:"status"` // "ACTIVE", "INACTIVE"
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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.
|
// 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 {
|
type UserResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateCompanyUseCase struct {
|
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) {
|
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.)
|
// 0. Sanitize inputs
|
||||||
// To be agnostic, let's assume NewCompany takes an ID. In real app, we might use a UUID generator service.
|
sanitizer := utils.DefaultSanitizer()
|
||||||
// For now, let's assume ID is generated by DB or we pass a placeholder if DB does it.
|
input.Name = sanitizer.SanitizeName(input.Name)
|
||||||
// Actually, the Entity `NewCompany` takes ID. I should generate one.
|
input.Contact = sanitizer.SanitizeString(input.Contact)
|
||||||
// But UseCase shouldn't rely on specific UUID lib ideally?
|
input.AdminEmail = sanitizer.SanitizeEmail(input.AdminEmail)
|
||||||
// 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.
|
|
||||||
|
|
||||||
// Implementation decision: Domain ID generation should be explicit.
|
// Validate name
|
||||||
// I'll assume input could pass it, or we rely on repo.
|
if input.Name == "" {
|
||||||
// Let's create the entity with empty ID and let Repo fill it? No, Entity usually needs Identity.
|
return nil, errors.New("nome da empresa é obrigatório")
|
||||||
// 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 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)
|
company := entity.NewCompany("", input.Name, &input.Document, &input.Contact)
|
||||||
|
|
||||||
savedCompany, err := uc.companyRepo.Save(ctx, company)
|
savedCompany, err := uc.companyRepo.Save(ctx, company)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,20 @@ package user
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
"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 {
|
type CreateUserUseCase struct {
|
||||||
userRepo ports.UserRepository
|
userRepo ports.UserRepository
|
||||||
authService ports.AuthService
|
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) {
|
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?)
|
// 1. Validate Email Uniqueness (within tenant? or global?)
|
||||||
// Usually email is unique global or per tenant. Let's assume unique.
|
// Usually email is unique global or per tenant. Let's assume unique.
|
||||||
exists, _ := uc.userRepo.FindByEmail(ctx, input.Email)
|
exists, _ := uc.userRepo.FindByEmail(ctx, input.Email)
|
||||||
if exists != nil {
|
if exists != nil {
|
||||||
return nil, errors.New("user already exists")
|
return nil, errors.New("email já cadastrado")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hash Password
|
// 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"`
|
RegionID *int `form:"regionId"`
|
||||||
CityID *int `form:"cityId"`
|
CityID *int `form:"cityId"`
|
||||||
EmploymentType *string `form:"employmentType"`
|
EmploymentType *string `form:"employmentType"`
|
||||||
|
WorkMode *string `form:"workMode"` // onsite, hybrid, remote
|
||||||
Status *string `form:"status"`
|
Status *string `form:"status"`
|
||||||
IsFeatured *bool `form:"isFeatured"` // Filter by featured status
|
IsFeatured *bool `form:"isFeatured"`
|
||||||
VisaSupport *bool `form:"visaSupport"`
|
VisaSupport *bool `form:"visaSupport"`
|
||||||
LanguageLevel *string `form:"languageLevel"`
|
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
|
// 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"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
"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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(app)
|
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
|
// @Tags Jobs
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param page query int false "Page number (default: 1)"
|
// @Param page query int false "Page number (default: 1)"
|
||||||
// @Param limit query int false "Items per page (default: 10, max: 100)"
|
// @Param limit query int false "Items per page (default: 10, max: 100)"
|
||||||
// @Param companyId query int false "Filter by company ID"
|
// @Param companyId query int false "Filter by company ID"
|
||||||
// @Param featured query bool false "Filter by featured status"
|
// @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
|
// @Success 200 {object} dto.PaginatedResponse
|
||||||
// @Failure 500 {string} string "Internal Server Error"
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
// @Router /api/v1/jobs [get]
|
// @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"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
companyID, _ := strconv.Atoi(r.URL.Query().Get("companyId"))
|
companyID, _ := strconv.Atoi(r.URL.Query().Get("companyId"))
|
||||||
isFeaturedStr := r.URL.Query().Get("featured")
|
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{
|
filter := dto.JobFilterQuery{
|
||||||
PaginationQuery: dto.PaginationQuery{
|
PaginationQuery: dto.PaginationQuery{
|
||||||
Page: page,
|
Page: page,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
},
|
},
|
||||||
|
SortBy: sortBy,
|
||||||
|
SortOrder: sortOrder,
|
||||||
}
|
}
|
||||||
|
|
||||||
if companyID > 0 {
|
if companyID > 0 {
|
||||||
filter.CompanyID = &companyID
|
filter.CompanyID = &companyID
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +75,28 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
val := true
|
val := true
|
||||||
filter.IsFeatured = &val
|
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)
|
jobs, total, err := h.Service.GetJobs(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -63,6 +104,13 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if page == 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
response := dto.PaginatedResponse{
|
response := dto.PaginatedResponse{
|
||||||
Data: jobs,
|
Data: jobs,
|
||||||
Pagination: dto.Pagination{
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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) {
|
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)
|
row := r.db.QueryRowContext(ctx, query, email)
|
||||||
|
|
||||||
u := &entity.User{}
|
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 != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil // Return nil if not found
|
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
|
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)
|
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
|
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)
|
row := r.db.QueryRowContext(ctx, query, id)
|
||||||
|
|
||||||
u := &entity.User{}
|
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 != nil {
|
||||||
return nil, err
|
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)
|
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||||
return u, nil
|
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 {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
// Populate roles N+1? Ideally join, but for now simple
|
|
||||||
u.Roles, _ = r.getRoles(ctx, u.ID)
|
u.Roles, _ = r.getRoles(ctx, u.ID)
|
||||||
users = append(users, u)
|
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) {
|
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()
|
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
|
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
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// User represents a system user (SuperAdmin, CompanyAdmin, Recruiter, or JobSeeker)
|
// User represents a system user (SuperAdmin, CompanyAdmin, Recruiter, or JobSeeker)
|
||||||
type User struct {
|
type User struct {
|
||||||
|
|
@ -24,38 +27,69 @@ type User struct {
|
||||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty" db:"last_login_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)
|
// UserResponse is the public representation of a user (without sensitive data)
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Identifier string `json:"identifier"`
|
Identifier string `json:"identifier"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
LineID *string `json:"lineId,omitempty"`
|
LineID *string `json:"lineId,omitempty"`
|
||||||
WhatsApp *string `json:"whatsapp,omitempty"`
|
WhatsApp *string `json:"whatsapp,omitempty"`
|
||||||
Instagram *string `json:"instagram,omitempty"`
|
Instagram *string `json:"instagram,omitempty"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
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
|
// ToResponse converts User to UserResponse
|
||||||
func (u *User) ToResponse() 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{
|
return UserResponse{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Identifier: u.Identifier,
|
Identifier: u.Identifier,
|
||||||
Role: u.Role,
|
Role: u.Role,
|
||||||
FullName: u.FullName,
|
FullName: u.FullName,
|
||||||
Phone: u.Phone,
|
Phone: u.Phone,
|
||||||
LineID: u.LineID,
|
LineID: u.LineID,
|
||||||
WhatsApp: u.WhatsApp,
|
WhatsApp: u.WhatsApp,
|
||||||
Instagram: u.Instagram,
|
Instagram: u.Instagram,
|
||||||
Language: u.Language,
|
Language: u.Language,
|
||||||
Active: u.Active,
|
Active: u.Active,
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
LastLoginAt: u.LastLoginAt,
|
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()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Initialize Services
|
// Initialize Services
|
||||||
|
emailService := services.NewMockEmailService()
|
||||||
jobService := services.NewJobService(database.DB)
|
jobService := services.NewJobService(database.DB)
|
||||||
applicationService := services.NewApplicationService(database.DB)
|
applicationService := services.NewApplicationService(database.DB, emailService)
|
||||||
|
|
||||||
// --- CORE ARCHITECTURE INITIALIZATION ---
|
// --- CORE ARCHITECTURE INITIALIZATION ---
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
|
|
@ -48,6 +49,15 @@ func NewRouter() http.Handler {
|
||||||
|
|
||||||
authService := authInfra.NewJWTService(jwtSecret, "todai-jobs")
|
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
|
// UseCases
|
||||||
loginUC := authUC.NewLoginUseCase(userRepo, authService)
|
loginUC := authUC.NewLoginUseCase(userRepo, authService)
|
||||||
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
|
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
|
||||||
|
|
@ -55,9 +65,15 @@ func NewRouter() http.Handler {
|
||||||
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
||||||
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
||||||
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
||||||
|
updateUserUC := userUC.NewUpdateUserUseCase(userRepo)
|
||||||
|
forgotPasswordUC := authUC.NewForgotPasswordUseCase(userRepo, tokenRepo, emailService, frontendURL)
|
||||||
|
resetPasswordUC := authUC.NewResetPasswordUseCase(userRepo, tokenRepo, authService)
|
||||||
|
|
||||||
// Handlers & Middleware
|
// 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)
|
authMiddleware := middleware.NewMiddleware(authService)
|
||||||
|
|
||||||
// Initialize Legacy Handlers
|
// Initialize Legacy Handlers
|
||||||
|
|
@ -120,6 +136,8 @@ func NewRouter() http.Handler {
|
||||||
// --- CORE ROUTES ---
|
// --- CORE ROUTES ---
|
||||||
// Public
|
// Public
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login)
|
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("POST /api/v1/companies", coreHandlers.CreateCompany)
|
||||||
mux.HandleFunc("GET /api/v1/companies", coreHandlers.ListCompanies)
|
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("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser)))
|
||||||
mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListUsers)))
|
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("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
|
// Job Routes
|
||||||
mux.HandleFunc("GET /api/v1/jobs", jobHandler.GetJobs)
|
mux.HandleFunc("GET /api/v1/jobs", jobHandler.GetJobs)
|
||||||
|
|
@ -139,6 +158,7 @@ func NewRouter() http.Handler {
|
||||||
|
|
||||||
// Application Routes
|
// Application Routes
|
||||||
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
|
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", applicationHandler.GetApplications)
|
||||||
mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID)
|
mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID)
|
||||||
mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus)
|
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")
|
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
|
// Swagger Route - available at /docs
|
||||||
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
|
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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||||
|
|
@ -9,11 +10,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApplicationService struct {
|
type ApplicationService struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
|
EmailService EmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApplicationService(db *sql.DB) *ApplicationService {
|
func NewApplicationService(db *sql.DB, emailService EmailService) *ApplicationService {
|
||||||
return &ApplicationService{DB: db}
|
return &ApplicationService{
|
||||||
|
DB: db,
|
||||||
|
EmailService: emailService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest) (*models.Application, error) {
|
func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest) (*models.Application, error) {
|
||||||
|
|
@ -51,6 +56,29 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
|
||||||
return nil, err
|
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
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,6 +109,41 @@ func (s *ApplicationService) GetApplications(jobID int) ([]models.Application, e
|
||||||
return apps, nil
|
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) {
|
func (s *ApplicationService) GetApplicationByID(id int) (*models.Application, error) {
|
||||||
var a models.Application
|
var a models.Application
|
||||||
query := `
|
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) {
|
func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) {
|
||||||
baseQuery := `
|
baseQuery := `
|
||||||
SELECT
|
SELECT
|
||||||
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
|
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,
|
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,
|
c.name as company_name, c.logo_url as company_logo_url,
|
||||||
r.name as region_name, ci.name as city_name
|
r.name as region_name, ci.name as city_name
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
LEFT JOIN companies c ON j.company_id = c.id
|
LEFT JOIN companies c ON j.company_id = c.id
|
||||||
LEFT JOIN regions r ON j.region_id = r.id
|
LEFT JOIN regions r ON j.region_id = r.id
|
||||||
LEFT JOIN cities ci ON j.city_id = ci.id
|
LEFT JOIN cities ci ON j.city_id = ci.id
|
||||||
WHERE 1=1`
|
WHERE 1=1`
|
||||||
countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1`
|
countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1`
|
||||||
|
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
argId := 1
|
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 {
|
if filter.CompanyID != nil {
|
||||||
baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
|
baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
|
||||||
countQuery += 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++
|
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 {
|
if filter.IsFeatured != nil {
|
||||||
baseQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
|
baseQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
|
||||||
countQuery += 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++
|
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
|
// Pagination
|
||||||
limit := filter.Limit
|
limit := filter.Limit
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
offset := (filter.Page - 1) * limit
|
offset := (filter.Page - 1) * limit
|
||||||
if offset < 0 {
|
if offset < 0 {
|
||||||
offset = 0
|
offset = 0
|
||||||
|
|
@ -120,7 +235,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
|
||||||
var j models.JobWithCompany
|
var j models.JobWithCompany
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
|
&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,
|
&j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, 0, err
|
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
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
RUN npm run build
|
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
|
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
|
WORKDIR /app
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
# Copy only production artifacts
|
||||||
COPY --from=builder /app/package*.json ./
|
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 NODE_ENV=production
|
||||||
ENV PORT=3001
|
ENV PORT=3001
|
||||||
|
|
||||||
|
# Use non-root user
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
EXPOSE 3001
|
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"]
|
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()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
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 { StripeModule } from './stripe';
|
||||||
import { PlansModule } from './plans';
|
import { PlansModule } from './plans';
|
||||||
import { AdminModule } from './admin';
|
import { AdminModule } from './admin';
|
||||||
|
import { TicketsModule } from './tickets';
|
||||||
|
import { ActivityLogsModule } from './activity-logs';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -12,8 +14,10 @@ import { AdminModule } from './admin';
|
||||||
StripeModule,
|
StripeModule,
|
||||||
PlansModule,
|
PlansModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
TicketsModule,
|
||||||
|
ActivityLogsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
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
|
import { useEffect, useState } from "react";
|
||||||
// Actually global layout handles sidebar/header.
|
import Link from "next/link";
|
||||||
// We just need the content.
|
import { format } from "date-fns";
|
||||||
// But wait, "My Jobs" page doesn't exist yet!
|
import { ptBR } from "date-fns/locale";
|
||||||
// I'll create a placeholder for now to prevent 404s on the new links.
|
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() {
|
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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="min-h-screen flex flex-col bg-background">
|
||||||
<h1 className="text-2xl font-bold mb-4">Minhas Vagas</h1>
|
<Navbar />
|
||||||
<p>Funcionalidade em desenvolvimento.</p>
|
|
||||||
|
<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>
|
</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 { Footer } from "@/components/footer";
|
||||||
import { useNotify } from "@/contexts/notification-context";
|
import { useNotify } from "@/contexts/notification-context";
|
||||||
import { mockJobs } from "@/lib/mock-data";
|
import { mockJobs } from "@/lib/mock-data";
|
||||||
|
import { storageApi, applicationsApi } from "@/lib/api";
|
||||||
|
|
||||||
// Definição Dos Passos
|
// Definição Dos Passos
|
||||||
const steps = [
|
const steps = [
|
||||||
|
|
@ -162,16 +163,59 @@ export default function JobApplicationPage({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setIsSubmitting(true);
|
try {
|
||||||
// Simular um chamado de API
|
setIsSubmitting(true);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
notify.success(
|
let resumeUrl = "";
|
||||||
"Candidatura enviada com sucesso!",
|
|
||||||
`Boa sorte! Sua candidatura para ${job.title} foi recebida.`
|
|
||||||
);
|
|
||||||
|
|
||||||
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 = () => {
|
const handleSaveDraft = () => {
|
||||||
|
|
@ -241,10 +285,10 @@ export default function JobApplicationPage({
|
||||||
<div
|
<div
|
||||||
key={step.id}
|
key={step.id}
|
||||||
className={`flex flex-col items-center gap-2 ${isActive
|
className={`flex flex-col items-center gap-2 ${isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
: isCompleted
|
: isCompleted
|
||||||
? "text-primary/60"
|
? "text-primary/60"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,14 @@ async function apiRequest<T>(
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
list: () => apiRequest<ApiUser[]>("/api/v1/users"),
|
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 }) =>
|
create: (data: { name: string; email: string; password: string; role: string }) =>
|
||||||
apiRequest<ApiUser>("/api/v1/users", {
|
apiRequest<ApiUser>("/api/v1/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -147,6 +155,77 @@ export const jobsApi = {
|
||||||
getById: (id: number) => apiRequest<ApiJob>(`/jobs/${id}`),
|
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
|
// Transform API job to frontend Job format
|
||||||
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
|
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
|
||||||
// Format salary
|
// Format salary
|
||||||
|
|
|
||||||
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