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:
parent
71150bcc4b
commit
ae475e41a9
23 changed files with 1894 additions and 67 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
111
backend/internal/handlers/company_follower_handler.go
Normal file
111
backend/internal/handlers/company_follower_handler.go
Normal 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)
|
||||
}
|
||||
99
backend/internal/handlers/favorite_job_handler.go
Normal file
99
backend/internal/handlers/favorite_job_handler.go
Normal 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})
|
||||
}
|
||||
128
backend/internal/handlers/job_alert_handler.go
Normal file
128
backend/internal/handlers/job_alert_handler.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
149
backend/internal/handlers/video_interview_handler.go
Normal file
149
backend/internal/handlers/video_interview_handler.go
Normal 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)
|
||||
}
|
||||
17
backend/internal/models/company_follower.go
Normal file
17
backend/internal/models/company_follower.go
Normal 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"`
|
||||
}
|
||||
23
backend/internal/models/job_alert.go
Normal file
23
backend/internal/models/job_alert.go
Normal 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"`
|
||||
}
|
||||
39
backend/internal/models/video_interview.go
Normal file
39
backend/internal/models/video_interview.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
138
backend/internal/services/company_follower_service.go
Normal file
138
backend/internal/services/company_follower_service.go
Normal 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
|
||||
}
|
||||
96
backend/internal/services/favorite_job_service.go
Normal file
96
backend/internal/services/favorite_job_service.go
Normal 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
|
||||
}
|
||||
208
backend/internal/services/job_alert_service.go
Normal file
208
backend/internal/services/job_alert_service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
302
backend/internal/services/video_interview_service.go
Normal file
302
backend/internal/services/video_interview_service.go
Normal 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
|
||||
}
|
||||
48
backend/migrations/034_create_video_interviews.sql
Normal file
48
backend/migrations/034_create_video_interviews.sql
Normal 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';
|
||||
41
backend/migrations/035_create_job_alerts.sql
Normal file
41
backend/migrations/035_create_job_alerts.sql
Normal 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';
|
||||
15
backend/migrations/036_create_company_followers.sql
Normal file
15
backend/migrations/036_create_company_followers.sql
Normal 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';
|
||||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
70
frontend/src/hooks/use-recent-searches.ts
Normal file
70
frontend/src/hooks/use-recent-searches.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue