diff --git a/backend/internal/dto/requests.go b/backend/internal/dto/requests.go index 503fb58..4db6f44 100755 --- a/backend/internal/dto/requests.go +++ b/backend/internal/dto/requests.go @@ -144,6 +144,9 @@ type JobFilterQuery struct { SalaryType *string `form:"salaryType"` // hourly, monthly, yearly LocationSearch *string `form:"locationSearch"` // HEAD's explicit location text search + + // Date Posted filter (24h, 7d, 30d) + DatePosted *string `form:"datePosted"` } // PaginatedResponse represents a paginated API response @@ -172,3 +175,46 @@ type SaveFCMTokenRequest struct { Token string `json:"token" validate:"required"` Platform string `json:"platform" validate:"required,oneof=web android ios"` } + +// CreateVideoInterviewRequest represents creating a new video interview +type CreateVideoInterviewRequest struct { + ApplicationID string `json:"applicationId" validate:"required"` + ScheduledAt string `json:"scheduledAt" validate:"required"` + DurationMinutes int `json:"durationMinutes" validate:"required,min=15,max=180"` + Timezone string `json:"timezone"` + MeetingProvider *string `json:"meetingProvider,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +// UpdateVideoInterviewRequest represents updating a video interview +type UpdateVideoInterviewRequest struct { + ScheduledAt *string `json:"scheduledAt,omitempty"` + DurationMinutes *int `json:"durationMinutes,omitempty"` + Timezone *string `json:"timezone,omitempty"` + MeetingLink *string `json:"meetingLink,omitempty"` + MeetingProvider *string `json:"meetingProvider,omitempty"` + MeetingID *string `json:"meetingId,omitempty"` + MeetingPassword *string `json:"meetingPassword,omitempty"` + Status *string `json:"status,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +// VideoInterviewFeedbackRequest represents submitting interview feedback +type VideoInterviewFeedbackRequest struct { + InterviewerFeedback *string `json:"interviewerFeedback,omitempty"` + CandidateFeedback *string `json:"candidateFeedback,omitempty"` + Rating *int `json:"rating,omitempty" validate:"omitempty,min=1,max=5"` +} + +// CreateJobAlertRequest represents creating a job alert +type CreateJobAlertRequest struct { + SearchQuery *string `json:"searchQuery,omitempty"` + Location *string `json:"location,omitempty"` + EmploymentType *string `json:"employmentType,omitempty"` + WorkMode *string `json:"workMode,omitempty"` + SalaryMin *float64 `json:"salaryMin,omitempty"` + SalaryMax *float64 `json:"salaryMax,omitempty"` + Currency *string `json:"currency,omitempty"` + Frequency *string `json:"frequency,omitempty" validate:"omitempty,oneof=daily weekly"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` // For guest alerts +} diff --git a/backend/internal/handlers/company_follower_handler.go b/backend/internal/handlers/company_follower_handler.go new file mode 100644 index 0000000..73ed0c5 --- /dev/null +++ b/backend/internal/handlers/company_follower_handler.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type CompanyFollowerServiceInterface interface { + Follow(userID, companyID string) (*models.CompanyFollower, error) + Unfollow(userID, companyID string) error + GetFollowing(userID string) ([]models.CompanyFollowerWithCompany, error) + IsFollowing(userID, companyID string) (bool, error) + GetCompaniesWithJobs(limit, offset int) ([]models.CompanyFollowerWithCompany, error) +} + +type CompanyFollowerHandler struct { + Service CompanyFollowerServiceInterface +} + +func NewCompanyFollowerHandler(service CompanyFollowerServiceInterface) *CompanyFollowerHandler { + return &CompanyFollowerHandler{Service: service} +} + +func (h *CompanyFollowerHandler) Follow(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + companyID := strings.TrimPrefix(r.URL.Path, "/api/v1/companies/follow/") + + follower, err := h.Service.Follow(userID, companyID) + 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(follower) +} + +func (h *CompanyFollowerHandler) Unfollow(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + companyID := strings.TrimPrefix(r.URL.Path, "/api/v1/companies/follow/") + + err := h.Service.Unfollow(userID, companyID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CompanyFollowerHandler) GetMyFollowing(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + following, err := h.Service.GetFollowing(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(following) +} + +func (h *CompanyFollowerHandler) CheckFollowing(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + companyID := strings.TrimPrefix(r.URL.Path, "/api/v1/companies/") + + isFollowing, err := h.Service.IsFollowing(userID, companyID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"isFollowing": isFollowing}) +} + +func (h *CompanyFollowerHandler) GetCompanies(w http.ResponseWriter, r *http.Request) { + companies, err := h.Service.GetCompaniesWithJobs(20, 0) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(companies) +} diff --git a/backend/internal/handlers/favorite_job_handler.go b/backend/internal/handlers/favorite_job_handler.go new file mode 100644 index 0000000..4720b13 --- /dev/null +++ b/backend/internal/handlers/favorite_job_handler.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type FavoriteJobServiceInterface interface { + AddFavorite(userID, jobID string) (*models.FavoriteJob, error) + RemoveFavorite(userID, jobID string) error + GetFavorites(userID string) ([]models.FavoriteJobWithDetails, error) + IsFavorite(userID, jobID string) (bool, error) +} + +type FavoriteJobHandler struct { + Service FavoriteJobServiceInterface +} + +func NewFavoriteJobHandler(service FavoriteJobServiceInterface) *FavoriteJobHandler { + return &FavoriteJobHandler{Service: service} +} + +func (h *FavoriteJobHandler) AddFavorite(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + jobID := strings.TrimPrefix(r.URL.Path, "/api/v1/favorites/") + + favorite, err := h.Service.AddFavorite(userID, jobID) + 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(favorite) +} + +func (h *FavoriteJobHandler) RemoveFavorite(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + jobID := strings.TrimPrefix(r.URL.Path, "/api/v1/favorites/") + + err := h.Service.RemoveFavorite(userID, jobID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *FavoriteJobHandler) GetMyFavorites(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + favorites, err := h.Service.GetFavorites(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(favorites) +} + +func (h *FavoriteJobHandler) CheckFavorite(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + jobID := strings.TrimPrefix(r.URL.Path, "/api/v1/favorites/") + + isFavorite, err := h.Service.IsFavorite(userID, jobID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"isFavorite": isFavorite}) +} diff --git a/backend/internal/handlers/job_alert_handler.go b/backend/internal/handlers/job_alert_handler.go new file mode 100644 index 0000000..a3333b8 --- /dev/null +++ b/backend/internal/handlers/job_alert_handler.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type JobAlertServiceInterface interface { + CreateAlert(req dto.CreateJobAlertRequest, userID *string) (*models.JobAlert, error) + ConfirmAlert(token string) (*models.JobAlert, error) + GetAlertsByUser(userID string) ([]models.JobAlert, error) + DeleteAlert(id string, userID string) error + ToggleAlert(id string, userID string, active bool) error +} + +type JobAlertHandler struct { + Service JobAlertServiceInterface +} + +func NewJobAlertHandler(service JobAlertServiceInterface) *JobAlertHandler { + return &JobAlertHandler{Service: service} +} + +func (h *JobAlertHandler) CreateAlert(w http.ResponseWriter, r *http.Request) { + var req dto.CreateJobAlertRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var userID *string + if uid, ok := r.Context().Value(middleware.ContextUserID).(string); ok && uid != "" { + userID = &uid + } + + alert, err := h.Service.CreateAlert(req, userID) + 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(alert) +} + +func (h *JobAlertHandler) ConfirmAlert(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "Token is required", http.StatusBadRequest) + return + } + + alert, err := h.Service.ConfirmAlert(token) + if err != nil { + http.Error(w, "Invalid or expired token", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(alert) +} + +func (h *JobAlertHandler) GetMyAlerts(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + alerts, err := h.Service.GetAlertsByUser(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(alerts) +} + +func (h *JobAlertHandler) DeleteAlert(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/v1/alerts/") + + err := h.Service.DeleteAlert(id, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *JobAlertHandler) ToggleAlert(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/v1/alerts/") + + var req struct { + Active bool `json:"active"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err := h.Service.ToggleAlert(id, userID, req.Active) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/backend/internal/handlers/job_handler.go b/backend/internal/handlers/job_handler.go index afc2753..051371e 100755 --- a/backend/internal/handlers/job_handler.go +++ b/backend/internal/handlers/job_handler.go @@ -28,27 +28,30 @@ func NewJobHandler(service JobServiceInterface) *JobHandler { return &JobHandler{Service: service} } -// GetJobs godoc -// @Summary List all jobs -// @Description Get a paginated list of job postings with optional filters -// @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 string 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] +// GetJobs godoc +// @Summary List all jobs +// @Description Get a paginated list of job postings with optional filters +// @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 string false "Filter by company ID" +// @Param featured query bool false "Filter by featured status" +// @Param search query string false "Full-text search query" +// @Param s query string false "Search query (Careerjet-style)" +// @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 l query string false "Location (Careerjet-style)" +// @Param salaryMin query number false "Minimum salary filter" +// @Param salaryMax query number false "Maximum salary filter" +// @Param datePosted query string false "Date posted filter (24h, 7d, 30d)" +// @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] func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) @@ -74,10 +77,16 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { if location == "" { location = r.URL.Query().Get("l") // Careerjet-style alias (Where) } - salaryMinStr := r.URL.Query().Get("salaryMin") - salaryMaxStr := r.URL.Query().Get("salaryMax") - sortBy := r.URL.Query().Get("sortBy") - sortOrder := r.URL.Query().Get("sortOrder") + salaryMinStr := r.URL.Query().Get("salaryMin") + salaryMaxStr := r.URL.Query().Get("salaryMax") + sortBy := r.URL.Query().Get("sortBy") + sortOrder := r.URL.Query().Get("sortOrder") + + // Date Posted filter (Careerjet-style: 24h, 7d, 30d) + datePosted := r.URL.Query().Get("datePosted") + if datePosted == "" { + datePosted = r.URL.Query().Get("date") // Alternative alias + } filter := dto.JobFilterQuery{ PaginationQuery: dto.PaginationQuery{ @@ -113,11 +122,14 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { filter.SalaryMin = &val } } - if salaryMaxStr != "" { - if val, err := strconv.ParseFloat(salaryMaxStr, 64); err == nil { - filter.SalaryMax = &val - } - } + if salaryMaxStr != "" { + if val, err := strconv.ParseFloat(salaryMaxStr, 64); err == nil { + filter.SalaryMax = &val + } + } + if datePosted != "" { + filter.DatePosted = &datePosted + } jobs, total, err := h.Service.GetJobs(filter) if err != nil { diff --git a/backend/internal/handlers/video_interview_handler.go b/backend/internal/handlers/video_interview_handler.go new file mode 100644 index 0000000..e4baf4b --- /dev/null +++ b/backend/internal/handlers/video_interview_handler.go @@ -0,0 +1,149 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type VideoInterviewServiceInterface interface { + CreateInterview(req dto.CreateVideoInterviewRequest, createdBy string) (*models.VideoInterview, error) + GetInterviewByID(id string) (*models.VideoInterview, error) + GetInterviewsByApplication(applicationID string) ([]models.VideoInterview, error) + GetInterviewsByCompany(companyID string) ([]models.VideoInterviewWithDetails, error) + UpdateInterview(id string, req dto.UpdateVideoInterviewRequest) (*models.VideoInterview, error) + SubmitFeedback(id string, req dto.VideoInterviewFeedbackRequest) (*models.VideoInterview, error) + DeleteInterview(id string) error +} + +type VideoInterviewHandler struct { + Service VideoInterviewServiceInterface +} + +func NewVideoInterviewHandler(service VideoInterviewServiceInterface) *VideoInterviewHandler { + return &VideoInterviewHandler{Service: service} +} + +func (h *VideoInterviewHandler) CreateInterview(w http.ResponseWriter, r *http.Request) { + var req dto.CreateVideoInterviewRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + userID := "" + if uid, ok := r.Context().Value(middleware.ContextUserID).(string); ok { + userID = uid + } + + interview, err := h.Service.CreateInterview(req, userID) + 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(interview) +} + +func (h *VideoInterviewHandler) GetInterview(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/interviews/") + + interview, err := h.Service.GetInterviewByID(id) + if err != nil { + http.Error(w, "Interview not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(interview) +} + +func (h *VideoInterviewHandler) GetInterviewsByApplication(w http.ResponseWriter, r *http.Request) { + applicationID := r.URL.Query().Get("applicationId") + if applicationID == "" { + http.Error(w, "applicationId is required", http.StatusBadRequest) + return + } + + interviews, err := h.Service.GetInterviewsByApplication(applicationID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(interviews) +} + +func (h *VideoInterviewHandler) GetCompanyInterviews(w http.ResponseWriter, r *http.Request) { + companyID := r.URL.Query().Get("companyId") + if companyID == "" { + http.Error(w, "companyId is required", http.StatusBadRequest) + return + } + + interviews, err := h.Service.GetInterviewsByCompany(companyID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(interviews) +} + +func (h *VideoInterviewHandler) UpdateInterview(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/interviews/") + + var req dto.UpdateVideoInterviewRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + interview, err := h.Service.UpdateInterview(id, req) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(interview) +} + +func (h *VideoInterviewHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/interviews/") + + var req dto.VideoInterviewFeedbackRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + interview, err := h.Service.SubmitFeedback(id, req) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(interview) +} + +func (h *VideoInterviewHandler) DeleteInterview(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/interviews/") + + err := h.Service.DeleteInterview(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/models/company_follower.go b/backend/internal/models/company_follower.go new file mode 100644 index 0000000..3cb8a91 --- /dev/null +++ b/backend/internal/models/company_follower.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type CompanyFollower struct { + ID string `json:"id" db:"id"` + UserID string `json:"userId" db:"user_id"` + CompanyID string `json:"companyId" db:"company_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +type CompanyFollowerWithCompany struct { + CompanyFollower + CompanyName string `json:"companyName"` + CompanyLogoURL *string `json:"companyLogoUrl,omitempty"` + JobsCount int `json:"jobsCount"` +} diff --git a/backend/internal/models/job_alert.go b/backend/internal/models/job_alert.go new file mode 100644 index 0000000..1bafb85 --- /dev/null +++ b/backend/internal/models/job_alert.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type JobAlert struct { + ID string `json:"id" db:"id"` + UserID *string `json:"userId,omitempty" db:"user_id"` + SearchQuery *string `json:"searchQuery,omitempty" db:"search_query"` + Location *string `json:"location,omitempty" db:"location"` + EmploymentType *string `json:"employmentType,omitempty" db:"employment_type"` + WorkMode *string `json:"workMode,omitempty" db:"work_mode"` + SalaryMin *float64 `json:"salaryMin,omitempty" db:"salary_min"` + SalaryMax *float64 `json:"salaryMax,omitempty" db:"salary_max"` + Currency string `json:"currency" db:"currency"` + Frequency string `json:"frequency" db:"frequency"` + IsActive bool `json:"isActive" db:"is_active"` + LastSentAt *time.Time `json:"lastSentAt,omitempty" db:"last_sent_at"` + NextSendAt *time.Time `json:"nextSendAt,omitempty" db:"next_send_at"` + ConfirmationToken *string `json:"confirmationToken,omitempty" db:"confirmation_token"` + ConfirmedAt *time.Time `json:"confirmedAt,omitempty" db:"confirmed_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} diff --git a/backend/internal/models/video_interview.go b/backend/internal/models/video_interview.go new file mode 100644 index 0000000..4d800bd --- /dev/null +++ b/backend/internal/models/video_interview.go @@ -0,0 +1,39 @@ +package models + +import "time" + +type VideoInterview struct { + ID string `json:"id" db:"id"` + ApplicationID string `json:"applicationId" db:"application_id"` + + ScheduledAt time.Time `json:"scheduledAt" db:"scheduled_at"` + DurationMinutes int `json:"durationMinutes" db:"duration_minutes"` + Timezone string `json:"timezone" db:"timezone"` + + MeetingLink *string `json:"meetingLink,omitempty" db:"meeting_link"` + MeetingProvider *string `json:"meetingProvider,omitempty" db:"meeting_provider"` + MeetingID *string `json:"meetingId,omitempty" db:"meeting_id"` + MeetingPassword *string `json:"meetingPassword,omitempty" db:"meeting_password"` + + Status string `json:"status" db:"status"` + StartedAt *time.Time `json:"startedAt,omitempty" db:"started_at"` + EndedAt *time.Time `json:"endedAt,omitempty" db:"ended_at"` + + Notes *string `json:"notes,omitempty" db:"notes"` + InterviewerFeedback *string `json:"interviewerFeedback,omitempty" db:"interviewer_feedback"` + CandidateFeedback *string `json:"candidateFeedback,omitempty" db:"candidate_feedback"` + Rating *int `json:"rating,omitempty" db:"rating"` + + CreatedBy *string `json:"createdBy,omitempty" db:"created_by"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +type VideoInterviewWithDetails struct { + VideoInterview + JobTitle string `json:"jobTitle"` + CompanyID string `json:"companyId"` + CompanyName string `json:"companyName"` + CandidateName string `json:"candidateName"` + CandidateEmail string `json:"candidateEmail"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d033784..01cecb0 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -284,13 +284,50 @@ func NewRouter() http.Handler { mux.HandleFunc("POST /api/v1/subscription/checkout", subHandler.CreateCheckoutSession) mux.HandleFunc("POST /api/v1/subscription/webhook", subHandler.HandleWebhook) - // Application Routes (merged: both OptionalAuth for create + both /me endpoints) - mux.Handle("POST /api/v1/applications", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(applicationHandler.CreateApplication))) - mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.GetMyApplications))) - 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) - mux.HandleFunc("DELETE /api/v1/applications/{id}", applicationHandler.DeleteApplication) + // Application Routes (merged: both OptionalAuth for create + both /me endpoints) + mux.Handle("POST /api/v1/applications", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(applicationHandler.CreateApplication))) + mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.GetMyApplications))) + 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) + mux.HandleFunc("DELETE /api/v1/applications/{id}", applicationHandler.DeleteApplication) + + // Job Alert Routes + jobAlertService := services.NewJobAlertService(database.DB, emailService) + jobAlertHandler := handlers.NewJobAlertHandler(jobAlertService) + mux.Handle("POST /api/v1/alerts", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(jobAlertHandler.CreateAlert))) + mux.Handle("GET /api/v1/alerts/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobAlertHandler.GetMyAlerts))) + mux.Handle("DELETE /api/v1/alerts/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobAlertHandler.DeleteAlert))) + mux.Handle("PATCH /api/v1/alerts/{id}/toggle", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobAlertHandler.ToggleAlert))) + mux.HandleFunc("GET /api/v1/alerts/confirm", jobAlertHandler.ConfirmAlert) + + // Favorite Jobs Routes + favoriteJobService := services.NewFavoriteJobService(database.DB) + favoriteJobHandler := handlers.NewFavoriteJobHandler(favoriteJobService) + mux.Handle("GET /api/v1/favorites", authMiddleware.HeaderAuthGuard(http.HandlerFunc(favoriteJobHandler.GetMyFavorites))) + mux.Handle("POST /api/v1/favorites/{jobId}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(favoriteJobHandler.AddFavorite))) + mux.Handle("DELETE /api/v1/favorites/{jobId}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(favoriteJobHandler.RemoveFavorite))) + mux.Handle("GET /api/v1/favorites/{jobId}/check", authMiddleware.HeaderAuthGuard(http.HandlerFunc(favoriteJobHandler.CheckFavorite))) + + // Company Followers Routes + companyFollowerService := services.NewCompanyFollowerService(database.DB) + companyFollowerHandler := handlers.NewCompanyFollowerHandler(companyFollowerService) + mux.Handle("GET /api/v1/companies/following", authMiddleware.HeaderAuthGuard(http.HandlerFunc(companyFollowerHandler.GetMyFollowing))) + mux.Handle("POST /api/v1/companies/follow/{companyId}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(companyFollowerHandler.Follow))) + mux.Handle("DELETE /api/v1/companies/follow/{companyId}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(companyFollowerHandler.Unfollow))) + mux.Handle("GET /api/v1/companies/followed/check/{companyId}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(companyFollowerHandler.CheckFollowing))) + mux.HandleFunc("GET /api/v1/companies/with-jobs", companyFollowerHandler.GetCompanies) + + // Video Interview Routes + videoInterviewService := services.NewVideoInterviewService(database.DB) + videoInterviewHandler := handlers.NewVideoInterviewHandler(videoInterviewService) + mux.Handle("POST /api/v1/interviews", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.CreateInterview))) + mux.Handle("GET /api/v1/interviews", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.GetCompanyInterviews))) + mux.HandleFunc("GET /api/v1/interviews/by-application", videoInterviewHandler.GetInterviewsByApplication) + mux.Handle("GET /api/v1/interviews/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.GetInterview))) + mux.Handle("PUT /api/v1/interviews/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.UpdateInterview))) + mux.Handle("POST /api/v1/interviews/{id}/feedback", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.SubmitFeedback))) + mux.Handle("DELETE /api/v1/interviews/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.DeleteInterview))) // Payment Routes mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout))) diff --git a/backend/internal/services/company_follower_service.go b/backend/internal/services/company_follower_service.go new file mode 100644 index 0000000..ee875ab --- /dev/null +++ b/backend/internal/services/company_follower_service.go @@ -0,0 +1,138 @@ +package services + +import ( + "database/sql" + "fmt" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type CompanyFollowerService struct { + DB *sql.DB +} + +func NewCompanyFollowerService(db *sql.DB) *CompanyFollowerService { + return &CompanyFollowerService{DB: db} +} + +func (s *CompanyFollowerService) Follow(userID, companyID string) (*models.CompanyFollower, error) { + query := ` + INSERT INTO company_followers (user_id, company_id, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, company_id) DO NOTHING + RETURNING id, user_id, company_id, created_at + ` + + var follower models.CompanyFollower + err := s.DB.QueryRow(query, userID, companyID, time.Now()).Scan( + &follower.ID, &follower.UserID, &follower.CompanyID, &follower.CreatedAt, + ) + + if err == sql.ErrNoRows { + err = s.DB.QueryRow( + "SELECT id, user_id, company_id, created_at FROM company_followers WHERE user_id = $1 AND company_id = $2", + userID, companyID, + ).Scan(&follower.ID, &follower.UserID, &follower.CompanyID, &follower.CreatedAt) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + return &follower, nil +} + +func (s *CompanyFollowerService) Unfollow(userID, companyID string) error { + _, err := s.DB.Exec("DELETE FROM company_followers WHERE user_id = $1 AND company_id = $2", userID, companyID) + return err +} + +func (s *CompanyFollowerService) GetFollowing(userID string) ([]models.CompanyFollowerWithCompany, error) { + query := ` + SELECT + cf.id, cf.user_id, cf.company_id, cf.created_at, + c.name, c.logo_url, + (SELECT COUNT(*) FROM jobs j WHERE j.company_id = c.id AND j.status = 'published') as jobs_count + FROM company_followers cf + JOIN companies c ON cf.company_id = c.id + WHERE cf.user_id = $1 + ORDER BY cf.created_at DESC + ` + + rows, err := s.DB.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var followers []models.CompanyFollowerWithCompany + for rows.Next() { + var f models.CompanyFollowerWithCompany + err := rows.Scan( + &f.ID, &f.UserID, &f.CompanyID, &f.CreatedAt, + &f.CompanyName, &f.CompanyLogoURL, &f.JobsCount, + ) + if err != nil { + return nil, err + } + followers = append(followers, f) + } + + return followers, nil +} + +func (s *CompanyFollowerService) IsFollowing(userID, companyID string) (bool, error) { + var exists bool + err := s.DB.QueryRow( + "SELECT EXISTS(SELECT 1 FROM company_followers WHERE user_id = $1 AND company_id = $2)", + userID, companyID, + ).Scan(&exists) + return exists, err +} + +func (s *CompanyFollowerService) GetCompanyFollowersCount(companyID string) (int, error) { + var count int + err := s.DB.QueryRow( + "SELECT COUNT(*) FROM company_followers WHERE company_id = $1", + companyID, + ).Scan(&count) + return count, err +} + +func (s *CompanyFollowerService) GetCompaniesWithJobs(limit, offset int) ([]models.CompanyFollowerWithCompany, error) { + query := ` + SELECT + cf.id, cf.user_id, cf.company_id, cf.created_at, + c.name, c.logo_url, + (SELECT COUNT(*) FROM jobs j WHERE j.company_id = c.id AND j.status = 'published') as jobs_count + FROM companies c + LEFT JOIN company_followers cf ON cf.company_id = c.id + WHERE c.active = true + GROUP BY c.id, cf.id + ORDER BY jobs_count DESC, c.name ASC + LIMIT $1 OFFSET $2 + ` + + rows, err := s.DB.Query(query, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var companies []models.CompanyFollowerWithCompany + for rows.Next() { + var c models.CompanyFollowerWithCompany + err := rows.Scan( + &c.ID, &c.UserID, &c.CompanyID, &c.CreatedAt, + &c.CompanyName, &c.CompanyLogoURL, &c.JobsCount, + ) + if err != nil { + return nil, err + } + companies = append(companies, c) + } + + return companies, nil +} diff --git a/backend/internal/services/favorite_job_service.go b/backend/internal/services/favorite_job_service.go new file mode 100644 index 0000000..09d5aa9 --- /dev/null +++ b/backend/internal/services/favorite_job_service.go @@ -0,0 +1,96 @@ +package services + +import ( + "database/sql" + "fmt" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type FavoriteJobService struct { + DB *sql.DB +} + +func NewFavoriteJobService(db *sql.DB) *FavoriteJobService { + return &FavoriteJobService{DB: db} +} + +func (s *FavoriteJobService) AddFavorite(userID, jobID string) (*models.FavoriteJob, error) { + query := ` + INSERT INTO favorite_jobs (user_id, job_id, created_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, job_id) DO NOTHING + RETURNING id, user_id, job_id, created_at + ` + + var favorite models.FavoriteJob + err := s.DB.QueryRow(query, userID, jobID, time.Now()).Scan( + &favorite.ID, &favorite.UserID, &favorite.JobID, &favorite.CreatedAt, + ) + + if err == sql.ErrNoRows { + // Already exists, fetch it + err = s.DB.QueryRow( + "SELECT id, user_id, job_id, created_at FROM favorite_jobs WHERE user_id = $1 AND job_id = $2", + userID, jobID, + ).Scan(&favorite.ID, &favorite.UserID, &favorite.JobID, &favorite.CreatedAt) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + return &favorite, nil +} + +func (s *FavoriteJobService) RemoveFavorite(userID, jobID string) error { + _, err := s.DB.Exec("DELETE FROM favorite_jobs WHERE user_id = $1 AND job_id = $2", userID, jobID) + return err +} + +func (s *FavoriteJobService) GetFavorites(userID string) ([]models.FavoriteJobWithDetails, error) { + query := ` + SELECT + f.id, f.user_id, f.job_id, f.created_at, + j.title, j.company_id, c.name, c.logo_url, j.location, + j.salary_min, j.salary_max, j.salary_type + FROM favorite_jobs f + JOIN jobs j ON f.job_id = j.id + JOIN companies c ON j.company_id = c.id + WHERE f.user_id = $1 + ORDER BY f.created_at DESC + ` + + rows, err := s.DB.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var favorites []models.FavoriteJobWithDetails + for rows.Next() { + var fav models.FavoriteJobWithDetails + err := rows.Scan( + &fav.ID, &fav.UserID, &fav.JobID, &fav.CreatedAt, + &fav.JobTitle, &fav.CompanyID, &fav.CompanyName, &fav.CompanyLogoURL, &fav.Location, + &fav.SalaryMin, &fav.SalaryMax, &fav.SalaryType, + ) + if err != nil { + return nil, err + } + favorites = append(favorites, fav) + } + + return favorites, nil +} + +func (s *FavoriteJobService) IsFavorite(userID, jobID string) (bool, error) { + var exists bool + err := s.DB.QueryRow( + "SELECT EXISTS(SELECT 1 FROM favorite_jobs WHERE user_id = $1 AND job_id = $2)", + userID, jobID, + ).Scan(&exists) + return exists, err +} diff --git a/backend/internal/services/job_alert_service.go b/backend/internal/services/job_alert_service.go new file mode 100644 index 0000000..1acac97 --- /dev/null +++ b/backend/internal/services/job_alert_service.go @@ -0,0 +1,208 @@ +package services + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type JobAlertService struct { + DB *sql.DB + EmailService EmailService +} + +func NewJobAlertService(db *sql.DB, emailService EmailService) *JobAlertService { + return &JobAlertService{DB: db, EmailService: emailService} +} + +func generateToken() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +func (s *JobAlertService) CreateAlert(req dto.CreateJobAlertRequest, userID *string) (*models.JobAlert, error) { + frequency := "daily" + if req.Frequency != nil && *req.Frequency == "weekly" { + frequency = *req.Frequency + } + + currency := "BRL" + if req.Currency != nil { + currency = *req.Currency + } + + token := generateToken() + nextSend := time.Now().Add(24 * time.Hour) + if frequency == "weekly" { + nextSend = time.Now().Add(7 * 24 * time.Hour) + } + + query := ` + INSERT INTO job_alerts ( + user_id, search_query, location, employment_type, work_mode, + salary_min, salary_max, currency, frequency, is_active, + confirmation_token, next_send_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, created_at, updated_at + ` + + alert := &models.JobAlert{ + UserID: userID, + SearchQuery: req.SearchQuery, + Location: req.Location, + EmploymentType: req.EmploymentType, + WorkMode: req.WorkMode, + SalaryMin: req.SalaryMin, + SalaryMax: req.SalaryMax, + Currency: currency, + Frequency: frequency, + IsActive: false, // Requires email confirmation + ConfirmationToken: &token, + NextSendAt: &nextSend, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := s.DB.QueryRow( + query, + alert.UserID, alert.SearchQuery, alert.Location, alert.EmploymentType, alert.WorkMode, + alert.SalaryMin, alert.SalaryMax, alert.Currency, alert.Frequency, alert.IsActive, + alert.ConfirmationToken, alert.NextSendAt, alert.CreatedAt, alert.UpdatedAt, + ).Scan(&alert.ID, &alert.CreatedAt, &alert.UpdatedAt) + + if err != nil { + return nil, fmt.Errorf("failed to create alert: %w", err) + } + + // Send confirmation email + email := "" + if userID == nil && req.Email != nil { + email = *req.Email + } else if userID != nil { + // Get user email + var userEmail string + err := s.DB.QueryRow("SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail) + if err == nil { + email = userEmail + } + } + + if email != "" { + confirmLink := fmt.Sprintf("https://gohorsejobs.com/alerts/confirm?token=%s", token) + subject := "Confirme seu alerta de vagas no GoHorse Jobs" + body := fmt.Sprintf(` + Olá, + + Confirme seu alerta de vagas para receber as melhores oportunidades. + + Link de confirmação: %s + + Se você não criou este alerta, ignore este email. + `, confirmLink) + go s.EmailService.SendEmail(email, subject, body) + } + + return alert, nil +} + +func (s *JobAlertService) ConfirmAlert(token string) (*models.JobAlert, error) { + var alert models.JobAlert + query := ` + SELECT id, user_id, search_query, location, employment_type, work_mode, + salary_min, salary_max, currency, frequency, is_active, + confirmation_token, confirmed_at, created_at, updated_at + FROM job_alerts WHERE confirmation_token = $1 + ` + + err := s.DB.QueryRow(query, token).Scan( + &alert.ID, &alert.UserID, &alert.SearchQuery, &alert.Location, &alert.EmploymentType, &alert.WorkMode, + &alert.SalaryMin, &alert.SalaryMax, &alert.Currency, &alert.Frequency, &alert.IsActive, + &alert.ConfirmationToken, &alert.ConfirmedAt, &alert.CreatedAt, &alert.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + if alert.ConfirmedAt != nil { + return &alert, nil // Already confirmed + } + + now := time.Now() + nextSend := now.Add(24 * time.Hour) + if alert.Frequency == "weekly" { + nextSend = now.Add(7 * 24 * time.Hour) + } + + _, err = s.DB.Exec( + "UPDATE job_alerts SET is_active = true, confirmed_at = $1, next_send_at = $2 WHERE id = $3", + now, nextSend, alert.ID, + ) + + if err != nil { + return nil, err + } + + alert.IsActive = true + alert.ConfirmedAt = &now + alert.NextSendAt = &nextSend + + return &alert, nil +} + +func (s *JobAlertService) GetAlertsByUser(userID string) ([]models.JobAlert, error) { + query := ` + SELECT id, user_id, search_query, location, employment_type, work_mode, + salary_min, salary_max, currency, frequency, is_active, + last_sent_at, next_send_at, confirmed_at, created_at, updated_at + FROM job_alerts WHERE user_id = $1 ORDER BY created_at DESC + ` + + rows, err := s.DB.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var alerts []models.JobAlert + for rows.Next() { + var alert models.JobAlert + err := rows.Scan( + &alert.ID, &alert.UserID, &alert.SearchQuery, &alert.Location, &alert.EmploymentType, &alert.WorkMode, + &alert.SalaryMin, &alert.SalaryMax, &alert.Currency, &alert.Frequency, &alert.IsActive, + &alert.LastSentAt, &alert.NextSendAt, &alert.ConfirmedAt, &alert.CreatedAt, &alert.UpdatedAt, + ) + if err != nil { + return nil, err + } + alerts = append(alerts, alert) + } + + return alerts, nil +} + +func (s *JobAlertService) DeleteAlert(id string, userID string) error { + result, err := s.DB.Exec("DELETE FROM job_alerts WHERE id = $1 AND user_id = $2", id, userID) + if err != nil { + return err + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (s *JobAlertService) ToggleAlert(id string, userID string, active bool) error { + _, err := s.DB.Exec("UPDATE job_alerts SET is_active = $1, updated_at = $2 WHERE id = $3 AND user_id = $4", + active, time.Now(), id, userID) + return err +} diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index c3a316b..6e07fa4 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -218,12 +218,34 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany 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++ - } + 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++ + } + + // Date Posted filter (24h, 7d, 30d) + if filter.DatePosted != nil && *filter.DatePosted != "" { + var hours int + switch *filter.DatePosted { + case "24h": + hours = 24 + case "7d": + hours = 24 * 7 + case "30d": + hours = 24 * 30 + default: + hours = 0 + } + if hours > 0 { + cutoffTime := time.Now().Add(-time.Duration(hours) * time.Hour) + baseQuery += fmt.Sprintf(" AND j.created_at >= $%d", argId) + countQuery += fmt.Sprintf(" AND j.created_at >= $%d", argId) + args = append(args, cutoffTime) + argId++ + } + } // Sorting sortClause := " ORDER BY j.is_featured DESC, j.created_at DESC" // default diff --git a/backend/internal/services/video_interview_service.go b/backend/internal/services/video_interview_service.go new file mode 100644 index 0000000..d39b717 --- /dev/null +++ b/backend/internal/services/video_interview_service.go @@ -0,0 +1,302 @@ +package services + +import ( + "database/sql" + "fmt" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type VideoInterviewService struct { + DB *sql.DB +} + +func NewVideoInterviewService(db *sql.DB) *VideoInterviewService { + return &VideoInterviewService{DB: db} +} + +func (s *VideoInterviewService) CreateInterview(req dto.CreateVideoInterviewRequest, createdBy string) (*models.VideoInterview, error) { + scheduledAt, err := time.Parse(time.RFC3339, req.ScheduledAt) + if err != nil { + return nil, fmt.Errorf("invalid scheduled_at format: %w", err) + } + + if req.Timezone == "" { + req.Timezone = "UTC" + } + + if req.MeetingProvider == nil { + provider := "custom" + req.MeetingProvider = &provider + } + + query := ` + INSERT INTO video_interviews ( + application_id, scheduled_at, duration_minutes, timezone, + meeting_provider, notes, status, created_by, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, created_at, updated_at + ` + + interview := &models.VideoInterview{ + ApplicationID: req.ApplicationID, + ScheduledAt: scheduledAt, + DurationMinutes: req.DurationMinutes, + Timezone: req.Timezone, + MeetingProvider: req.MeetingProvider, + Notes: req.Notes, + Status: "scheduled", + CreatedBy: &createdBy, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err = s.DB.QueryRow( + query, + interview.ApplicationID, interview.ScheduledAt, interview.DurationMinutes, interview.Timezone, + interview.MeetingProvider, interview.Notes, interview.Status, interview.CreatedBy, + interview.CreatedAt, interview.UpdatedAt, + ).Scan(&interview.ID, &interview.CreatedAt, &interview.UpdatedAt) + + if err != nil { + return nil, fmt.Errorf("failed to create interview: %w", err) + } + + return interview, nil +} + +func (s *VideoInterviewService) GetInterviewByID(id string) (*models.VideoInterview, error) { + query := ` + SELECT id, application_id, scheduled_at, duration_minutes, timezone, + meeting_link, meeting_provider, meeting_id, meeting_password, + status, started_at, ended_at, notes, interviewer_feedback, + candidate_feedback, rating, created_by, created_at, updated_at + FROM video_interviews WHERE id = $1 + ` + + interview := &models.VideoInterview{} + err := s.DB.QueryRow(query, id).Scan( + &interview.ID, &interview.ApplicationID, &interview.ScheduledAt, &interview.DurationMinutes, &interview.Timezone, + &interview.MeetingLink, &interview.MeetingProvider, &interview.MeetingID, &interview.MeetingPassword, + &interview.Status, &interview.StartedAt, &interview.EndedAt, &interview.Notes, &interview.InterviewerFeedback, + &interview.CandidateFeedback, &interview.Rating, &interview.CreatedBy, &interview.CreatedAt, &interview.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + return interview, nil +} + +func (s *VideoInterviewService) GetInterviewsByApplication(applicationID string) ([]models.VideoInterview, error) { + query := ` + SELECT id, application_id, scheduled_at, duration_minutes, timezone, + meeting_link, meeting_provider, meeting_id, meeting_password, + status, started_at, ended_at, notes, interviewer_feedback, + candidate_feedback, rating, created_by, created_at, updated_at + FROM video_interviews + WHERE application_id = $1 + ORDER BY scheduled_at DESC + ` + + rows, err := s.DB.Query(query, applicationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var interviews []models.VideoInterview + for rows.Next() { + var interview models.VideoInterview + err := rows.Scan( + &interview.ID, &interview.ApplicationID, &interview.ScheduledAt, &interview.DurationMinutes, &interview.Timezone, + &interview.MeetingLink, &interview.MeetingProvider, &interview.MeetingID, &interview.MeetingPassword, + &interview.Status, &interview.StartedAt, &interview.EndedAt, &interview.Notes, &interview.InterviewerFeedback, + &interview.CandidateFeedback, &interview.Rating, &interview.CreatedBy, &interview.CreatedAt, &interview.UpdatedAt, + ) + if err != nil { + return nil, err + } + interviews = append(interviews, interview) + } + + return interviews, nil +} + +func (s *VideoInterviewService) GetInterviewsByCompany(companyID string) ([]models.VideoInterviewWithDetails, error) { + query := ` + SELECT + vi.id, vi.application_id, vi.scheduled_at, vi.duration_minutes, vi.timezone, + vi.meeting_link, vi.meeting_provider, vi.meeting_id, vi.meeting_password, + vi.status, vi.started_at, vi.ended_at, vi.notes, vi.interviewer_feedback, + vi.candidate_feedback, vi.rating, vi.created_by, vi.created_at, vi.updated_at, + j.title as job_title, c.id as company_id, c.name as company_name, + COALESCE(u.name, a.name) as candidate_name, + COALESCE(u.email, a.email) as candidate_email + FROM video_interviews vi + JOIN applications a ON vi.application_id = a.id + JOIN jobs j ON a.job_id = j.id + JOIN companies c ON j.company_id = c.id + LEFT JOIN users u ON a.user_id = u.id + WHERE c.id = $1 + ORDER BY vi.scheduled_at DESC + ` + + rows, err := s.DB.Query(query, companyID) + if err != nil { + return nil, err + } + defer rows.Close() + + var interviews []models.VideoInterviewWithDetails + for rows.Next() { + var interview models.VideoInterviewWithDetails + err := rows.Scan( + &interview.ID, &interview.ApplicationID, &interview.ScheduledAt, &interview.DurationMinutes, &interview.Timezone, + &interview.MeetingLink, &interview.MeetingProvider, &interview.MeetingID, &interview.MeetingPassword, + &interview.Status, &interview.StartedAt, &interview.EndedAt, &interview.Notes, &interview.InterviewerFeedback, + &interview.CandidateFeedback, &interview.Rating, &interview.CreatedBy, &interview.CreatedAt, &interview.UpdatedAt, + &interview.JobTitle, &interview.CompanyID, &interview.CompanyName, + &interview.CandidateName, &interview.CandidateEmail, + ) + if err != nil { + return nil, err + } + interviews = append(interviews, interview) + } + + return interviews, nil +} + +func (s *VideoInterviewService) UpdateInterview(id string, req dto.UpdateVideoInterviewRequest) (*models.VideoInterview, error) { + interview, err := s.GetInterviewByID(id) + if err != nil { + return nil, err + } + + if req.ScheduledAt != nil { + scheduledAt, err := time.Parse(time.RFC3339, *req.ScheduledAt) + if err != nil { + return nil, fmt.Errorf("invalid scheduled_at format: %w", err) + } + interview.ScheduledAt = scheduledAt + } + + if req.DurationMinutes != nil { + interview.DurationMinutes = *req.DurationMinutes + } + + if req.Timezone != nil { + interview.Timezone = *req.Timezone + } + + if req.MeetingLink != nil { + interview.MeetingLink = req.MeetingLink + } + + if req.MeetingProvider != nil { + interview.MeetingProvider = req.MeetingProvider + } + + if req.MeetingID != nil { + interview.MeetingID = req.MeetingID + } + + if req.MeetingPassword != nil { + interview.MeetingPassword = req.MeetingPassword + } + + if req.Status != nil { + interview.Status = *req.Status + + if *req.Status == "in_progress" { + now := time.Now() + interview.StartedAt = &now + } else if *req.Status == "completed" { + now := time.Now() + interview.EndedAt = &now + } + } + + if req.Notes != nil { + interview.Notes = req.Notes + } + + interview.UpdatedAt = time.Now() + + query := ` + UPDATE video_interviews SET + scheduled_at = $1, duration_minutes = $2, timezone = $3, + meeting_link = $4, meeting_provider = $5, meeting_id = $6, meeting_password = $7, + status = $8, started_at = $9, ended_at = $10, notes = $11, updated_at = $12 + WHERE id = $13 + ` + + _, err = s.DB.Exec(query, + interview.ScheduledAt, interview.DurationMinutes, interview.Timezone, + interview.MeetingLink, interview.MeetingProvider, interview.MeetingID, interview.MeetingPassword, + interview.Status, interview.StartedAt, interview.EndedAt, interview.Notes, interview.UpdatedAt, + id, + ) + + if err != nil { + return nil, fmt.Errorf("failed to update interview: %w", err) + } + + return interview, nil +} + +func (s *VideoInterviewService) SubmitFeedback(id string, req dto.VideoInterviewFeedbackRequest) (*models.VideoInterview, error) { + interview, err := s.GetInterviewByID(id) + if err != nil { + return nil, err + } + + if req.InterviewerFeedback != nil { + interview.InterviewerFeedback = req.InterviewerFeedback + } + + if req.CandidateFeedback != nil { + interview.CandidateFeedback = req.CandidateFeedback + } + + if req.Rating != nil { + interview.Rating = req.Rating + } + + interview.UpdatedAt = time.Now() + + query := ` + UPDATE video_interviews SET + interviewer_feedback = $1, candidate_feedback = $2, rating = $3, updated_at = $4 + WHERE id = $5 + ` + + _, err = s.DB.Exec(query, + interview.InterviewerFeedback, interview.CandidateFeedback, interview.Rating, + interview.UpdatedAt, id, + ) + + if err != nil { + return nil, fmt.Errorf("failed to submit feedback: %w", err) + } + + return interview, nil +} + +func (s *VideoInterviewService) DeleteInterview(id string) error { + result, err := s.DB.Exec("DELETE FROM video_interviews WHERE id = $1", id) + if err != nil { + return err + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} diff --git a/backend/migrations/034_create_video_interviews.sql b/backend/migrations/034_create_video_interviews.sql new file mode 100644 index 0000000..a9f064c --- /dev/null +++ b/backend/migrations/034_create_video_interviews.sql @@ -0,0 +1,48 @@ +-- Migration: Create video_interviews table +-- Description: Table for scheduling and managing video interviews between companies and candidates + +CREATE TABLE IF NOT EXISTS video_interviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + + -- Interview scheduling + scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL, + duration_minutes INTEGER NOT NULL DEFAULT 30, + timezone VARCHAR(50) NOT NULL DEFAULT 'UTC', + + -- Interview details + meeting_link VARCHAR(500), + meeting_provider VARCHAR(50) DEFAULT 'custom', -- custom, zoom, meet, teams + meeting_id VARCHAR(255), + meeting_password VARCHAR(100), + + -- Status + status VARCHAR(30) NOT NULL DEFAULT 'scheduled', -- scheduled, in_progress, completed, cancelled, no_show + started_at TIMESTAMP WITH TIME ZONE, + ended_at TIMESTAMP WITH TIME ZONE, + + -- Notes and feedback + notes TEXT, + interviewer_feedback TEXT, + candidate_feedback TEXT, + rating INTEGER, -- 1-5 rating + + -- Metadata + created_by UUID REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_video_interviews_application_id ON video_interviews(application_id); +CREATE INDEX IF NOT EXISTS idx_video_interviews_scheduled_at ON video_interviews(scheduled_at); +CREATE INDEX IF NOT EXISTS idx_video_interviews_status ON video_interviews(status); + +-- Comments +COMMENT ON TABLE video_interviews IS 'Video interview scheduling for job applications'; +COMMENT ON COLUMN video_interviews.application_id IS 'The job application this interview is for'; +COMMENT ON COLUMN video_interviews.scheduled_at IS 'When the interview is scheduled to start'; +COMMENT ON COLUMN video_interviews.duration_minutes IS 'Interview duration in minutes'; +COMMENT ON COLUMN video_interviews.meeting_link IS 'URL to join the video meeting'; +COMMENT ON COLUMN video_interviews.meeting_provider IS 'Video meeting provider: custom, zoom, google_meet, teams'; +COMMENT ON COLUMN video_interviews.status IS 'Interview status: scheduled, in_progress, completed, cancelled, no_show'; diff --git a/backend/migrations/035_create_job_alerts.sql b/backend/migrations/035_create_job_alerts.sql new file mode 100644 index 0000000..d89e0f7 --- /dev/null +++ b/backend/migrations/035_create_job_alerts.sql @@ -0,0 +1,41 @@ +-- Migration: Create job_alerts table +-- Description: Table for job search alerts sent to candidates via email + +CREATE TABLE IF NOT EXISTS job_alerts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + + -- Alert configuration + search_query VARCHAR(255), + location VARCHAR(255), + employment_type VARCHAR(50), + work_mode VARCHAR(50), + salary_min DECIMAL(12,2), + salary_max DECIMAL(12,2), + currency VARCHAR(3) DEFAULT 'BRL', + + -- Alert metadata + frequency VARCHAR(20) NOT NULL DEFAULT 'daily', -- daily, weekly + is_active BOOLEAN NOT NULL DEFAULT true, + last_sent_at TIMESTAMP WITH TIME ZONE, + next_send_at TIMESTAMP WITH TIME ZONE, + + -- Confirmation + confirmation_token VARCHAR(255), + confirmed_at TIMESTAMP WITH TIME ZONE, + + -- Metadata + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_job_alerts_user_id ON job_alerts(user_id); +CREATE INDEX IF NOT EXISTS idx_job_alerts_is_active ON job_alerts(is_active); +CREATE INDEX IF NOT EXISTS idx_job_alerts_next_send_at ON job_alerts(next_send_at); + +-- Comments +COMMENT ON TABLE job_alerts IS 'Job search alerts for candidates'; +COMMENT ON COLUMN job_alerts.user_id IS 'User who created the alert (NULL for guest alerts)'; +COMMENT ON COLUMN job_alerts.frequency IS 'How often to send alerts: daily, weekly'; +COMMENT ON COLUMN job_alerts.confirmation_token IS 'Token for email confirmation'; diff --git a/backend/migrations/036_create_company_followers.sql b/backend/migrations/036_create_company_followers.sql new file mode 100644 index 0000000..47981d7 --- /dev/null +++ b/backend/migrations/036_create_company_followers.sql @@ -0,0 +1,15 @@ +-- Migration: Create company_followers table +-- Description: Table for users following companies + +CREATE TABLE IF NOT EXISTS company_followers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE(user_id, company_id) +); + +CREATE INDEX IF NOT EXISTS idx_company_followers_user_id ON company_followers(user_id); +CREATE INDEX IF NOT EXISTS idx_company_followers_company_id ON company_followers(company_id); + +COMMENT ON TABLE company_followers IS 'Users following companies for job notifications'; diff --git a/docs/CAREERJET_GAP_ANALYSIS.md b/docs/CAREERJET_GAP_ANALYSIS.md index 35f2980..4ad7c04 100644 --- a/docs/CAREERJET_GAP_ANALYSIS.md +++ b/docs/CAREERJET_GAP_ANALYSIS.md @@ -41,12 +41,12 @@ Mapear o que já existe no GoHorseJobs e o que ainda falta para alcançar um flu ## P0 (alta prioridade, impacto imediato) 1. **Date Posted no backend + frontend** - - Backend: aceitar `datePosted` (`24h`, `7d`, `30d`) e filtrar por `created_at`. - - Frontend: filtro visível na listagem com UX similar ao Careerjet. + - [x] Backend: aceitar `datePosted` (`24h`, `7d`, `30d`) e filtrar por `created_at`. + - [x] Frontend: filtro visível na listagem com UX similar ao Careerjet. 2. **Filtro por empresa no público** - - Endpoint de jobs com `companyId` já existe; falta UX forte para seleção por empresa. + - [x] Endpoint de jobs com `companyId` já existe; falta UX forte para seleção por empresa. 3. **Persistência de buscas recentes** - - LocalStorage para anônimos + conta autenticada (sincronização opcional). + - [x] LocalStorage para anônimos + conta autenticada (sincronização opcional). ## P1 (médio prazo) 4. **Alerta de vagas público** diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index e89d0c8..255c005 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -1,7 +1,7 @@ "use client" import { useSearchParams } from "next/navigation" -import { useEffect, useState, useMemo, Suspense } from "react" +import { useEffect, useState, useMemo, Suspense, useCallback } from "react" import { Navbar } from "@/components/navbar" import { Footer } from "@/components/footer" import { JobCard } from "@/components/job-card" @@ -13,14 +13,16 @@ import { Card, CardContent } from "@/components/ui/card" import { PageSkeleton } from "@/components/loading-skeletons" import { jobsApi, transformApiJobToFrontend } from "@/lib/api" import { useDebounce } from "@/hooks/use-utils" +import { useRecentSearches } from "@/hooks/use-recent-searches" import { useTranslation } from "@/lib/i18n" -import { Search, MapPin, Briefcase, SlidersHorizontal, X, ArrowUpDown } from "lucide-react" +import { Search, MapPin, Briefcase, SlidersHorizontal, X, ArrowUpDown, Clock, Bell } from "lucide-react" import { motion, AnimatePresence } from "framer-motion" import type { Job } from "@/lib/types" function JobsContent() { const { t } = useTranslation() const searchParams = useSearchParams() + const { searches, saveSearch, clearSearches, removeSearch } = useRecentSearches() // State const [jobs, setJobs] = useState([]) @@ -40,6 +42,8 @@ function JobsContent() { const [salaryMax, setSalaryMax] = useState("") const [currencyFilter, setCurrencyFilter] = useState("all") const [visaSupport, setVisaSupport] = useState(false) + const [datePostedFilter, setDatePostedFilter] = useState("all") + const [companyFilter, setCompanyFilter] = useState("") // Pagination state const [currentPage, setCurrentPage] = useState(1) @@ -50,6 +54,20 @@ function JobsContent() { const debouncedSearchTerm = useDebounce(searchTerm, 500) const debouncedLocation = useDebounce(locationFilter, 500) + const handleSearch = useCallback(() => { + if (searchTerm || locationFilter) { + saveSearch({ + query: searchTerm, + location: locationFilter, + filters: { + type: typeFilter !== "all" ? typeFilter : undefined, + workMode: workModeFilter !== "all" ? workModeFilter : undefined, + datePosted: datePostedFilter !== "all" ? datePostedFilter : undefined, + }, + }) + } + }, [searchTerm, locationFilter, typeFilter, workModeFilter, datePostedFilter, saveSearch]) + // Initial params useEffect(() => { const tech = searchParams.get("tech") @@ -83,7 +101,7 @@ function JobsContent() { // Reset page when filters change (debounced) useEffect(() => { setCurrentPage(1) - }, [debouncedSearchTerm, debouncedLocation, typeFilter, workModeFilter]) + }, [debouncedSearchTerm, debouncedLocation, typeFilter, workModeFilter, datePostedFilter, companyFilter]) // Main Fetch Logic useEffect(() => { @@ -106,6 +124,8 @@ function JobsContent() { currency: currencyFilter === "all" ? undefined : currencyFilter, visaSupport: visaSupport || undefined, sortBy: sortBy || undefined, + datePosted: datePostedFilter === "all" ? undefined : datePostedFilter, + companyId: companyFilter || undefined, }) // Transform the raw API response to frontend format @@ -145,12 +165,14 @@ function JobsContent() { currencyFilter, visaSupport, sortBy, + datePostedFilter, + companyFilter, t, ]) // Computed const totalPages = Math.ceil(totalJobs / ITEMS_PER_PAGE) - const hasActiveFilters = searchTerm || locationFilter || typeFilter !== "all" || workModeFilter !== "all" + const hasActiveFilters = searchTerm || locationFilter || typeFilter !== "all" || workModeFilter !== "all" || datePostedFilter !== "all" || companyFilter const clearFilters = () => { setSearchTerm("") @@ -162,6 +184,8 @@ function JobsContent() { setCurrencyFilter("all") setVisaSupport(false) setSortBy("recent") + setDatePostedFilter("all") + setCompanyFilter("") } const getTypeLabel = (type: string) => { @@ -241,6 +265,37 @@ function JobsContent() {
+ + + + )} + {companyFilter && ( + + {companyFilter} + + + )}
)} + + {/* Recent Searches */} + {searches.length > 0 && ( +
+
+ + Buscas recentes: + +
+
+ {searches.slice(0, 5).map((search) => ( + { + setSearchTerm(search.query) + setLocationFilter(search.location) + if (search.filters.type) setTypeFilter(search.filters.type) + if (search.filters.workMode) setWorkModeFilter(search.filters.workMode) + if (search.filters.datePosted) setDatePostedFilter(search.filters.datePosted) + }} + > + {search.query || search.location || "Busca"} + + + ))} +
+
+ )} {/* Jobs Grid */} diff --git a/frontend/src/components/job-card.tsx b/frontend/src/components/job-card.tsx index 23f2956..99936fe 100644 --- a/frontend/src/components/job-card.tsx +++ b/frontend/src/components/job-card.tsx @@ -20,9 +20,10 @@ import { } from "lucide-react"; import Link from "next/link"; import { motion } from "framer-motion"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNotify } from "@/contexts/notification-context"; import { useTranslation } from "@/lib/i18n"; +import { jobsApi } from "@/lib/api"; interface JobCardProps { job: Job; @@ -33,9 +34,46 @@ interface JobCardProps { export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) { const { t } = useTranslation(); const [isFavorited, setIsFavorited] = useState(false); + const [isLoading, setIsLoading] = useState(true); const notify = useNotify(); - const formatTimeAgo = (dateString: string) => { + useEffect(() => { + const checkFavorite = async () => { + try { + const res = await jobsApi.checkFavorite(job.id); + setIsFavorited(res.isFavorite); + } catch { + // User not logged in or error - ignore + } finally { + setIsLoading(false); + } + }; + checkFavorite(); + }, [job.id]); + + const handleFavorite = async () => { + if (isLoading) return; + + try { + if (isFavorited) { + await jobsApi.removeFavorite(job.id); + setIsFavorited(false); + } else { + await jobsApi.addFavorite(job.id); + setIsFavorited(true); + notify.info( + t('jobs.favorites.added.title'), + t('jobs.favorites.added.desc', { title: job.title }), + { + actionUrl: "/dashboard/favorites", + actionLabel: t('jobs.favorites.action'), + } + ); + } + } catch { + notify.error("Erro", "Faça login para salvar vagas."); + } + }; const date = new Date(dateString); const now = new Date(); const diffInMs = now.getTime() - date.getTime(); @@ -76,20 +114,6 @@ export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) { .slice(0, 2); }; - const handleFavorite = () => { - setIsFavorited(!isFavorited); - if (!isFavorited) { - notify.info( - t('jobs.favorites.added.title'), - t('jobs.favorites.added.desc', { title: job.title }), - { - actionUrl: "/dashboard/favorites", - actionLabel: t('jobs.favorites.action'), - } - ); - } - }; - return ( ([]) + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + try { + setSearches(JSON.parse(stored)) + } catch { + setSearches([]) + } + } + }, []) + + const saveSearch = useCallback((search: Omit) => { + const newSearch: RecentSearch = { + ...search, + id: crypto.randomUUID(), + timestamp: Date.now(), + } + + setSearches((prev) => { + const filtered = prev.filter( + (s) => !(s.query === search.query && s.location === search.location) + ) + const updated = [newSearch, ...filtered].slice(0, MAX_SEARCHES) + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) + return updated + }) + }, []) + + const clearSearches = useCallback(() => { + setSearches([]) + localStorage.removeItem(STORAGE_KEY) + }, []) + + const removeSearch = useCallback((id: string) => { + setSearches((prev) => { + const updated = prev.filter((s) => s.id !== id) + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) + return updated + }) + }, []) + + return { + searches, + saveSearch, + clearSearches, + removeSearch, + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 98b810a..f79b800 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -362,6 +362,7 @@ export const jobsApi = { currency?: string; visaSupport?: boolean; sortBy?: string; + datePosted?: string; }) => { const query = new URLSearchParams(); if (params.page) query.append("page", params.page.toString()); @@ -377,6 +378,7 @@ export const jobsApi = { if (params.currency) query.append("currency", params.currency); if (params.visaSupport) query.append("visaSupport", "true"); if (params.sortBy) query.append("sortBy", params.sortBy); + if (params.datePosted) query.append("datePosted", params.datePosted); return apiRequest<{ data: ApiJob[]; @@ -406,6 +408,72 @@ export const jobsApi = { method: "DELETE", }); }, + + // Job Alerts + createAlert: (data: { + searchQuery?: string; + location?: string; + employmentType?: string; + workMode?: string; + salaryMin?: number; + salaryMax?: number; + currency?: string; + frequency?: string; + email?: string; + }) => apiRequest<{ id: string }>("/api/v1/alerts", { + method: "POST", + body: JSON.stringify(data), + }), + getMyAlerts: () => apiRequest>("/api/v1/alerts/me"), + deleteAlert: (id: string) => apiRequest(`/api/v1/alerts/${id}`, { + method: "DELETE", + }), + toggleAlert: (id: string, active: boolean) => apiRequest(`/api/v1/alerts/${id}/toggle`, { + method: "PATCH", + body: JSON.stringify({ active }), + }), + + // Favorites + getFavorites: () => apiRequest>("/api/v1/favorites"), + addFavorite: (jobId: string) => apiRequest<{ id: string }>(`/api/v1/favorites/${jobId}`, { + method: "POST", + }), + removeFavorite: (jobId: string) => apiRequest(`/api/v1/favorites/${jobId}`, { + method: "DELETE", + }), + checkFavorite: (jobId: string) => apiRequest<{ isFavorite: boolean }>(`/api/v1/favorites/${jobId}/check`), + + // Companies + getFollowing: () => apiRequest>("/api/v1/companies/following"), + followCompany: (companyId: string) => apiRequest<{ id: string }>(`/api/v1/companies/follow/${companyId}`, { + method: "POST", + }), + unfollowCompany: (companyId: string) => apiRequest(`/api/v1/companies/follow/${companyId}`, { + method: "DELETE", + }), + checkFollowing: (companyId: string) => apiRequest<{ isFollowing: boolean }>(`/api/v1/companies/followed/check/${companyId}`), + getCompaniesWithJobs: () => apiRequest>("/api/v1/companies/with-jobs"), }; // Applications API