- fix(seeder): add PASSWORD_PEPPER to all bcrypt hashes (admin + candidates/recruiters) - fix(seeder): add created_by field to jobs INSERT (was causing NOT NULL violation) - feat(backend): add custom job questions support in applications - feat(backend): add payment handler and Stripe routes - feat(frontend): add navbar and footer to /register and /register/user pages - feat(frontend): add custom question answers to job apply page - feat(frontend): update home page hero section and navbar buttons - feat(frontend): update auth/api lib with new endpoints - chore(db): add migration 045 for application answers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
401 lines
17 KiB
Go
401 lines
17 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type SeederService struct {
|
|
DB *sql.DB
|
|
}
|
|
|
|
func NewSeederService(db *sql.DB) *SeederService {
|
|
return &SeederService{DB: db}
|
|
}
|
|
|
|
// SendEvent is a helper to stream logs via SSE
|
|
func (s *SeederService) SendEvent(logChan chan string, msg string) {
|
|
if logChan != nil {
|
|
logChan <- msg
|
|
} else {
|
|
fmt.Println("[SEEDER]", msg)
|
|
}
|
|
}
|
|
|
|
func (s *SeederService) Reset(ctx context.Context) error {
|
|
// Dangerous operation: Truncate tables
|
|
queries := []string{
|
|
"TRUNCATE TABLE applications CASCADE",
|
|
"TRUNCATE TABLE jobs CASCADE",
|
|
"TRUNCATE TABLE companies CASCADE",
|
|
"TRUNCATE TABLE users CASCADE",
|
|
// Add other tables as needed
|
|
}
|
|
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, q := range queries {
|
|
if _, err := tx.ExecContext(ctx, q); err != nil {
|
|
// Ignore if table doesn't exist, but log it
|
|
fmt.Printf("Error executing %s: %v\n", q, err)
|
|
}
|
|
}
|
|
|
|
// Re-create SuperAdmin if needed, or leave it to manual registration
|
|
// For dev, it's nice to have a default admin.
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SeederService) Seed(ctx context.Context, logChan chan string) error {
|
|
s.SendEvent(logChan, "🚀 Starting Database Seed...")
|
|
|
|
// 0. Auto-Create Superadmin from Env
|
|
adminEmail := os.Getenv("ADMIN_EMAIL")
|
|
adminPass := os.Getenv("ADMIN_PASSWORD")
|
|
|
|
if adminEmail != "" && adminPass != "" {
|
|
s.SendEvent(logChan, fmt.Sprintf("🛡️ Found ADMIN_EMAIL env. Creating Superadmin: %s", adminEmail))
|
|
|
|
// Hash password with pepper (must match VerifyPassword in JWT service)
|
|
pepper := os.Getenv("PASSWORD_PEPPER")
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass+pepper), bcrypt.DefaultCost)
|
|
if err == nil {
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err == nil {
|
|
adminID := uuid.New().String()
|
|
_, execErr := tx.ExecContext(ctx, `
|
|
INSERT INTO users (id, identifier, full_name, password_hash, role, created_at, updated_at)
|
|
VALUES ($1, $2, 'System Master', $3, 'superadmin', NOW(), NOW())
|
|
ON CONFLICT (identifier) DO UPDATE
|
|
SET password_hash = EXCLUDED.password_hash,
|
|
role = 'superadmin',
|
|
updated_at = NOW()
|
|
`, adminID, adminEmail, string(hash))
|
|
|
|
if execErr == nil {
|
|
tx.Commit()
|
|
s.SendEvent(logChan, "✅ Superadmin created/verified successfully.")
|
|
} else {
|
|
tx.Rollback()
|
|
s.SendEvent(logChan, fmt.Sprintf("❌ Failed to create Superadmin: %v", execErr))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create Random Source
|
|
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// 1. Create Users (Candidates & Recruiters)
|
|
s.SendEvent(logChan, "👤 Creating Users...")
|
|
|
|
candidates := []string{"Alice Johnson", "Bob Smith", "Charlie Brown", "Diana Prince", "Evan Wright"}
|
|
var candidateIDs []string
|
|
|
|
pepper := os.Getenv("PASSWORD_PEPPER")
|
|
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password123"+pepper), bcrypt.DefaultCost)
|
|
|
|
for _, name := range candidates {
|
|
id := uuid.New().String()
|
|
// Using email format as identifier
|
|
identifier := strings.ToLower(strings.ReplaceAll(name, " ", ".")) + "@example.com"
|
|
|
|
// UPSERT to ensure we get the correct ID back if it exists
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO users (id, identifier, full_name, password_hash, role, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, 'candidate', NOW(), NOW())
|
|
ON CONFLICT (identifier) DO UPDATE SET updated_at = NOW()
|
|
RETURNING id
|
|
`, id, identifier, name, string(passwordHash))
|
|
|
|
if err := row.Scan(&id); err != nil {
|
|
s.SendEvent(logChan, fmt.Sprintf("❌ Error creating candidate %s: %v", name, err))
|
|
continue
|
|
}
|
|
candidateIDs = append(candidateIDs, id)
|
|
s.SendEvent(logChan, fmt.Sprintf(" - Created Candidate: %s (%s)", name, identifier))
|
|
}
|
|
|
|
// 2. Create Companies & Recruiters
|
|
s.SendEvent(logChan, "🏢 Creating Companies...")
|
|
companyNames := []string{"TechCorp", "InnovateX", "GlobalSolutions", "CodeFactory", "DesignStudio"}
|
|
var companyIDs []string
|
|
var companyRecruiterIDs []string
|
|
|
|
for _, compName := range companyNames {
|
|
// Create Recruiter
|
|
recruiterID := uuid.New().String()
|
|
recIdentifier := "hr@" + strings.ToLower(compName) + ".com"
|
|
|
|
row := tx.QueryRowContext(ctx, `
|
|
INSERT INTO users (id, identifier, full_name, password_hash, role, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, 'recruiter', NOW(), NOW())
|
|
ON CONFLICT (identifier) DO UPDATE SET updated_at = NOW()
|
|
RETURNING id
|
|
`, recruiterID, recIdentifier, compName+" Recruiter", string(passwordHash))
|
|
|
|
if err := row.Scan(&recruiterID); err != nil {
|
|
s.SendEvent(logChan, fmt.Sprintf("❌ Error creating recruiter %s: %v", recIdentifier, err))
|
|
continue
|
|
}
|
|
|
|
// Create Company
|
|
compID := uuid.New().String()
|
|
slug := strings.ToLower(compName)
|
|
|
|
row = tx.QueryRowContext(ctx, `
|
|
INSERT INTO companies (id, name, slug, type, verified, active, created_at, updated_at)
|
|
VALUES ($1, $2, $3, 'COMPANY', true, true, NOW(), NOW())
|
|
ON CONFLICT (slug) DO UPDATE SET updated_at = NOW()
|
|
RETURNING id
|
|
`, compID, compName, slug)
|
|
|
|
if err := row.Scan(&compID); err != nil {
|
|
s.SendEvent(logChan, fmt.Sprintf("❌ Error creating company %s: %v", compName, err))
|
|
continue
|
|
}
|
|
companyIDs = append(companyIDs, compID)
|
|
companyRecruiterIDs = append(companyRecruiterIDs, recruiterID)
|
|
|
|
// Link Recruiter - Use 'admin' as role (per schema constraint: 'admin', 'recruiter')
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO user_companies (user_id, company_id, role)
|
|
VALUES ($1, $2, 'admin')
|
|
ON CONFLICT DO NOTHING
|
|
`, recruiterID, compID)
|
|
|
|
if err != nil {
|
|
s.SendEvent(logChan, fmt.Sprintf("⚠️ Failed to link recruiter: %v", err))
|
|
}
|
|
|
|
s.SendEvent(logChan, fmt.Sprintf(" - Created Company: %s (HR: %s)", compName, recIdentifier))
|
|
}
|
|
|
|
// 3. Create Jobs
|
|
s.SendEvent(logChan, "💼 Creating Jobs...")
|
|
|
|
type jobTemplate struct {
|
|
Title string
|
|
Description string
|
|
EmploymentType string
|
|
WorkMode string
|
|
Location string
|
|
SalaryMin float64
|
|
SalaryMax float64
|
|
SalaryType string
|
|
Currency string
|
|
Negotiable bool
|
|
LanguageLevel string
|
|
VisaSupport bool
|
|
Status string
|
|
Questions string // JSONB: {"items":[{id,label,type,required,options?}]}
|
|
}
|
|
|
|
jobTemplates := []jobTemplate{
|
|
{
|
|
Title: "Senior Software Engineer",
|
|
Description: "We are looking for a Senior Software Engineer to join our growing engineering team. You will design, build, and maintain efficient, reusable, and reliable Go or TypeScript services. Strong understanding of distributed systems and cloud infrastructure required.",
|
|
EmploymentType: "full-time",
|
|
WorkMode: "remote",
|
|
Location: "Remote — Worldwide",
|
|
SalaryMin: 8000, SalaryMax: 14000, SalaryType: "monthly", Currency: "USD",
|
|
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
|
Questions: `{"items":[{"id":"q1","label":"Describe a distributed system you have designed or maintained.","type":"textarea","required":true},{"id":"q2","label":"Which cloud provider are you most experienced with?","type":"select","required":true,"options":["AWS","GCP","Azure","Other"]}]}`,
|
|
},
|
|
{
|
|
Title: "Frontend Developer (React / Next.js)",
|
|
Description: "Join our product team as a Frontend Developer. You will own the UI layer of our SaaS platform, building responsive and accessible interfaces using React and Next.js. Experience with TypeScript and Tailwind CSS is a plus.",
|
|
EmploymentType: "full-time",
|
|
WorkMode: "hybrid",
|
|
Location: "São Paulo, SP — Brasil",
|
|
SalaryMin: 6000, SalaryMax: 10000, SalaryType: "monthly", Currency: "BRL",
|
|
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
|
Questions: `{"items":[{"id":"q1","label":"Link to a project or portfolio you are proud of:","type":"text","required":false},{"id":"q2","label":"Do you have experience with TypeScript?","type":"radio","required":true,"options":["Yes","No"]}]}`,
|
|
},
|
|
{
|
|
Title: "Backend Engineer — Go",
|
|
Description: "We need a Backend Engineer experienced in Go to help us scale our API layer. You will work with PostgreSQL, Redis, and gRPC. Clean architecture and test-driven development are core to how we work.",
|
|
EmploymentType: "contract",
|
|
WorkMode: "remote",
|
|
Location: "Remote — Latin America",
|
|
SalaryMin: 60, SalaryMax: 90, SalaryType: "hourly", Currency: "USD",
|
|
Negotiable: true, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
|
},
|
|
{
|
|
Title: "Product Manager",
|
|
Description: "We are hiring a Product Manager to lead our core product squad. You will define the roadmap, work closely with engineering and design, and ensure we ship features that customers love. Prior B2B SaaS experience preferred.",
|
|
EmploymentType: "full-time",
|
|
WorkMode: "onsite",
|
|
Location: "Tokyo, Japan",
|
|
SalaryMin: 500000, SalaryMax: 800000, SalaryType: "monthly", Currency: "JPY",
|
|
Negotiable: false, LanguageLevel: "N3", VisaSupport: true, Status: "open",
|
|
Questions: `{"items":[{"id":"q1","label":"Describe your experience with B2B SaaS product development.","type":"textarea","required":true},{"id":"q2","label":"What is your Japanese proficiency level?","type":"select","required":true,"options":["N1","N2","N3","N4","N5","Beginner"]},{"id":"q3","label":"Are you currently based in Japan?","type":"radio","required":true,"options":["Yes","No, but willing to relocate","No"]}]}`,
|
|
},
|
|
{
|
|
Title: "UX / Product Designer",
|
|
Description: "Looking for a talented UX Designer to shape user experiences across our mobile and web products. You will conduct user research, create wireframes and prototypes, and collaborate with frontend engineers to deliver polished interfaces.",
|
|
EmploymentType: "part-time",
|
|
WorkMode: "hybrid",
|
|
Location: "Berlin, Germany",
|
|
SalaryMin: 2500, SalaryMax: 4000, SalaryType: "monthly", Currency: "EUR",
|
|
Negotiable: false, LanguageLevel: "none", VisaSupport: true, Status: "open",
|
|
Questions: `{"items":[{"id":"q1","label":"Please share a link to your design portfolio:","type":"text","required":true},{"id":"q2","label":"Which design tools do you use regularly?","type":"checkbox","required":true,"options":["Figma","Sketch","Adobe XD","InVision","Framer"]}]}`,
|
|
},
|
|
{
|
|
Title: "DevOps / Platform Engineer",
|
|
Description: "We are seeking a Platform Engineer to own our cloud infrastructure on AWS. You will manage Kubernetes clusters, CI/CD pipelines, monitoring, and cost optimization. Terraform and Helm experience required.",
|
|
EmploymentType: "full-time",
|
|
WorkMode: "remote",
|
|
Location: "Remote — Europe",
|
|
SalaryMin: 70000, SalaryMax: 100000, SalaryType: "yearly", Currency: "GBP",
|
|
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
|
},
|
|
{
|
|
Title: "Data Analyst",
|
|
Description: "Join our data team as a Data Analyst. You will work with large datasets using SQL and Python, build dashboards in Metabase, and support business decisions with clear, actionable insights.",
|
|
EmploymentType: "full-time",
|
|
WorkMode: "onsite",
|
|
Location: "São Paulo, SP — Brasil",
|
|
SalaryMin: 5000, SalaryMax: 8000, SalaryType: "monthly", Currency: "BRL",
|
|
Negotiable: true, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
|
Questions: `{"items":[{"id":"q1","label":"What SQL databases have you worked with?","type":"checkbox","required":true,"options":["PostgreSQL","MySQL","BigQuery","Snowflake","Other"]},{"id":"q2","label":"Describe a data analysis project that had business impact.","type":"textarea","required":true}]}`,
|
|
},
|
|
{
|
|
Title: "Mobile Developer (iOS / Swift)",
|
|
Description: "We are looking for an iOS Developer to build and maintain our native iOS app. You will work with Swift, SwiftUI, and REST APIs. Experience publishing apps to the App Store is required.",
|
|
EmploymentType: "contract",
|
|
WorkMode: "remote",
|
|
Location: "Remote — Americas",
|
|
SalaryMin: 50, SalaryMax: 80, SalaryType: "hourly", Currency: "USD",
|
|
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
|
},
|
|
{
|
|
Title: "Dispatch Warehouse Associate",
|
|
Description: "We are hiring Dispatch Associates to join our logistics operations team in Tokyo. No prior experience required — full on-the-job training provided. Great opportunity to build a career in supply chain.",
|
|
EmploymentType: "dispatch",
|
|
WorkMode: "onsite",
|
|
Location: "Tokyo, Japan",
|
|
SalaryMin: 180000, SalaryMax: 220000, SalaryType: "monthly", Currency: "JPY",
|
|
Negotiable: false, LanguageLevel: "N4", VisaSupport: true, Status: "open",
|
|
Questions: `{"items":[{"id":"q1","label":"Do you have a valid work visa for Japan?","type":"radio","required":true,"options":["Yes","No, I need sponsorship"]},{"id":"q2","label":"Can you start immediately?","type":"radio","required":true,"options":["Yes","Within 1 month","Within 3 months"]}]}`,
|
|
},
|
|
{
|
|
Title: "QA Engineer",
|
|
Description: "We need a QA Engineer to ensure the quality of our web and mobile products. You will write automated tests with Playwright and Cypress, perform exploratory testing, and collaborate with developers to fix bugs fast.",
|
|
EmploymentType: "full-time",
|
|
WorkMode: "hybrid",
|
|
Location: "Remote — Brazil",
|
|
SalaryMin: 4500, SalaryMax: 7500, SalaryType: "monthly", Currency: "BRL",
|
|
Negotiable: false, LanguageLevel: "none", VisaSupport: false, Status: "open",
|
|
},
|
|
}
|
|
|
|
var jobIDs []string
|
|
|
|
for i, compID := range companyIDs {
|
|
recruiterID := companyRecruiterIDs[i]
|
|
numJobs := rnd.Intn(2) + 2
|
|
for j := 0; j < numJobs; j++ {
|
|
tmpl := jobTemplates[(i*3+j)%len(jobTemplates)]
|
|
jobID := uuid.New().String()
|
|
|
|
var questionsArg interface{}
|
|
if tmpl.Questions != "" {
|
|
questionsArg = tmpl.Questions
|
|
}
|
|
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO jobs (
|
|
id, company_id, created_by, title, description,
|
|
employment_type, work_mode, location,
|
|
salary_min, salary_max, salary_type, currency, salary_negotiable,
|
|
language_level, visa_support,
|
|
questions, status, date_posted, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8,
|
|
$9, $10, $11, $12, $13,
|
|
$14, $15,
|
|
$16::jsonb, $17, NOW(), NOW(), NOW()
|
|
) ON CONFLICT DO NOTHING
|
|
`,
|
|
jobID, compID, recruiterID, tmpl.Title, tmpl.Description,
|
|
tmpl.EmploymentType, tmpl.WorkMode, tmpl.Location,
|
|
tmpl.SalaryMin, tmpl.SalaryMax, tmpl.SalaryType, tmpl.Currency, tmpl.Negotiable,
|
|
tmpl.LanguageLevel, tmpl.VisaSupport,
|
|
questionsArg, tmpl.Status,
|
|
)
|
|
if err != nil {
|
|
s.SendEvent(logChan, fmt.Sprintf("❌ Error creating job %s: %v", tmpl.Title, err))
|
|
continue
|
|
}
|
|
jobIDs = append(jobIDs, jobID)
|
|
s.SendEvent(logChan, fmt.Sprintf(" - Posted Job: %s (%s/%s) at %s", tmpl.Title, tmpl.EmploymentType, tmpl.WorkMode, compID[:8]))
|
|
}
|
|
}
|
|
|
|
// 4. Create Applications
|
|
s.SendEvent(logChan, "📝 Creating Applications...")
|
|
|
|
sampleMessages := []string{
|
|
"I am very excited about this opportunity and believe my background is a great fit for the role.",
|
|
"I have been following your company for a while and would love to contribute to your team.",
|
|
"This position aligns perfectly with my career goals and technical expertise.",
|
|
"I am passionate about this field and eager to bring my skills to your organization.",
|
|
"I believe I can add significant value to your team with my experience and enthusiasm.",
|
|
}
|
|
appStatuses := []string{"pending", "reviewed", "shortlisted", "rejected"}
|
|
|
|
for _, candID := range candidateIDs {
|
|
// Apply to 1-3 random jobs
|
|
numApps := rnd.Intn(3) + 1
|
|
for i := 0; i < numApps; i++ {
|
|
if len(jobIDs) == 0 {
|
|
break
|
|
}
|
|
jobID := jobIDs[rnd.Intn(len(jobIDs))]
|
|
appID := uuid.New().String()
|
|
status := appStatuses[rnd.Intn(len(appStatuses))]
|
|
message := sampleMessages[rnd.Intn(len(sampleMessages))]
|
|
answers := `{"salaryExpectation":"5k-8k","hasExperience":"yes","availability":["remote","immediate"]}`
|
|
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO applications (id, job_id, user_id, message, answers, status, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6, NOW(), NOW())
|
|
ON CONFLICT DO NOTHING
|
|
`, appID, jobID, candID, message, answers, status)
|
|
|
|
if err == nil {
|
|
s.SendEvent(logChan, fmt.Sprintf(" - Candidate %s applied to Job %s (status: %s)", candID[:8], jobID[:8], status))
|
|
} else {
|
|
s.SendEvent(logChan, fmt.Sprintf(" ⚠️ Application error: %v", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.SendEvent(logChan, "✅ Seed Completed Successfully!")
|
|
return nil
|
|
}
|