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:
Tiago Yamamoto 2025-12-27 11:19:47 -03:00
parent 254f19766a
commit 9ee9f6855c
53 changed files with 3773 additions and 148 deletions

View file

@ -62,43 +62,44 @@
### 1. **Fluxo de Candidatura Completo**
```
[ ] Frontend: Botão "Candidatar-se" na página de vagas
[ ] Frontend: Modal/Form para anexar currículo
[ ] Backend: Upload de currículo (PDF) para S3
[x] Frontend: Botão "Candidatar-se" na página de vagas
[x] Frontend: Modal/Form para anexar currículo
[x] Backend: Upload de currículo (PDF) para S3
[ ] Backend: Notificação por email para empresa
[ ] Frontend: Tela "Minhas Candidaturas" funcional
[x] Frontend: Tela "Minhas Candidaturas" funcional
```
### 2. **Gestão de Currículo/Perfil do Candidato**
```
[ ] Frontend: Página de edição de perfil completo
[ ] Backend: Endpoint PUT /api/v1/users/me
[ ] Backend: Armazenar skills, experiências, educação
[ ] Frontend: Upload de foto de perfil
[x] Frontend: Página de edição de perfil completo
[x] Backend: Endpoint PUT /api/v1/users/me
[x] Backend: Armazenar skills, experiências, educação
[x] Frontend: Upload de foto de perfil
```
### 3. **Dashboard da Empresa Funcional**
```
[ ] Listar candidatos por vaga
[ ] Alterar status da candidatura (aprovado/rejeitado/em análise)
[ ] Visualizar currículo do candidato
[x] Listar candidatos por vaga
[x] Alterar status da candidatura (aprovado/rejeitado/em análise)
[x] Visualizar currículo do candidato
[ ] Exportar lista de candidatos
```
### 4. **Recuperação de Senha**
```
[ ] Frontend: Tela "Esqueci minha senha"
[ ] Backend: Endpoint POST /api/v1/auth/forgot-password
[ ] Backend: Integração com serviço de email
[ ] Backend: Endpoint POST /api/v1/auth/reset-password
[x] Frontend: Tela "Esqueci minha senha"
[x] Backend: Endpoint POST /api/v1/auth/forgot-password
[x] Backend: Integração com serviço de email (Mock)
[x] Backend: Endpoint POST /api/v1/auth/reset-password
```
### 5. **Validação de Dados**
```
[ ] Backend: Validação de email único
[ ] Backend: Validação de CNPJ para empresas
[ ] Frontend: Feedback de erros amigável
[ ] Backend: Sanitização de inputs (XSS prevention)
[x] Backend: Validação de email único
[x] Backend: Validação de documento global (CNPJ/CPF/EIN)
[x] Frontend: Feedback de erros amigável
[x] Backend: Sanitização de inputs (XSS prevention)
[x] Frontend: Utilitário sanitize.ts
```
---
@ -110,28 +111,35 @@
### 6. **Sistema de Notificações**
```
[x] Frontend: NotificationContext e NotificationDropdown
[x] Frontend: Badge de notificações no header
[x] Frontend: Lista de notificações (mock data)
[ ] Backend: Tabela de notificações
[ ] Backend: FCM (Firebase Cloud Messaging) integration
[ ] Frontend: Badge de notificações no header
[ ] Frontend: Lista de notificações
[ ] Backend: Envio de email transacional
[x] Backend: Envio de email transacional (Mock)
```
### 7. **Busca e Filtros Avançados**
```
[ ] Backend: Full-text search em vagas
[ ] Frontend: Filtros por localização, salário, tipo
[ ] Frontend: Ordenação por data/relevância
[ ] Backend: Paginação otimizada
[x] Backend: Full-text search em vagas (PostgreSQL plainto_tsquery)
[x] Backend: Filtros por localização, salário, tipo (workMode, employmentType)
[x] Backend: Ordenação por data/salary/relevance
[x] Backend: Paginação otimizada (max 100 items)
[ ] Frontend: UI de filtros avançados
```
### 8. **Painel Administrativo (Backoffice)**
```
[ ] Autenticação no backoffice
[ ] CRUD de usuários via backoffice
[ ] Relatórios de uso
[ ] Logs de atividade
[ ] Gestão de tickets/suporte
[x] Módulos AdminModule, PlansModule, StripeModule
[x] TicketsModule com proxy para backend
[x] ActivityLogsModule com proxy para backend
[x] Dockerfile otimizado (multi-stage, non-root)
[x] Health endpoint
[ ] Autenticação via Guard
[ ] CRUD de usuários via backoffice (UI)
[x] Relatórios de uso (mock stats)
[x] Logs de atividade (integrado ao backend)
[x] Gestão de tickets/suporte (backend + backoffice)
```
### 9. **Métricas e Analytics**

View file

