Add dynamic candidate management data

This commit is contained in:
Tiago Yamamoto 2025-12-22 19:18:15 -03:00
parent 6996e11f03
commit e71fc361ac
8 changed files with 528 additions and 34 deletions

View file

@ -295,3 +295,19 @@ func (h *AdminHandlers) UpdateTag(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tag) json.NewEncoder(w).Encode(tag)
} }
func (h *AdminHandlers) ListCandidates(w http.ResponseWriter, r *http.Request) {
candidates, stats, err := h.adminService.ListCandidates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := dto.CandidateListResponse{
Stats: stats,
Candidates: candidates,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View file

@ -0,0 +1,42 @@
package dto
import "time"
// CandidateStats represents aggregate stats for candidates.
type CandidateStats struct {
TotalCandidates int `json:"totalCandidates"`
NewCandidates int `json:"newCandidates"`
ActiveApplications int `json:"activeApplications"`
HiringRate float64 `json:"hiringRate"`
}
// CandidateApplication represents an application entry for the candidate profile.
type CandidateApplication struct {
ID int `json:"id"`
JobTitle string `json:"jobTitle"`
Company string `json:"company"`
Status string `json:"status"`
AppliedAt time.Time `json:"appliedAt"`
}
// Candidate represents candidate profile data for backoffice.
type Candidate struct {
ID int `json:"id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Location *string `json:"location,omitempty"`
Title *string `json:"title,omitempty"`
Experience *string `json:"experience,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
Bio *string `json:"bio,omitempty"`
Skills []string `json:"skills"`
Applications []CandidateApplication `json:"applications"`
CreatedAt time.Time `json:"createdAt"`
}
// CandidateListResponse wraps candidate listing with summary statistics.
type CandidateListResponse struct {
Stats CandidateStats `json:"stats"`
Candidates []Candidate `json:"candidates"`
}

View file

@ -152,6 +152,7 @@ func NewRouter() http.Handler {
mux.Handle("GET /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListTags)))) mux.Handle("GET /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListTags))))
mux.Handle("POST /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateTag)))) mux.Handle("POST /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateTag))))
mux.Handle("PATCH /api/v1/admin/tags/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateTag)))) mux.Handle("PATCH /api/v1/admin/tags/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateTag))))
mux.Handle("GET /api/v1/admin/candidates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListCandidates))))
// Application Routes // Application Routes
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication) mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)

View file

