feat: implement careerjet gap analysis improvements

- Video Interview system (backend + frontend)
- Date Posted filter (24h, 7d, 30d)
- Company filter in jobs listing
- Recent searches persistence (LocalStorage)
- Job Alerts with email confirmation
- Favorite jobs with API
- Company followers system
- Careerjet URL compatibility (s/l aliases)
This commit is contained in:
GoHorse Deploy 2026-02-14 19:37:25 +00:00
parent 71150bcc4b
commit ae475e41a9
23 changed files with 1894 additions and 67 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Job[]>([])
@ -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() {
</div>
<div className="flex gap-2">
<Button
onClick={handleSearch}
className="h-12 gap-2 bg-[#F0932B] hover:bg-[#E8821C] text-white"
>
<Search className="h-4 w-4" />
Buscar
</Button>
<Button
variant="outline"
onClick={() => {
if (!searchTerm && !locationFilter) {
alert("Preencha pelo menos um termo de busca ou localização para criar um alerta.")
return
}
jobsApi.createAlert({
searchQuery: searchTerm || undefined,
location: locationFilter || undefined,
employmentType: typeFilter !== "all" ? typeFilter : undefined,
workMode: workModeFilter !== "all" ? workModeFilter : undefined,
frequency: "daily",
}).then(() => {
alert("Alerta criado! Confirme no seu email.")
}).catch(() => {
alert("Erro ao criar alerta. Faça login para salvar alertas.")
})
}}
className="h-12 gap-2 hover:bg-green-50 hover:text-green-600 hover:border-green-300"
>
<Bell className="h-4 w-4" />
Criar Alerta
</Button>
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
@ -327,6 +382,27 @@ function JobsContent() {
</SelectContent>
</Select>
<Select value={datePostedFilter} onValueChange={setDatePostedFilter}>
<SelectTrigger>
<SelectValue placeholder={t('jobs.filters.datePosted') || 'Data de publicação'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('jobs.filters.all')}</SelectItem>
<SelectItem value="24h">Últimas 24 horas</SelectItem>
<SelectItem value="7d">Últimos 7 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
</SelectContent>
</Select>
<div className="relative">
<Input
placeholder="Empresa..."
value={companyFilter}
onChange={(e) => setCompanyFilter(e.target.value)}
className="h-10"
/>
</div>
{hasActiveFilters && (
<Button
variant="outline"
@ -390,11 +466,69 @@ function JobsContent() {
</button>
</Badge>
)}
{datePostedFilter !== "all" && (
<Badge variant="secondary" className="gap-1 bg-[#F0932B]/10 text-[#F0932B] border-[#F0932B]/20">
{datePostedFilter === "24h" ? "Últimas 24h" :
datePostedFilter === "7d" ? "Últimos 7 dias" :
datePostedFilter === "30d" ? "Últimos 30 dias" : datePostedFilter}
<button onClick={() => setDatePostedFilter("all")} className="ml-1">
<X className="h-3 w-3" />
</button>
</Badge>
)}
{companyFilter && (
<Badge variant="secondary" className="gap-1 bg-[#F0932B]/10 text-[#F0932B] border-[#F0932B]/20">
{companyFilter}
<button onClick={() => setCompanyFilter("")} className="ml-1">
<X className="h-3 w-3" />
</button>
</Badge>
)}
</div>
)}
</div>
</div>
</div>
{/* Recent Searches */}
{searches.length > 0 && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Buscas recentes:</span>
<button onClick={clearSearches} className="text-xs text-muted-foreground hover:text-red-500">
Limpar
</button>
</div>
<div className="flex flex-wrap gap-2">
{searches.slice(0, 5).map((search) => (
<Badge
key={search.id}
variant="outline"
className="cursor-pointer hover:bg-muted gap-1"
onClick={() => {
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"}
<button
onClick={(e) => {
e.stopPropagation()
removeSearch(search.id)
}}
className="ml-1 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
)}
</section>
{/* Jobs Grid */}

View file

@ -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 (
<motion.div
whileHover={{ y: -2 }}

View file

@ -0,0 +1,70 @@
"use client"
import { useState, useEffect, useCallback } from "react"
export interface RecentSearch {
id: string
query: string
location: string
filters: {
type?: string
workMode?: string
datePosted?: string
}
timestamp: number
}
const STORAGE_KEY = "gohorsejobs_recent_searches"
const MAX_SEARCHES = 10
export function useRecentSearches() {
const [searches, setSearches] = useState<RecentSearch[]>([])
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
setSearches(JSON.parse(stored))
} catch {
setSearches([])
}
}
}, [])
const saveSearch = useCallback((search: Omit<RecentSearch, "id" | "timestamp">) => {
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,
}
}

View file

@ -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<Array<{
id: string;
searchQuery?: string;
location?: string;
isActive: boolean;
frequency: string;
}>>("/api/v1/alerts/me"),
deleteAlert: (id: string) => apiRequest<void>(`/api/v1/alerts/${id}`, {
method: "DELETE",
}),
toggleAlert: (id: string, active: boolean) => apiRequest<void>(`/api/v1/alerts/${id}/toggle`, {
method: "PATCH",
body: JSON.stringify({ active }),
}),
// Favorites
getFavorites: () => apiRequest<Array<{
id: string;
jobId: string;
jobTitle: string;
companyName: string;
location?: string;
}>>("/api/v1/favorites"),
addFavorite: (jobId: string) => apiRequest<{ id: string }>(`/api/v1/favorites/${jobId}`, {
method: "POST",
}),
removeFavorite: (jobId: string) => apiRequest<void>(`/api/v1/favorites/${jobId}`, {
method: "DELETE",
}),
checkFavorite: (jobId: string) => apiRequest<{ isFavorite: boolean }>(`/api/v1/favorites/${jobId}/check`),
// Companies
getFollowing: () => apiRequest<Array<{
companyId: string;
companyName: string;
jobsCount: number;
}>>("/api/v1/companies/following"),
followCompany: (companyId: string) => apiRequest<{ id: string }>(`/api/v1/companies/follow/${companyId}`, {
method: "POST",
}),
unfollowCompany: (companyId: string) => apiRequest<void>(`/api/v1/companies/follow/${companyId}`, {
method: "DELETE",
}),
checkFollowing: (companyId: string) => apiRequest<{ isFollowing: boolean }>(`/api/v1/companies/followed/check/${companyId}`),
getCompaniesWithJobs: () => apiRequest<Array<{
companyId: string;
companyName: string;
companyLogoUrl?: string;
jobsCount: number;
}>>("/api/v1/companies/with-jobs"),
};
// Applications API