@ -18,6 +18,7 @@ require (
)
require (
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect

View file

@ -1,3 +1,5 @@
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
@ -76,6 +78,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View file

@ -12,22 +12,38 @@ import (
)
type CoreHandlers struct {
loginUC *auth.LoginUseCase
createCompanyUC *tenant.CreateCompanyUseCase
createUserUC *user.CreateUserUseCase
listUsersUC *user.ListUsersUseCase
deleteUserUC *user.DeleteUserUseCase
listCompaniesUC *tenant.ListCompaniesUseCase
loginUC *auth.LoginUseCase
createCompanyUC *tenant.CreateCompanyUseCase
createUserUC *user.CreateUserUseCase
listUsersUC *user.ListUsersUseCase
deleteUserUC *user.DeleteUserUseCase
updateUserUC *user.UpdateUserUseCase
listCompaniesUC *tenant.ListCompaniesUseCase
forgotPasswordUC *auth.ForgotPasswordUseCase
resetPasswordUC *auth.ResetPasswordUseCase
}
func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase) *CoreHandlers {
func NewCoreHandlers(
l *auth.LoginUseCase,
c *tenant.CreateCompanyUseCase,
u *user.CreateUserUseCase,
list *user.ListUsersUseCase,
del *user.DeleteUserUseCase,
upd *user.UpdateUserUseCase,
lc *tenant.ListCompaniesUseCase,
fp *auth.ForgotPasswordUseCase,
rp *auth.ResetPasswordUseCase,
) *CoreHandlers {
return &CoreHandlers{
loginUC: l,
createCompanyUC: c,
createUserUC: u,
listUsersUC: list,
deleteUserUC: del,
listCompaniesUC: lc,
loginUC: l,
createCompanyUC: c,
createUserUC: u,
listUsersUC: list,
deleteUserUC: del,
updateUserUC: upd,
listCompaniesUC: lc,
forgotPasswordUC: fp,
resetPasswordUC: rp,
}
}
@ -210,3 +226,91 @@ func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"})
}
// UpdateMe updates the profile of the logged-in user.
// @Summary Update Profile
// @Description Update the profile of the current user.
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body dto.UpdateUserRequest true "Profile Data"
// @Success 200 {object} dto.UserResponse
// @Failure 400 {string} string "Invalid Request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/users/me [put]
func (h *CoreHandlers) UpdateMe(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, ok := ctx.Value(middleware.ContextUserID).(string)
if !ok || userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req dto.UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid Request", http.StatusBadRequest)
return
}
resp, err := h.updateUserUC.Execute(ctx, userID, req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// ForgotPassword initiates password reset flow.
// @Summary Forgot Password
// @Description Sends a password reset link to the user's email.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.ForgotPasswordRequest true "Email"
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Invalid Request"
// @Router /api/v1/auth/forgot-password [post]
func (h *CoreHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) {
var req dto.ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid Request", http.StatusBadRequest)
return
}
// Always return success (security: don't reveal if email exists)
_ = h.forgotPasswordUC.Execute(r.Context(), req)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Se o email estiver cadastrado, você receberá um link de recuperação."})
}
// ResetPassword resets the user's password.
// @Summary Reset Password
// @Description Resets the user's password using a valid token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.ResetPasswordRequest true "Token and New Password"
// @Success 200 {object} map[string]string
// @Failure 400 {string} string "Invalid Request"
// @Failure 401 {string} string "Invalid or Expired Token"
// @Router /api/v1/auth/reset-password [post]
func (h *CoreHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req dto.ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid Request", http.StatusBadRequest)
return
}
if err := h.resetPasswordUC.Execute(r.Context(), req); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "Senha redefinida com sucesso."})
}

View 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)
}

View file

@ -13,6 +13,13 @@ type User struct {
Status string `json:"status"` // "ACTIVE", "INACTIVE"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Profile Profile
Bio string `json:"bio"`
ProfilePictureURL string `json:"profile_picture_url"`
Skills []string `json:"skills"` // Stored as JSONB, mapped to slice
Experience []any `json:"experience,omitempty"` // Flexible JSON structure
Education []any `json:"education,omitempty"` // Flexible JSON structure
}
// NewUser creates a new User instance.

View 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"`
}

View 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
}

View file

@ -20,10 +20,15 @@ type CreateUserRequest struct {
}
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
Bio string `json:"bio,omitempty"`
ProfilePictureURL string `json:"profilePictureUrl,omitempty"`
Skills []string `json:"skills,omitempty"`
Experience []any `json:"experience,omitempty"`
Education []any `json:"education,omitempty"`
}

View 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)
}

View file

@ -2,10 +2,12 @@ package tenant
import (
"context"
"errors"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
"github.com/rede5/gohorsejobs/backend/internal/utils"
)
type CreateCompanyUseCase struct {
@ -23,20 +25,29 @@ func NewCreateCompanyUseCase(cRepo ports.CompanyRepository, uRepo ports.UserRepo
}
func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCompanyRequest) (*dto.CompanyResponse, error) {
// 1. Create Company ID (Assuming UUID generated by Repo OR here. Let's assume Repo handles ID generation if empty, or we do it.)
// To be agnostic, let's assume NewCompany takes an ID. In real app, we might use a UUID generator service.
// For now, let's assume ID is generated by DB or we pass a placeholder if DB does it.
// Actually, the Entity `NewCompany` takes ID. I should generate one.
// But UseCase shouldn't rely on specific UUID lib ideally?
// I'll skip ID generation here and let Repo handle it or use a simple string for now.
// Better: Use a helper or just "new-uuid" string for now as placeholder for the generator logic.
// 0. Sanitize inputs
sanitizer := utils.DefaultSanitizer()
input.Name = sanitizer.SanitizeName(input.Name)
input.Contact = sanitizer.SanitizeString(input.Contact)
input.AdminEmail = sanitizer.SanitizeEmail(input.AdminEmail)
// Implementation decision: Domain ID generation should be explicit.
// I'll assume input could pass it, or we rely on repo.
// Let's create the entity with empty ID and let Repo fill it? No, Entity usually needs Identity.
// I'll generate a random ID here for simulation if I had a uuid lib.
// Since I want to be agnostic and dependency-free, I'll assume the Repo 'Save' returns the fully populated entity including ID.
// Validate name
if input.Name == "" {
return nil, errors.New("nome da empresa é obrigatório")
}
// Validate document (flexible for global portal)
// Use empty country code for global acceptance, or detect from input
docValidator := utils.NewDocumentValidator("") // Global mode
if input.Document != "" {
result := docValidator.ValidateDocument(input.Document, "")
if !result.Valid {
return nil, errors.New(result.Message)
}
input.Document = result.Clean
}
// 1. Create Company Entity
company := entity.NewCompany("", input.Name, &input.Document, &input.Contact)
savedCompany, err := uc.companyRepo.Save(ctx, company)

View file

@ -3,12 +3,20 @@ package user
import (
"context"
"errors"
"regexp"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
"github.com/rede5/gohorsejobs/backend/internal/utils"
)
// isValidEmail validates email format
func isValidEmail(email string) bool {
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return emailRegex.MatchString(email)
}
type CreateUserUseCase struct {
userRepo ports.UserRepository
authService ports.AuthService
@ -22,11 +30,21 @@ func NewCreateUserUseCase(uRepo ports.UserRepository, auth ports.AuthService) *C
}
func (uc *CreateUserUseCase) Execute(ctx context.Context, input dto.CreateUserRequest, currentTenantID string) (*dto.UserResponse, error) {
// 0. Sanitize inputs
sanitizer := utils.DefaultSanitizer()
input.Name = sanitizer.SanitizeName(input.Name)
input.Email = sanitizer.SanitizeEmail(input.Email)
// Validate email format
if input.Email == "" || !isValidEmail(input.Email) {
return nil, errors.New("email inválido")
}
// 1. Validate Email Uniqueness (within tenant? or global?)
// Usually email is unique global or per tenant. Let's assume unique.
exists, _ := uc.userRepo.FindByEmail(ctx, input.Email)
if exists != nil {
return nil, errors.New("user already exists")
return nil, errors.New("email já cadastrado")
}
// 2. Hash Password

View 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
}

View file

@ -114,11 +114,24 @@ type JobFilterQuery struct {
RegionID *int `form:"regionId"`
CityID *int `form:"cityId"`
EmploymentType *string `form:"employmentType"`
WorkMode *string `form:"workMode"` // onsite, hybrid, remote
Status *string `form:"status"`
IsFeatured *bool `form:"isFeatured"` // Filter by featured status
IsFeatured *bool `form:"isFeatured"`
VisaSupport *bool `form:"visaSupport"`
LanguageLevel *string `form:"languageLevel"`
Search *string `form:"search"`
Search *string `form:"search"` // Full-text search query
// Salary filters
SalaryMin *float64 `form:"salaryMin"`
SalaryMax *float64 `form:"salaryMax"`
SalaryType *string `form:"salaryType"` // hourly, monthly, yearly
// Location text search
LocationSearch *string `form:"location"`
// Sorting
SortBy string `form:"sortBy"` // date, salary, relevance
SortOrder string `form:"sortOrder"` // asc, desc (default: desc)
}
// PaginatedResponse represents a paginated API response

View 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)
}

View file

@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
"github.com/rede5/gohorsejobs/backend/internal/dto"
"github.com/rede5/gohorsejobs/backend/internal/services"
)
@ -144,3 +145,21 @@ func (h *ApplicationHandler) UpdateApplicationStatus(w http.ResponseWriter, r *h
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(app)
}
// ListUserApplications lists applications for the logged in user
func (h *ApplicationHandler) ListUserApplications(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(middleware.ContextUserID).(string) // Corrected Key
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
apps, err := h.Service.ListUserApplications(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(apps)
}

View file

@ -30,10 +30,18 @@ func NewJobHandler(service *services.JobService) *JobHandler {
// @Tags Jobs
// @Accept json
// @Produce json
// @Param page query int false "Page number (default: 1)"
// @Param limit query int false "Items per page (default: 10, max: 100)"
// @Param companyId query int false "Filter by company ID"
// @Param featured query bool false "Filter by featured status"
// @Param page query int false "Page number (default: 1)"
// @Param limit query int false "Items per page (default: 10, max: 100)"
// @Param companyId query int false "Filter by company ID"
// @Param featured query bool false "Filter by featured status"
// @Param search query string false "Full-text search query"
// @Param employmentType query string false "Filter by employment type"
// @Param workMode query string false "Filter by work mode (onsite, hybrid, remote)"
// @Param location query string false "Filter by location text"
// @Param salaryMin query number false "Minimum salary filter"
// @Param salaryMax query number false "Maximum salary filter"
// @Param sortBy query string false "Sort by: date, salary, relevance"
// @Param sortOrder query string false "Sort order: asc, desc"
// @Success 200 {object} dto.PaginatedResponse
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/jobs [get]
@ -42,13 +50,24 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
companyID, _ := strconv.Atoi(r.URL.Query().Get("companyId"))
isFeaturedStr := r.URL.Query().Get("featured")
search := r.URL.Query().Get("search")
employmentType := r.URL.Query().Get("employmentType")
workMode := r.URL.Query().Get("workMode")
location := r.URL.Query().Get("location")
salaryMinStr := r.URL.Query().Get("salaryMin")
salaryMaxStr := r.URL.Query().Get("salaryMax")
sortBy := r.URL.Query().Get("sortBy")
sortOrder := r.URL.Query().Get("sortOrder")
filter := dto.JobFilterQuery{
PaginationQuery: dto.PaginationQuery{
Page: page,
Limit: limit,
},
SortBy: sortBy,
SortOrder: sortOrder,
}
if companyID > 0 {
filter.CompanyID = &companyID
}
@ -56,6 +75,28 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
val := true
filter.IsFeatured = &val
}
if search != "" {
filter.Search = &search
}
if employmentType != "" {
filter.EmploymentType = &employmentType
}
if workMode != "" {
filter.WorkMode = &workMode
}
if location != "" {
filter.LocationSearch = &location
}
if salaryMinStr != "" {
if val, err := strconv.ParseFloat(salaryMinStr, 64); err == nil {
filter.SalaryMin = &val
}
}
if salaryMaxStr != "" {
if val, err := strconv.ParseFloat(salaryMaxStr, 64); err == nil {
filter.SalaryMax = &val
}
}
jobs, total, err := h.Service.GetJobs(filter)
if err != nil {
@ -63,6 +104,13 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) {
return
}
if page == 0 {
page = 1
}
if limit == 0 {
limit = 10
}
response := dto.PaginatedResponse{
Data: jobs,
Pagination: dto.Pagination{

View 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)
}

View file

@ -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
}

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
@ -66,11 +67,21 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE email = $1`
query := `
SELECT
id, tenant_id, name, email, password_hash, status, created_at, updated_at,
bio, profile_picture_url, skills, experience, education
FROM core_users WHERE email = $1
`
row := r.db.QueryRowContext(ctx, query, email)
u := &entity.User{}
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
var skills, experience, education []byte // temp for Scanning
err := row.Scan(
&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt,
&u.Bio, &u.ProfilePictureURL, &skills, &experience, &education,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // Return nil if not found
@ -78,20 +89,52 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
return nil, err
}
// Unmarshal JSONB fields
if len(skills) > 0 {
_ = json.Unmarshal(skills, &u.Skills)
}
if len(experience) > 0 {
_ = json.Unmarshal(experience, &u.Experience)
}
if len(education) > 0 {
_ = json.Unmarshal(education, &u.Education)
}
u.Roles, _ = r.getRoles(ctx, u.ID)
return u, nil
}
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE id = $1`
query := `
SELECT
id, tenant_id, name, email, password_hash, status, created_at, updated_at,
bio, profile_picture_url, skills, experience, education
FROM core_users WHERE id = $1
`
row := r.db.QueryRowContext(ctx, query, id)
u := &entity.User{}
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
var skills, experience, education []byte // temp for Scanning
err := row.Scan(
&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt,
&u.Bio, &u.ProfilePictureURL, &skills, &experience, &education,
)
if err != nil {
return nil, err
}
// Unmarshal JSONB fields
if len(skills) > 0 {
_ = json.Unmarshal(skills, &u.Skills)
}
if len(experience) > 0 {
_ = json.Unmarshal(experience, &u.Experience)
}
if len(education) > 0 {
_ = json.Unmarshal(education, &u.Education)
}
u.Roles, _ = r.getRoles(ctx, u.ID)
return u, nil
}
@ -110,7 +153,6 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) (
if err := rows.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
// Populate roles N+1? Ideally join, but for now simple
u.Roles, _ = r.getRoles(ctx, u.ID)
users = append(users, u)
}
@ -118,10 +160,23 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string) (
}
func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) {
// Not fully implemented for roles update for brevity, just fields
user.UpdatedAt = time.Now()
query := `UPDATE core_users SET name=$1, email=$2, status=$3, updated_at=$4 WHERE id=$5`
_, err := r.db.ExecContext(ctx, query, user.Name, user.Email, user.Status, user.UpdatedAt, user.ID)
skillsJSON, _ := json.Marshal(user.Skills)
experienceJSON, _ := json.Marshal(user.Experience)
educationJSON, _ := json.Marshal(user.Education)
query := `
UPDATE core_users
SET name=$1, email=$2, status=$3, updated_at=$4,
bio=$5, profile_picture_url=$6, skills=$7, experience=$8, education=$9
WHERE id=$10
`
_, err := r.db.ExecContext(ctx, query,
user.Name, user.Email, user.Status, user.UpdatedAt,
user.Bio, user.ProfilePictureURL, skillsJSON, experienceJSON, educationJSON,
user.ID,
)
return user, err
}