@ -7,6 +7,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/lib/pq"
"github.com/rede5/gohorsejobs/backend/internal/dto"
"github.com/rede5/gohorsejobs/backend/internal/models" "github.com/rede5/gohorsejobs/backend/internal/models"
) )
@ -252,6 +254,198 @@ func (s *AdminService) UpdateTag(ctx context.Context, id int, name *string, acti
return tag, nil return tag, nil
} }
func (s *AdminService) ListCandidates(ctx context.Context) ([]dto.Candidate, dto.CandidateStats, error) {
query := `
SELECT id, full_name, email, phone, city, state, title, experience, bio, skills, avatar_url, created_at
FROM users
WHERE role = 'jobSeeker'
ORDER BY created_at DESC
`
rows, err := s.DB.QueryContext(ctx, query)
if err != nil {
return nil, dto.CandidateStats{}, err
}
defer rows.Close()
candidates := make([]dto.Candidate, 0)
candidateIndex := make(map[int]int)
candidateIDs := make([]int, 0)
stats := dto.CandidateStats{}
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
for rows.Next() {
var (
id int
fullName string
email sql.NullString
phone sql.NullString
city sql.NullString
state sql.NullString
title sql.NullString
experience sql.NullString
bio sql.NullString
avatarURL sql.NullString
skills []string
createdAt time.Time
)
if err := rows.Scan(
&id,
&fullName,
&email,
&phone,
&city,
&state,
&title,
&experience,
&bio,
pq.Array(&skills),
&avatarURL,
&createdAt,
); err != nil {
return nil, dto.CandidateStats{}, err
}
location := buildLocation(city, state)
candidate := dto.Candidate{
ID: id,
Name: fullName,
Email: stringOrNil(email),
Phone: stringOrNil(phone),
Location: location,
Title: stringOrNil(title),
Experience: stringOrNil(experience),
Bio: stringOrNil(bio),
AvatarURL: stringOrNil(avatarURL),
Skills: normalizeSkills(skills),
Applications: []dto.CandidateApplication{},
CreatedAt: createdAt,
}
if createdAt.After(thirtyDaysAgo) {
stats.NewCandidates++
}
candidateIndex[id] = len(candidates)
candidateIDs = append(candidateIDs, id)
candidates = append(candidates, candidate)
}
stats.TotalCandidates = len(candidates)
if len(candidateIDs) == 0 {
return candidates, stats, nil
}
appQuery := `
SELECT a.id, a.user_id, a.status, a.created_at, j.title, c.name
FROM applications a
JOIN jobs j ON j.id = a.job_id
JOIN companies c ON c.id = j.company_id
WHERE a.user_id = ANY($1)
ORDER BY a.created_at DESC
`
appRows, err := s.DB.QueryContext(ctx, appQuery, pq.Array(candidateIDs))
if err != nil {
return nil, dto.CandidateStats{}, err
}
defer appRows.Close()
totalApplications := 0
hiredApplications := 0
for appRows.Next() {
var (
app dto.CandidateApplication
userID int
)
if err := appRows.Scan(
&app.ID,
&userID,
&app.Status,
&app.AppliedAt,
&app.JobTitle,
&app.Company,
); err != nil {
return nil, dto.CandidateStats{}, err
}
totalApplications++
if isActiveApplicationStatus(app.Status) {
stats.ActiveApplications++
}
if app.Status == "hired" {
hiredApplications++
}
if index, ok := candidateIndex[userID]; ok {
candidates[index].Applications = append(candidates[index].Applications, app)
}
}
if totalApplications > 0 {
stats.HiringRate = (float64(hiredApplications) / float64(totalApplications)) * 100
}
return candidates, stats, nil
}
func stringOrNil(value sql.NullString) *string {
if !value.Valid {
return nil
}
trimmed := strings.TrimSpace(value.String)
if trimmed == "" {
return nil
}
return &trimmed
}
func buildLocation(city sql.NullString, state sql.NullString) *string {
parts := make([]string, 0, 2)
if city.Valid && strings.TrimSpace(city.String) != "" {
parts = append(parts, strings.TrimSpace(city.String))
}
if state.Valid && strings.TrimSpace(state.String) != "" {
parts = append(parts, strings.TrimSpace(state.String))
}
if len(parts) == 0 {
return nil
}
location := strings.Join(parts, ", ")
return &location
}
func normalizeSkills(skills []string) []string {
if len(skills) == 0 {
return []string{}
}
normalized := make([]string, 0, len(skills))
for _, skill := range skills {
trimmed := strings.TrimSpace(skill)
if trimmed == "" {
continue
}
normalized = append(normalized, trimmed)
}
if len(normalized) == 0 {
return []string{}
}
return normalized
}
func isActiveApplicationStatus(status string) bool {
switch status {
case "pending", "reviewed", "shortlisted":
return true
default:
return false
}
}
func (s *AdminService) getCompanyByID(ctx context.Context, id int) (*models.Company, error) { func (s *AdminService) getCompanyByID(ctx context.Context, id int) (*models.Company, error) {
query := ` query := `
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at

View file

@ -0,0 +1,20 @@
-- Migration: Add candidate profile fields to users table
-- Description: Store candidate registration details used by backoffice candidate management
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email VARCHAR(255),
ADD COLUMN IF NOT EXISTS birth_date DATE,
ADD COLUMN IF NOT EXISTS address VARCHAR(255),
ADD COLUMN IF NOT EXISTS city VARCHAR(100),
ADD COLUMN IF NOT EXISTS state VARCHAR(100),
ADD COLUMN IF NOT EXISTS zip_code VARCHAR(20),
ADD COLUMN IF NOT EXISTS education VARCHAR(100),
ADD COLUMN IF NOT EXISTS experience VARCHAR(100),
ADD COLUMN IF NOT EXISTS skills TEXT[],
ADD COLUMN IF NOT EXISTS objective TEXT,
ADD COLUMN IF NOT EXISTS title VARCHAR(150),
ADD COLUMN IF NOT EXISTS bio TEXT,
ADD COLUMN IF NOT EXISTS avatar_url TEXT;
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role_created_at ON users(role, created_at);

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@ -16,17 +16,53 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Search, Eye, Mail, Phone, MapPin, Briefcase } from "lucide-react" import { Search, Eye, Mail, Phone, MapPin, Briefcase } from "lucide-react"
import { mockCandidates } from "@/lib/mock-data" import { adminCandidatesApi, AdminCandidate, AdminCandidateStats } from "@/lib/api"
export default function AdminCandidatesPage() { export default function AdminCandidatesPage() {
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [selectedCandidate, setSelectedCandidate] = useState<any>(null) const [selectedCandidate, setSelectedCandidate] = useState<AdminCandidate | null>(null)
const [candidates, setCandidates] = useState<AdminCandidate[]>([])
const [stats, setStats] = useState<AdminCandidateStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const filteredCandidates = mockCandidates.filter( useEffect(() => {
(candidate) => let isMounted = true
candidate.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
candidate.email.toLowerCase().includes(searchTerm.toLowerCase()), const loadCandidates = async () => {
) setIsLoading(true)
setErrorMessage(null)
try {
const response = await adminCandidatesApi.list()
if (!isMounted) return
setCandidates(response.candidates)
setStats(response.stats)
} catch (error) {
if (!isMounted) return
setErrorMessage(error instanceof Error ? error.message : "Failed to load candidates")
} finally {
if (isMounted) {
setIsLoading(false)
}
}
}
loadCandidates()
return () => {
isMounted = false
}
}, [])
const filteredCandidates = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return candidates
return candidates.filter((candidate) => {
const name = candidate.name.toLowerCase()
const email = candidate.email?.toLowerCase() || ""
return name.includes(term) || email.includes(term)
})
}, [candidates, searchTerm])
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@ -41,25 +77,25 @@ export default function AdminCandidatesPage() {
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Total candidates</CardDescription> <CardDescription>Total candidates</CardDescription>
<CardTitle className="text-3xl">{mockCandidates.length}</CardTitle> <CardTitle className="text-3xl">{stats?.totalCandidates ?? 0}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>New (30 days)</CardDescription> <CardDescription>New (30 days)</CardDescription>
<CardTitle className="text-3xl">24</CardTitle> <CardTitle className="text-3xl">{stats?.newCandidates ?? 0}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Active applications</CardDescription> <CardDescription>Active applications</CardDescription>
<CardTitle className="text-3xl">{"49"}</CardTitle> <CardTitle className="text-3xl">{stats?.activeApplications ?? 0}</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription>Hiring rate</CardDescription> <CardDescription>Hiring rate</CardDescription>
<CardTitle className="text-3xl">8%</CardTitle> <CardTitle className="text-3xl">{Math.round(stats?.hiringRate ?? 0)}%</CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
</div> </div>
@ -80,6 +116,9 @@ export default function AdminCandidatesPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{errorMessage ? (
<div className="text-sm text-destructive">{errorMessage}</div>
) : null}
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -92,20 +131,33 @@ export default function AdminCandidatesPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
Loading candidates...
</TableCell>
</TableRow>
)}
{!isLoading && filteredCandidates.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
No candidates found.
</TableCell>
</TableRow>
)}
{filteredCandidates.map((candidate) => ( {filteredCandidates.map((candidate) => (
<TableRow key={candidate.id}> <TableRow key={candidate.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
<div className="font-medium">{candidate.name}</div> <div className="font-medium">{candidate.name}</div>
<div className="text-sm text-muted-foreground">{candidate.title}</div> <div className="text-sm text-muted-foreground">{candidate.title ?? "—"}</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell>{candidate.email}</TableCell> <TableCell>{candidate.email ?? "—"}</TableCell>
<TableCell>{candidate.phone}</TableCell> <TableCell>{candidate.phone ?? "—"}</TableCell>
<TableCell>{candidate.location}</TableCell> <TableCell>{candidate.location ?? "—"}</TableCell>
<TableCell> <TableCell>
<Badge variant="secondary">{candidate.applications.length}</Badge> <Badge variant="secondary">{candidate.applications.length}</Badge>
</TableCell> </TableCell>
@ -125,7 +177,7 @@ export default function AdminCandidatesPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<Avatar className="h-20 w-20"> <Avatar className="h-20 w-20">
<AvatarImage src={selectedCandidate.avatar || "/placeholder.svg"} /> <AvatarImage src={selectedCandidate.avatarUrl || "/placeholder.svg"} />
<AvatarFallback> <AvatarFallback>
{selectedCandidate.name {selectedCandidate.name
.split(" ") .split(" ")
@ -135,45 +187,54 @@ export default function AdminCandidatesPage() {
</Avatar> </Avatar>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl font-semibold">{selectedCandidate.name}</h3> <h3 className="text-xl font-semibold">{selectedCandidate.name}</h3>
<p className="text-muted-foreground">{selectedCandidate.title}</p> <p className="text-muted-foreground">{selectedCandidate.title ?? "—"}</p>
<div className="flex flex-wrap gap-2 mt-3"> {selectedCandidate.skills.length > 0 && (
{selectedCandidate.skills.map((skill: string) => ( <div className="flex flex-wrap gap-2 mt-3">
<Badge key={skill} variant="secondary"> {selectedCandidate.skills.map((skill: string) => (
{skill} <Badge key={skill} variant="secondary">
</Badge> {skill}
))} </Badge>
</div> ))}
</div>
)}
</div> </div>
</div> </div>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-muted-foreground" /> <Mail className="h-4 w-4 text-muted-foreground" />
<span>{selectedCandidate.email}</span> <span>{selectedCandidate.email ?? "—"}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-muted-foreground" /> <Phone className="h-4 w-4 text-muted-foreground" />
<span>{selectedCandidate.phone}</span> <span>{selectedCandidate.phone ?? "—"}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" /> <MapPin className="h-4 w-4 text-muted-foreground" />
<span>{selectedCandidate.location}</span> <span>{selectedCandidate.location ?? "—"}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Briefcase className="h-4 w-4 text-muted-foreground" /> <Briefcase className="h-4 w-4 text-muted-foreground" />
<span>{selectedCandidate.experience}</span> <span>{selectedCandidate.experience ?? "—"}</span>
</div> </div>
</div> </div>
<div> <div>
<h4 className="font-semibold mb-2">About</h4> <h4 className="font-semibold mb-2">About</h4>
<p className="text-sm text-muted-foreground">{selectedCandidate.bio}</p> <p className="text-sm text-muted-foreground">
{selectedCandidate.bio ?? "No profile summary provided."}
</p>
</div> </div>
<div> <div>
<h4 className="font-semibold mb-2">Recent applications</h4> <h4 className="font-semibold mb-2">Recent applications</h4>
<div className="space-y-2"> <div className="space-y-2">
{selectedCandidate.applications.map((app: any) => ( {selectedCandidate.applications.length === 0 && (
<div className="text-sm text-muted-foreground">
No applications submitted yet.
</div>
)}
{selectedCandidate.applications.map((app) => (
<div <div
key={app.id} key={app.id}
className="flex items-center justify-between p-3 border rounded-lg" className="flex items-center justify-between p-3 border rounded-lg"
@ -184,7 +245,7 @@ export default function AdminCandidatesPage() {
</div> </div>
<Badge <Badge
variant={ variant={
app.status === "accepted" app.status === "hired"
? "default" ? "default"
: app.status === "rejected" : app.status === "rejected"
? "destructive" ? "destructive"
@ -192,7 +253,9 @@ export default function AdminCandidatesPage() {
} }
> >
{app.status === "pending" && "Pending"} {app.status === "pending" && "Pending"}
{app.status === "accepted" && "Accepted"} {app.status === "reviewed" && "Reviewed"}
{app.status === "shortlisted" && "Shortlisted"}
{app.status === "hired" && "Hired"}
{app.status === "rejected" && "Rejected"} {app.status === "rejected" && "Rejected"}
</Badge> </Badge>
</div> </div>

View file

@ -204,6 +204,41 @@ export interface AdminTag {
updatedAt: string; updatedAt: string;
} }
export interface AdminCandidateApplication {
id: number;
jobTitle: string;
company: string;
status: "pending" | "reviewed" | "shortlisted" | "rejected" | "hired";
appliedAt: string;
}
export interface AdminCandidate {
id: number;
name: string;
email?: string;
phone?: string;
location?: string;
title?: string;
experience?: string;
avatarUrl?: string;
bio?: string;
skills: string[];
applications: AdminCandidateApplication[];
createdAt: string;
}
export interface AdminCandidateStats {
totalCandidates: number;
newCandidates: number;
activeApplications: number;
hiringRate: number;
}
export interface AdminCandidateListResponse {
stats: AdminCandidateStats;
candidates: AdminCandidate[];
}
export const adminAccessApi = { export const adminAccessApi = {
listRoles: () => { listRoles: () => {
logCrudAction("read", "admin/access/roles"); logCrudAction("read", "admin/access/roles");
@ -218,6 +253,13 @@ export const adminAuditApi = {
}, },
}; };
export const adminCandidatesApi = {
list: () => {
logCrudAction("read", "admin/candidates");
return apiRequest<AdminCandidateListResponse>("/api/v1/admin/candidates");
},
};
export const adminCompaniesApi = { export const adminCompaniesApi = {
list: (verified?: boolean) => { list: (verified?: boolean) => {
logCrudAction("read", "admin/companies", typeof verified === "boolean" ? { verified } : undefined); logCrudAction("read", "admin/companies", typeof verified === "boolean" ? { verified } : undefined);

View file

@ -104,6 +104,122 @@ export async function seedUsers() {
console.log(` ✓ Candidate created: ${cand.email}`); console.log(` ✓ Candidate created: ${cand.email}`);
} }
console.log('👤 Seeding legacy candidates...');
const legacyCandidates = [
{
identifier: 'ana_silva',
fullName: 'Ana Silva',
email: 'ana.silva@example.com',
phone: '+55 11 98765-4321',
city: 'São Paulo',
state: 'SP',
title: 'Full Stack Developer',
experience: '5 years of experience',
skills: ['React', 'Node.js', 'TypeScript', 'AWS', 'Docker'],
bio: 'Developer passionate about building innovative solutions. Experience in React, Node.js, and cloud computing.',
objective: 'Grow as a full stack developer building scalable products.'
},
{
identifier: 'carlos_santos',
fullName: 'Carlos Santos',
email: 'carlos.santos@example.com',
phone: '+55 11 91234-5678',
city: 'Rio de Janeiro',
state: 'RJ',
title: 'UX/UI Designer',
experience: '3 years of experience',
skills: ['Figma', 'Adobe XD', 'UI Design', 'Prototyping', 'Design Systems'],
bio: 'Designer focused on creating memorable experiences. Specialist in design systems and prototyping.',
objective: 'Design intuitive experiences for web and mobile products.'
},
{
identifier: 'maria_oliveira',
fullName: 'Maria Oliveira',
email: 'maria.oliveira@example.com',
phone: '+55 21 99876-5432',
city: 'Belo Horizonte',
state: 'MG',
title: 'Data Engineer',
experience: '7 years of experience',
skills: ['Python', 'SQL', 'Spark', 'Machine Learning', 'Data Visualization'],
bio: 'Data engineer with a strong background in machine learning and big data. Passionate about turning data into insights.',
objective: 'Build robust data pipelines and analytics products.'
},
{
identifier: 'pedro_costa',
fullName: 'Pedro Costa',
email: 'pedro.costa@example.com',
phone: '+55 31 98765-1234',
city: 'Curitiba',
state: 'PR',
title: 'Product Manager',
experience: '6 years of experience',
skills: ['Product Management', 'Agile', 'Scrum', 'Data Analysis', 'User Research'],
bio: 'Product Manager with experience in digital products and agile methodologies. Focused on delivering user value.',
objective: 'Lead cross-functional teams to deliver customer-centric products.'
},
{
identifier: 'juliana_ferreira',
fullName: 'Juliana Ferreira',
email: 'juliana.ferreira@example.com',
phone: '+55 41 91234-8765',
city: 'Porto Alegre',
state: 'RS',
title: 'DevOps Engineer',
experience: '4 years of experience',
skills: ['Docker', 'Kubernetes', 'AWS', 'Terraform', 'CI/CD'],
bio: 'DevOps engineer specialized in automation and cloud infrastructure. Experience with Kubernetes and CI/CD.',
objective: 'Improve delivery pipelines and cloud reliability.'
}
];
for (const cand of legacyCandidates) {
const hash = await bcrypt.hash('User@2025', 10);
await pool.query(`
INSERT INTO users (
identifier,
password_hash,
role,
full_name,
email,
phone,
city,
state,
title,
experience,
skills,
objective,
bio
) VALUES ($1, $2, 'jobSeeker', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (identifier) DO UPDATE SET
full_name = EXCLUDED.full_name,
email = EXCLUDED.email,
phone = EXCLUDED.phone,
city = EXCLUDED.city,
state = EXCLUDED.state,
title = EXCLUDED.title,
experience = EXCLUDED.experience,
skills = EXCLUDED.skills,
objective = EXCLUDED.objective,
bio = EXCLUDED.bio
`, [
cand.identifier,
hash,
cand.fullName,
cand.email,
cand.phone,
cand.city,
cand.state,
cand.title,
cand.experience,
cand.skills,
cand.objective,
cand.bio
]);
console.log(` ✓ Legacy candidate created: ${cand.email}`);
}
} catch (error) { } catch (error) {
console.error(' ❌ Error seeding users:', error.message); console.error(' ❌ Error seeding users:', error.message);
throw error; throw error;