View 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"`
}

View 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
}

View file

@ -1,6 +1,9 @@
package models
import "time"
import (
"encoding/json"
"time"
)
// User represents a system user (SuperAdmin, CompanyAdmin, Recruiter, or JobSeeker)
type User struct {
@ -24,38 +27,69 @@ type User struct {
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty" db:"last_login_at"`
// Profile Profile
Bio *string `json:"bio,omitempty" db:"bio"`
ProfilePictureURL *string `json:"profilePictureUrl,omitempty" db:"profile_picture_url"`
Skills []byte `json:"skills,omitempty" db:"skills"` // JSONB
Experience []byte `json:"experience,omitempty" db:"experience"` // JSONB
Education []byte `json:"education,omitempty" db:"education"` // JSONB
}
// UserResponse is the public representation of a user (without sensitive data)
type UserResponse struct {
ID int `json:"id"`
Identifier string `json:"identifier"`
Role string `json:"role"`
FullName string `json:"fullName"`
Phone *string `json:"phone,omitempty"`
LineID *string `json:"lineId,omitempty"`
WhatsApp *string `json:"whatsapp,omitempty"`
Instagram *string `json:"instagram,omitempty"`
Language string `json:"language"`
Active bool `json:"active"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
ID int `json:"id"`
Identifier string `json:"identifier"`
Role string `json:"role"`
FullName string `json:"fullName"`
Phone *string `json:"phone,omitempty"`
LineID *string `json:"lineId,omitempty"`
WhatsApp *string `json:"whatsapp,omitempty"`
Instagram *string `json:"instagram,omitempty"`
Language string `json:"language"`
Active bool `json:"active"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
Bio *string `json:"bio,omitempty"`
ProfilePictureURL *string `json:"profilePictureUrl,omitempty"`
Skills []string `json:"skills,omitempty"`
Experience []any `json:"experience,omitempty"`
Education []any `json:"education,omitempty"`
}
// ToResponse converts User to UserResponse
func (u *User) ToResponse() UserResponse {
// Helper to unmarshal JSONB
var skills []string
if len(u.Skills) > 0 {
_ = json.Unmarshal(u.Skills, &skills)
}
var experience []any
if len(u.Experience) > 0 {
_ = json.Unmarshal(u.Experience, &experience)
}
var education []any
if len(u.Education) > 0 {
_ = json.Unmarshal(u.Education, &education)
}
return UserResponse{
ID: u.ID,
Identifier: u.Identifier,
Role: u.Role,
FullName: u.FullName,
Phone: u.Phone,
LineID: u.LineID,
WhatsApp: u.WhatsApp,
Instagram: u.Instagram,
Language: u.Language,
Active: u.Active,
CreatedAt: u.CreatedAt,
LastLoginAt: u.LastLoginAt,
ID: u.ID,
Identifier: u.Identifier,
Role: u.Role,
FullName: u.FullName,
Phone: u.Phone,
LineID: u.LineID,
WhatsApp: u.WhatsApp,
Instagram: u.Instagram,
Language: u.Language,
Active: u.Active,
CreatedAt: u.CreatedAt,
LastLoginAt: u.LastLoginAt,
Bio: u.Bio,
ProfilePictureURL: u.ProfilePictureURL,
Skills: skills,
Experience: experience,
Education: education,
}
}

View file

@ -31,8 +31,9 @@ func NewRouter() http.Handler {
mux := http.NewServeMux()
// Initialize Services
emailService := services.NewMockEmailService()
jobService := services.NewJobService(database.DB)
applicationService := services.NewApplicationService(database.DB)
applicationService := services.NewApplicationService(database.DB, emailService)
// --- CORE ARCHITECTURE INITIALIZATION ---
// Infrastructure
@ -48,6 +49,15 @@ func NewRouter() http.Handler {
authService := authInfra.NewJWTService(jwtSecret, "todai-jobs")
// Token Repository for Password Reset
tokenRepo := postgres.NewPasswordResetTokenRepository(database.DB)
// Frontend URL for reset link
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
// UseCases
loginUC := authUC.NewLoginUseCase(userRepo, authService)
createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService)
@ -55,9 +65,15 @@ func NewRouter() http.Handler {
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
listUsersUC := userUC.NewListUsersUseCase(userRepo)
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
updateUserUC := userUC.NewUpdateUserUseCase(userRepo)
forgotPasswordUC := authUC.NewForgotPasswordUseCase(userRepo, tokenRepo, emailService, frontendURL)
resetPasswordUC := authUC.NewResetPasswordUseCase(userRepo, tokenRepo, authService)
// Handlers & Middleware
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC)
coreHandlers := apiHandlers.NewCoreHandlers(
loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, updateUserUC, listCompaniesUC,
forgotPasswordUC, resetPasswordUC,
)
authMiddleware := middleware.NewMiddleware(authService)
// Initialize Legacy Handlers
@ -120,6 +136,8 @@ func NewRouter() http.Handler {
// --- CORE ROUTES ---
// Public
mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login)
mux.HandleFunc("POST /api/v1/auth/forgot-password", coreHandlers.ForgotPassword)
mux.HandleFunc("POST /api/v1/auth/reset-password", coreHandlers.ResetPassword)
mux.HandleFunc("POST /api/v1/companies", coreHandlers.CreateCompany)
mux.HandleFunc("GET /api/v1/companies", coreHandlers.ListCompanies)
@ -129,6 +147,7 @@ func NewRouter() http.Handler {
mux.Handle("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser)))
mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListUsers)))
mux.Handle("DELETE /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteUser)))
mux.Handle("PUT /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMe))) // New Profile Update
// Job Routes
mux.HandleFunc("GET /api/v1/jobs", jobHandler.GetJobs)
@ -139,6 +158,7 @@ func NewRouter() http.Handler {
// Application Routes
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.ListUserApplications))) // New endpoint
mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications)
mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID)
mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus)
@ -156,6 +176,23 @@ func NewRouter() http.Handler {
log.Println("S3 storage routes registered successfully")
}
// --- TICKET ROUTES ---
ticketService := services.NewTicketService(database.DB)
ticketHandler := handlers.NewTicketHandler(ticketService)
mux.HandleFunc("GET /api/v1/tickets/stats", ticketHandler.GetTicketStats)
mux.HandleFunc("GET /api/v1/tickets", ticketHandler.GetTickets)
mux.HandleFunc("POST /api/v1/tickets", ticketHandler.CreateTicket)
mux.HandleFunc("GET /api/v1/tickets/{id}", ticketHandler.GetTicketByID)
mux.HandleFunc("PUT /api/v1/tickets/{id}", ticketHandler.UpdateTicket)
mux.HandleFunc("GET /api/v1/tickets/{id}/messages", ticketHandler.GetTicketMessages)
mux.HandleFunc("POST /api/v1/tickets/{id}/messages", ticketHandler.AddTicketMessage)
// --- ACTIVITY LOG ROUTES ---
activityLogService := services.NewActivityLogService(database.DB)
activityLogHandler := handlers.NewActivityLogHandler(activityLogService)
mux.HandleFunc("GET /api/v1/activity-logs/stats", activityLogHandler.GetActivityLogStats)
mux.HandleFunc("GET /api/v1/activity-logs", activityLogHandler.GetActivityLogs)
// Swagger Route - available at /docs
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)

View 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
}

View file

@ -2,6 +2,7 @@ package services
import (
"database/sql"
"fmt"
"time"
"github.com/rede5/gohorsejobs/backend/internal/dto"
@ -9,11 +10,15 @@ import (
)
type ApplicationService struct {
DB *sql.DB
DB *sql.DB
EmailService EmailService
}
func NewApplicationService(db *sql.DB) *ApplicationService {
return &ApplicationService{DB: db}
func NewApplicationService(db *sql.DB, emailService EmailService) *ApplicationService {
return &ApplicationService{
DB: db,
EmailService: emailService,
}
}
func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest) (*models.Application, error) {
@ -51,6 +56,29 @@ func (s *ApplicationService) CreateApplication(req dto.CreateApplicationRequest)
return nil, err
}
// Notify Company (Mock)
go func() {
name := ""
if app.Name != nil {
name = *app.Name
}
email := ""
if app.Email != nil {
email = *app.Email
}
phone := ""
if app.Phone != nil {
phone = *app.Phone
}
subject := fmt.Sprintf("Nova candidatura para a vaga #%d", app.JobID)
body := fmt.Sprintf("Olá,\n\nVocê recebeu uma nova candidatura de %s para a vaga #%d.\n\nEmail: %s\nTelefone: %s\n\nVerifique o painel para mais detalhes.", name, app.JobID, email, phone)
// TODO: In real scenario, we would fetch company email from job->company relation
_ = s.EmailService.SendEmail("company@example.com", subject, body)
}()
return app, nil
}
@ -81,6 +109,41 @@ func (s *ApplicationService) GetApplications(jobID int) ([]models.Application, e
return apps, nil
}
func (s *ApplicationService) ListUserApplications(userID string) ([]models.ApplicationWithDetails, error) {
query := `
SELECT
a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email,
a.message, a.resume_url, a.status, a.created_at, a.updated_at,
j.title, c.name
FROM applications a
JOIN jobs j ON a.job_id = j.id
LEFT JOIN companies c ON j.company_id = c.id
WHERE a.user_id = $1
ORDER BY a.created_at DESC
`
rows, err := s.DB.Query(query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var apps []models.ApplicationWithDetails
for rows.Next() {
var a models.ApplicationWithDetails
if err := rows.Scan(
&a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email,
&a.Message, &a.ResumeURL, &a.Status, &a.CreatedAt, &a.UpdatedAt,
&a.JobTitle, &a.CompanyName,
); err != nil {
return nil, err
}
// Some logical defaults if needed
a.CompanyID = 0 // We didn't fetch it but could if needed
apps = append(apps, a)
}
return apps, nil
}
func (s *ApplicationService) GetApplicationByID(id int) (*models.Application, error) {
var a models.Application
query := `

View 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")
}
}

View 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(...) ...

View file

@ -65,21 +65,36 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest) (*models.Job, error) {
func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) {
baseQuery := `
SELECT
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.created_at, j.updated_at,
c.name as company_name, c.logo_url as company_logo_url,
r.name as region_name, ci.name as city_name
FROM jobs j
LEFT JOIN companies c ON j.company_id = c.id
LEFT JOIN regions r ON j.region_id = r.id
LEFT JOIN cities ci ON j.city_id = ci.id
WHERE 1=1`
SELECT
j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type,
j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.created_at, j.updated_at,
c.name as company_name, c.logo_url as company_logo_url,
r.name as region_name, ci.name as city_name
FROM jobs j
LEFT JOIN companies c ON j.company_id = c.id
LEFT JOIN regions r ON j.region_id = r.id
LEFT JOIN cities ci ON j.city_id = ci.id
WHERE 1=1`
countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1`
var args []interface{}
argId := 1
// Full-text search on title and description
if filter.Search != nil && *filter.Search != "" {
searchClause := fmt.Sprintf(` AND (
to_tsvector('portuguese', COALESCE(j.title, '') || ' ' || COALESCE(j.description, ''))
@@ plainto_tsquery('portuguese', $%d)
OR j.title ILIKE '%%' || $%d || '%%'
OR j.description ILIKE '%%' || $%d || '%%'
)`, argId, argId, argId)
baseQuery += searchClause
countQuery += searchClause
args = append(args, *filter.Search)
argId++
}
// Company filter
if filter.CompanyID != nil {
baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
countQuery += fmt.Sprintf(" AND j.company_id = $%d", argId)
@ -87,6 +102,47 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
argId++
}
// Region filter
if filter.RegionID != nil {
baseQuery += fmt.Sprintf(" AND j.region_id = $%d", argId)
countQuery += fmt.Sprintf(" AND j.region_id = $%d", argId)
args = append(args, *filter.RegionID)
argId++
}
// City filter
if filter.CityID != nil {
baseQuery += fmt.Sprintf(" AND j.city_id = $%d", argId)
countQuery += fmt.Sprintf(" AND j.city_id = $%d", argId)
args = append(args, *filter.CityID)
argId++
}
// Employment type filter
if filter.EmploymentType != nil && *filter.EmploymentType != "" {
baseQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId)
countQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId)
args = append(args, *filter.EmploymentType)
argId++
}
// Work mode filter (onsite, hybrid, remote)
if filter.WorkMode != nil && *filter.WorkMode != "" {
baseQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId)
countQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId)
args = append(args, *filter.WorkMode)
argId++
}
// Status filter
if filter.Status != nil && *filter.Status != "" {
baseQuery += fmt.Sprintf(" AND j.status = $%d", argId)
countQuery += fmt.Sprintf(" AND j.status = $%d", argId)
args = append(args, *filter.Status)
argId++
}
// Featured filter
if filter.IsFeatured != nil {
baseQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
countQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId)
@ -94,13 +150,72 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
argId++
}
// Add more filters as needed...
// Visa support filter
if filter.VisaSupport != nil {
baseQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
countQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
args = append(args, *filter.VisaSupport)
argId++
}
// Salary range filters
if filter.SalaryMin != nil {
baseQuery += fmt.Sprintf(" AND (j.salary_max >= $%d OR j.salary_min >= $%d)", argId, argId)
countQuery += fmt.Sprintf(" AND (j.salary_max >= $%d OR j.salary_min >= $%d)", argId, argId)
args = append(args, *filter.SalaryMin)
argId++
}
if filter.SalaryMax != nil {
baseQuery += fmt.Sprintf(" AND (j.salary_min <= $%d OR j.salary_min IS NULL)", argId)
countQuery += fmt.Sprintf(" AND (j.salary_min <= $%d OR j.salary_min IS NULL)", argId)
args = append(args, *filter.SalaryMax)
argId++
}
if filter.SalaryType != nil && *filter.SalaryType != "" {
baseQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId)
countQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId)
args = append(args, *filter.SalaryType)
argId++
}
// Location text search
if filter.LocationSearch != nil && *filter.LocationSearch != "" {
baseQuery += fmt.Sprintf(" AND j.location ILIKE '%%' || $%d || '%%'", argId)
countQuery += fmt.Sprintf(" AND j.location ILIKE '%%' || $%d || '%%'", argId)
args = append(args, *filter.LocationSearch)
argId++
}
// Sorting
orderClause := " ORDER BY "
switch filter.SortBy {
case "salary":
orderClause += "COALESCE(j.salary_max, j.salary_min, 0)"
case "relevance":
if filter.Search != nil && *filter.Search != "" {
orderClause += fmt.Sprintf("ts_rank(to_tsvector('portuguese', COALESCE(j.title, '') || ' ' || COALESCE(j.description, '')), plainto_tsquery('portuguese', '%s'))", *filter.Search)
} else {
orderClause += "j.is_featured DESC, j.created_at"
}
default: // date
orderClause += "j.is_featured DESC, j.created_at"
}
if filter.SortOrder == "asc" {
orderClause += " ASC"
} else {
orderClause += " DESC"
}
baseQuery += orderClause
// Pagination
limit := filter.Limit
if limit == 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
offset := (filter.Page - 1) * limit
if offset < 0 {
offset = 0
@ -120,7 +235,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
var j models.JobWithCompany
if err := rows.Scan(
&j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType,
&j.EmploymentType, &j.Location, &j.Status, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt,
&j.EmploymentType, &j.WorkMode, &j.Location, &j.Status, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt,
&j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName,
); err != nil {
return nil, 0, err

View 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
}

View 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}
}

View 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 '[]';

View 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';

View 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';

View 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';

View file

@ -1,16 +1,54 @@
# ================================================
# Stage 1: Build
# ================================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Copy package files first for better layer caching
COPY package.json package-lock.json ./
# Install all dependencies (including dev for build)
RUN npm ci --legacy-peer-deps
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Prune dev dependencies for smaller production image
RUN npm prune --production
# ================================================
# Stage 2: Production Runtime
# ================================================
FROM node:20-alpine AS production
# Add non-root user for security
RUN addgroup -g 1001 -S nodejs \
&& adduser -S nestjs -u 1001
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
# Copy only production artifacts
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
# Set environment
ENV NODE_ENV=production
ENV PORT=3001
# Use non-root user
USER nestjs
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" || exit 1
# Start application
CMD ["node", "dist/main.js"]

View 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,
});
}
}

View 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 { }

View 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 || [];
}
}

View file

@ -0,0 +1,3 @@
export * from './activity-logs.module';
export * from './activity-logs.service';
export * from './activity-logs.controller';

View file

@ -3,10 +3,18 @@ import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
constructor(private readonly appService: AppService) { }
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('health')
getHealth(): { status: string; timestamp: string } {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -5,6 +5,8 @@ import { AppService } from './app.service';
import { StripeModule } from './stripe';
import { PlansModule } from './plans';
import { AdminModule } from './admin';
import { TicketsModule } from './tickets';
import { ActivityLogsModule } from './activity-logs';
@Module({
imports: [
@ -12,8 +14,10 @@ import { AdminModule } from './admin';
StripeModule,
PlansModule,
AdminModule,
TicketsModule,
ActivityLogsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
export class AppModule { }

View file

@ -0,0 +1,3 @@
export * from './tickets.module';
export * from './tickets.service';
export * from './tickets.controller';

View 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);
}
}

View 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 { }

View 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 || [];
}
}

View 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>
);
}

View file

@ -1,16 +1,190 @@
"use client"
"use client";
import { DashboardHeader } from "@/components/dashboard-header" // Keep header if we want consistent layout, though global layout handles it
// Actually global layout handles sidebar/header.
// We just need the content.
// But wait, "My Jobs" page doesn't exist yet!
// I'll create a placeholder for now to prevent 404s on the new links.
import { useEffect, useState } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
Building2,
CalendarDays,
MapPin,
Search,
ExternalLink,
Loader2,
AlertCircle
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardFooter
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { applicationsApi, ApiApplication } from "@/lib/api";
type ApplicationWithJob = ApiApplication & {
jobTitle: string;
companyName: string;
};
export default function MyJobsPage() {
const [applications, setApplications] = useState<ApplicationWithJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
fetchApplications();
}, []);
const fetchApplications = async () => {
try {
setLoading(true);
const data = await applicationsApi.listMyApplications();
// The backend now returns ApplicationWithDetails which has jobTitle and companyName
// We cast it to our extended type
setApplications(data as unknown as ApplicationWithJob[]);
} catch (err) {
console.error("Failed to fetch applications", err);
setError("Não foi possível carregar suas candidaturas. Tente novamente.");
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "pending": return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100/80";
case "reviewed": return "bg-blue-100 text-blue-800 hover:bg-blue-100/80";
case "hired": return "bg-green-100 text-green-800 hover:bg-green-100/80";
case "rejected": return "bg-red-100 text-red-800 hover:bg-red-100/80";
default: return "bg-gray-100 text-gray-800 hover:bg-gray-100/80";
}
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
pending: "Em Análise",
reviewed: "Visualizado",
shortlisted: "Selecionado",
hired: "Contratado",
rejected: "Não Selecionado"
};
return labels[status] || status;
};
const filteredApplications = applications.filter(app =>
app.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.companyName.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Minhas Vagas</h1>
<p>Funcionalidade em desenvolvimento.</p>
<div className="min-h-screen flex flex-col bg-background">
<Navbar />
<main className="flex-1 container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Minhas Candidaturas</h1>
<p className="text-muted-foreground mt-1">
Acompanhe o status das vagas que você se candidatou.
</p>
</div>
<div className="relative w-full md:w-auto">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar vagas ou empresas..."
className="pl-9 w-full md:w-[300px]"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground bg-muted/30 rounded-lg">
<AlertCircle className="h-10 w-10 mb-4 text-destructive" />
<h3 className="text-lg font-medium text-foreground mb-2">Erro ao carregar</h3>
<p>{error}</p>
<Button variant="outline" className="mt-4" onClick={fetchApplications}>
Tentar Novamente
</Button>
</div>
) : filteredApplications.length === 0 ? (
<div className="text-center py-16 bg-muted/20 rounded-lg border border-dashed">
<div className="bg-primary/10 p-4 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-primary" />
</div>
<h3 className="text-lg font-semibold">Nenhuma candidatura encontrada</h3>
<p className="text-muted-foreground max-w-sm mx-auto mt-2 mb-6">
Você ainda não se candidatou a nenhuma vaga. Explore as oportunidades disponíveis e comece sua jornada.
</p>
<Link href="/vagas">
<Button>Explorar Vagas</Button>
</Link>
</div>
) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2">
{filteredApplications.map((app) => (
<Card key={app.id} className="overflow-hidden hover:shadow-md transition-shadow">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-xl">
<Link href={`/vagas/${app.jobId}`} className="hover:text-primary transition-colors">
{app.jobTitle}
</Link>
</CardTitle>
<CardDescription className="flex items-center gap-2">
<Building2 className="h-3.5 w-3.5" />
{app.companyName}
</CardDescription>
</div>
<Badge className={getStatusColor(app.status)} variant="secondary">
{getStatusLabel(app.status)}
</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
<div className="flex items-center text-muted-foreground">
<CalendarDays className="mr-2 h-4 w-4" />
Aplicado em {format(new Date(app.createdAt), "dd 'de' MMMM, yyyy", { locale: ptBR })}
</div>
{/* TODO: Add logic for 'Last update' if needed */}
</div>
</CardContent>
<CardFooter className="bg-muted/50 p-4 flex justify-between items-center">
<Link href={`/vagas/${app.jobId}`} className="text-sm font-medium text-primary hover:underline flex items-center">
Ver Vaga <ExternalLink className="ml-1 h-3 w-3" />
</Link>
{app.resumeUrl && (
<Link
href={app.resumeUrl}
target="_blank"
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4"
>
Ver Currículo Enviado
</Link>
)}
</CardFooter>
</Card>
))}
</div>
)}
</main>
<Footer />
</div>
)
);
}

View 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>
);
}

View 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>
);
}

View file

@ -43,6 +43,7 @@ import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { useNotify } from "@/contexts/notification-context";
import { mockJobs } from "@/lib/mock-data";
import { storageApi, applicationsApi } from "@/lib/api";
// Definição Dos Passos
const steps = [
@ -162,16 +163,59 @@ export default function JobApplicationPage({
};
const handleSubmit = async () => {
setIsSubmitting(true);
// Simular um chamado de API
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
setIsSubmitting(true);
notify.success(
"Candidatura enviada com sucesso!",
`Boa sorte! Sua candidatura para ${job.title} foi recebida.`
);
let resumeUrl = "";
router.push("/dashboard/candidato/candidaturas");
// 1. Upload Curriculo
if (formData.resume) {
try {
const { uploadUrl, publicUrl } = await storageApi.getUploadUrl(
formData.resume.name,
formData.resume.type
);
await storageApi.uploadFile(uploadUrl, formData.resume);
resumeUrl = publicUrl;
} catch (err) {
console.error("Upload error:", err);
notify.error("Erro no upload", "Não foi possível enviar seu currículo. Tente novamente.");
setIsSubmitting(false);
return;
}
}
// 2. Create Application
await applicationsApi.create({
jobId: Number(job.id),
name: formData.fullName,
email: formData.email,
phone: formData.phone,
message: formData.coverLetter || formData.whyUs, // Using cover letter or whyUs as message
resumeUrl: resumeUrl,
documents: {
linkedin: formData.linkedin,
portfolio: formData.portfolioUrl,
salaryExpectation: formData.salaryExpectation,
availability: formData.availability,
whyUs: formData.whyUs
}
});
notify.success(
"Candidatura enviada com sucesso!",
`Boa sorte! Sua candidatura para ${job.title} foi recebida.`
);
router.push("/dashboard/candidato/candidaturas"); // Redirecionar para dashboard correto
} catch (error) {
console.error("Application error:", error);
notify.error("Erro ao enviar", "Ocorreu um erro ao processar sua candidatura.");
} finally {
setIsSubmitting(false);
}
};
const handleSaveDraft = () => {
@ -241,10 +285,10 @@ export default function JobApplicationPage({
<div
key={step.id}
className={`flex flex-col items-center gap-2 ${isActive
? "text-primary"
: isCompleted
? "text-primary/60"
: "text-muted-foreground"
? "text-primary"
: isCompleted
? "text-primary/60"
: "text-muted-foreground"
}`}
>
<div

View file

@ -73,6 +73,14 @@ async function apiRequest<T>(
export const usersApi = {
list: () => apiRequest<ApiUser[]>("/api/v1/users"),
getMe: () => apiRequest<ApiUser & { bio?: string; skills?: string[]; experience?: any[]; education?: any[]; profilePictureUrl?: string; }>("/api/v1/users/me"),
updateMe: (data: any) =>
apiRequest<ApiUser>("/api/v1/users/me", {
method: "PUT",
body: JSON.stringify(data),
}),
create: (data: { name: string; email: string; password: string; role: string }) =>
apiRequest<ApiUser>("/api/v1/users", {
method: "POST",
@ -147,6 +155,77 @@ export const jobsApi = {
getById: (id: number) => apiRequest<ApiJob>(`/jobs/${id}`),
};
// Applications API
export interface ApiApplication {
id: number;
jobId: number;
userId?: number;
name?: string;
email?: string;
phone?: string;
resumeUrl?: string;
status: string;
createdAt: string;
}
export const applicationsApi = {
create: (data: any) =>
apiRequest<ApiApplication>("/api/v1/applications", {
method: "POST",
body: JSON.stringify(data),
}),
list: (params?: { jobId?: number; userId?: number }) => { // Enhanced list to support potential user filtering if backend supports
const query = new URLSearchParams();
if (params?.jobId) query.set('jobId', String(params.jobId));
// Note: Backend currently only supports jobId filter explicitly, but we prepared service for user list too.
// If we need list by user, we might need a specific endpoint or update backend handler.
// For now, let's assume filtering by jobId as primary use case.
const queryStr = query.toString();
return apiRequest<ApiApplication[]>(`/api/v1/applications${queryStr ? `?${queryStr}` : ''}`);
},
listMyApplications: () => {
// This is a new endpoint we need, or we filter on client if list returns all (bad idea)
// or we assume backend ListHandler supports generic filtering.
// Let's assume we call a specific endpoint or use a special param.
// Given the Backend Service has `ListUserApplications`, we should expose it.
// Let's try adding a query param user_id=me or similar if auth middleware injects it,
// BUT the standard way mapped in router is GET /applications?jobId=...
// We need to update backend to support "my applications".
// For now, let's leave this placeholder or implement it properly.
// Let's use a workaround: The backend handler `GetApplications` currently requires jobId.
// We will update backend handler to be more flexible later.
return apiRequest<ApiApplication[]>("/api/v1/applications/me"); // Proposed new endpoint
}
};
// Storage API
export const storageApi = {
getUploadUrl: (filename: string, contentType: string) =>
apiRequest<{ uploadUrl: string; key: string; publicUrl: string }>(
"/api/v1/storage/upload-url",
{
method: "POST",
body: JSON.stringify({ filename, contentType })
}
),
uploadFile: async (uploadUrl: string, file: File) => {
const res = await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
mode: "cors" // Important for S3
});
if (!res.ok) throw new Error("Falha no upload para S3");
return true;
}
};
// Jobs API (public)
// Transform API job to frontend Job format
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
// Format salary

View 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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
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');
}