From 4253f49cbfd45bda67d1226f06858ccb9f556ca3 Mon Sep 17 00:00:00 2001 From: Marcus Bohessef Date: Sat, 7 Feb 2026 10:41:16 -0300 Subject: [PATCH 1/8] Ajuste nas migrations --- backend/cmd/manual_migrate/main.go | 102 ++++++++++-------- .../004_create_prefectures_cities_tables.sql | 28 +++-- .../migrations/032_update_superadmin_lol.sql | 14 ++- 3 files changed, 88 insertions(+), 56 deletions(-) diff --git a/backend/cmd/manual_migrate/main.go b/backend/cmd/manual_migrate/main.go index eafa9bf..2c19e1d 100644 --- a/backend/cmd/manual_migrate/main.go +++ b/backend/cmd/manual_migrate/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "sort" "strings" "github.com/joho/godotenv" @@ -44,60 +45,69 @@ func main() { } defer db.Close() - // List of migrations to run (in order) - migrations := []string{ - "024_create_external_services_credentials.sql", - "025_create_chat_tables.sql", - "026_create_system_settings.sql", - "027_create_email_system.sql", - "028_add_avatar_url_to_users.sql", + // Discover migrations directory from several probable locations + possibleDirs := []string{ + "migrations", + "backend/migrations", + "../migrations", + "/home/yamamoto/lab/gohorsejobs/backend/migrations", } - for _, migFile := range migrations { - log.Printf("Processing migration: %s", migFile) + var migrationsDir string + for _, d := range possibleDirs { + if fi, err := os.Stat(d); err == nil && fi.IsDir() { + migrationsDir = d + break + } + } - // Try multiple paths - paths := []string{ - "migrations/" + migFile, - "backend/migrations/" + migFile, - "../migrations/" + migFile, - "/home/yamamoto/lab/gohorsejobs/backend/migrations/" + migFile, + if migrationsDir == "" { + log.Fatal("Could not find migrations directory; looked in common locations") + } + + entries, err := os.ReadDir(migrationsDir) + if err != nil { + log.Fatalf("Failed reading migrations dir %s: %v", migrationsDir, err) + } + + var files []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".sql") { + files = append(files, name) + } + } + + // Sort filenames to ensure chronological order + sort.Strings(files) + + for _, migFile := range files { + fullPath := migrationsDir + "/" + migFile + log.Printf("Processing migration: %s (from %s)", migFile, fullPath) + + content, err := os.ReadFile(fullPath) + if err != nil { + log.Fatalf("Could not read migration %s: %v", fullPath, err) } - var content []byte - var readErr error - - for _, p := range paths { - content, readErr = os.ReadFile(p) - if readErr == nil { - log.Printf("Found migration at: %s", p) - break - } - } - - if content == nil { - log.Fatalf("Could not find migration file %s. Last error: %v", migFile, readErr) - } - - statements := strings.Split(string(content), ";") - for _, stmt := range statements { - trimmed := strings.TrimSpace(stmt) - if trimmed == "" { + // Execute the whole file. lib/pq supports multi-statement Exec. + sqlText := string(content) + log.Printf("Executing migration file %s", fullPath) + _, err = db.Exec(sqlText) + if err != nil { + errStr := err.Error() + // Tolerable errors: object already exists, column doesn't exist in some contexts, + // or duplicate key when updates are guarded by intent. Log and continue. + if strings.Contains(errStr, "already exists") || strings.Contains(errStr, "column") && strings.Contains(errStr, "does not exist") || strings.Contains(errStr, "duplicate key value violates unique constraint") { + log.Printf("Warning while applying %s: %v", migFile, err) continue } - log.Printf("Executing: %s", trimmed) - _, err = db.Exec(trimmed) - if err != nil { - if strings.Contains(err.Error(), "already exists") { - log.Printf("Warning (ignored): %v", err) - } else { - log.Printf("FAILED executing: %s\nError: %v", trimmed, err) - // Verify if we should stop. For now, continue best effort or fail? - // Use fatal for critical schema errors not "already exists" - log.Fatal(err) - } - } + log.Fatalf("Failed applying migration %s: %v", migFile, err) } + log.Printf("Migration %s applied successfully", migFile) } diff --git a/backend/migrations/004_create_prefectures_cities_tables.sql b/backend/migrations/004_create_prefectures_cities_tables.sql index 749a9ee..a7d322c 100755 --- a/backend/migrations/004_create_prefectures_cities_tables.sql +++ b/backend/migrations/004_create_prefectures_cities_tables.sql @@ -11,17 +11,29 @@ CREATE TABLE IF NOT EXISTS regions ( CREATE TABLE IF NOT EXISTS cities ( id SERIAL PRIMARY KEY, - region_id INT NOT NULL, + region_id INT, name VARCHAR(100) NOT NULL, - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - FOREIGN KEY (region_id) REFERENCES regions(id) ON DELETE CASCADE + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); --- Indexes -CREATE INDEX idx_regions_country ON regions(country_code); -CREATE INDEX idx_cities_region ON cities(region_id); +-- Ensure column and constraints exist when table already existed without them +ALTER TABLE cities + ADD COLUMN IF NOT EXISTS region_id INT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name + WHERE tc.table_name = 'cities' AND tc.constraint_type = 'FOREIGN KEY' AND kcu.column_name = 'region_id' + ) THEN + ALTER TABLE cities ADD CONSTRAINT fk_cities_region FOREIGN KEY (region_id) REFERENCES regions(id) ON DELETE CASCADE; + END IF; +END$$; + +-- Indexes (safe if table/column already existed) +CREATE INDEX IF NOT EXISTS idx_regions_country ON regions(country_code); +CREATE INDEX IF NOT EXISTS idx_cities_region ON cities(region_id); -- Comments COMMENT ON TABLE regions IS 'Global Regions (States, Provinces, Prefectures)'; diff --git a/backend/migrations/032_update_superadmin_lol.sql b/backend/migrations/032_update_superadmin_lol.sql index efdedf9..d08cdf9 100644 --- a/backend/migrations/032_update_superadmin_lol.sql +++ b/backend/migrations/032_update_superadmin_lol.sql @@ -3,7 +3,7 @@ -- Increase status column length to support 'force_change_password' (21 chars) ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(50); - +-- Update only the intended superadmin identifier to avoid unique constraint conflicts. UPDATE users SET identifier = 'lol', @@ -12,4 +12,14 @@ SET name = 'Dr. Horse Expert', status = 'force_change_password', updated_at = CURRENT_TIMESTAMP -WHERE identifier = 'superadmin' OR email = 'admin@gohorsejobs.com'; +WHERE identifier = 'superadmin'; + +-- If there is a separate user with email 'admin@gohorsejobs.com', update non-identifier fields only +UPDATE users +SET + email = 'lol@gohorsejobs.com', + full_name = 'Dr. Horse Expert', + name = 'Dr. Horse Expert', + status = 'force_change_password', + updated_at = CURRENT_TIMESTAMP +WHERE email = 'admin@gohorsejobs.com' AND identifier <> 'lol'; From 40e7cce971d0d527aa61e591f64f422496bd5bda Mon Sep 17 00:00:00 2001 From: Marcus Bohessef Date: Sat, 7 Feb 2026 10:48:42 -0300 Subject: [PATCH 2/8] Ajuste nas migrations --- .../migrations/032_update_superadmin_lol.sql | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/backend/migrations/032_update_superadmin_lol.sql b/backend/migrations/032_update_superadmin_lol.sql index d08cdf9..df6f2ae 100644 --- a/backend/migrations/032_update_superadmin_lol.sql +++ b/backend/migrations/032_update_superadmin_lol.sql @@ -3,18 +3,20 @@ -- Increase status column length to support 'force_change_password' (21 chars) ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(50); --- Update only the intended superadmin identifier to avoid unique constraint conflicts. -UPDATE users -SET - identifier = 'lol', - email = 'lol@gohorsejobs.com', - full_name = 'Dr. Horse Expert', - name = 'Dr. Horse Expert', - status = 'force_change_password', - updated_at = CURRENT_TIMESTAMP -WHERE identifier = 'superadmin'; +-- Safely change the superadmin identifier ONLY if 'lol' is not already taken. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM users WHERE identifier = 'lol') THEN + UPDATE users + SET identifier = 'lol', + updated_at = CURRENT_TIMESTAMP + WHERE identifier = 'superadmin'; + ELSE + RAISE NOTICE 'Skipping identifier change: ''lol'' already exists'; + END IF; +END$$; --- If there is a separate user with email 'admin@gohorsejobs.com', update non-identifier fields only +-- Update non-identifier fields for the superadmin row (if present) UPDATE users SET email = 'lol@gohorsejobs.com', @@ -22,4 +24,4 @@ SET name = 'Dr. Horse Expert', status = 'force_change_password', updated_at = CURRENT_TIMESTAMP -WHERE email = 'admin@gohorsejobs.com' AND identifier <> 'lol'; +WHERE identifier = 'superadmin' OR identifier = 'lol' OR email = 'admin@gohorsejobs.com'; From 1b1a7d1d0025394a5c8d5072813e2fd9058ccc91 Mon Sep 17 00:00:00 2001 From: Marcus Bohessef Date: Sat, 7 Feb 2026 10:54:21 -0300 Subject: [PATCH 3/8] Ajuste nas migrations --- .../internal/services/credentials_service.go | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/backend/internal/services/credentials_service.go b/backend/internal/services/credentials_service.go index 5515402..8f7f412 100644 --- a/backend/internal/services/credentials_service.go +++ b/backend/internal/services/credentials_service.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "strings" "fmt" "os" "sync" @@ -92,10 +93,10 @@ func (s *CredentialsService) GetDecryptedKey(ctx context.Context, serviceName st } func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, error) { - // 1. Decode Private Key from Env - rawPrivateKey, err := base64.StdEncoding.DecodeString(os.Getenv("RSA_PRIVATE_KEY_BASE64")) + // 1. Load Private Key bytes from env with fallbacks (base64, raw PEM, \n literals) + rawPrivateKey, err := getRawPrivateKeyBytes() if err != nil { - return "", fmt.Errorf("failed to decode env RSA private key: %w", err) + return "", fmt.Errorf("failed to obtain RSA private key: %w", err) } block, _ := pem.Decode(rawPrivateKey) @@ -214,11 +215,10 @@ func (s *CredentialsService) DeleteCredentials(ctx context.Context, serviceName // EncryptPayload encrypts a payload using the derived public key func (s *CredentialsService) EncryptPayload(payload string) (string, error) { - // 1. Decode Private Key from Env (to derive Public Key) - // In a real scenario, you might store Public Key separately, but we can derive it. - rawPrivateKey, err := base64.StdEncoding.DecodeString(os.Getenv("RSA_PRIVATE_KEY_BASE64")) + // 1. Load Private Key bytes from env with fallbacks (base64, raw PEM, \n literals) + rawPrivateKey, err := getRawPrivateKeyBytes() if err != nil { - return "", fmt.Errorf("failed to decode env RSA private key: %w", err) + return "", fmt.Errorf("failed to obtain RSA private key: %w", err) } block, _ := pem.Decode(rawPrivateKey) @@ -257,6 +257,47 @@ func (s *CredentialsService) EncryptPayload(payload string) (string, error) { return base64.StdEncoding.EncodeToString(ciphertext), nil } +// getRawPrivateKeyBytes attempts to load the RSA private key from the environment +// trying several fallbacks: +// 1) Treat env as base64 and decode +// 2) Treat env as a PEM string with literal "\n" escapes and replace them +// 3) Treat env as raw PEM +// 4) Trim and try base64 again +func getRawPrivateKeyBytes() ([]byte, error) { + env := os.Getenv("RSA_PRIVATE_KEY_BASE64") + if env == "" { + return nil, fmt.Errorf("RSA_PRIVATE_KEY_BASE64 environment variable is empty") + } + + // Try base64 decode first + if b, err := base64.StdEncoding.DecodeString(env); err == nil { + if block, _ := pem.Decode(b); block != nil { + return b, nil + } + // Return decoded bytes even if pem.Decode returned nil; parsing later will catch it + return b, nil + } + + // Try replacing literal \n with real newlines + envNew := strings.ReplaceAll(env, "\\n", "\n") + if block, _ := pem.Decode([]byte(envNew)); block != nil { + return []byte(envNew), nil + } + + // Try raw env as PEM + if block, _ := pem.Decode([]byte(env)); block != nil { + return []byte(env), nil + } + + // Trim and try base64 again + trimmed := strings.TrimSpace(env) + if b, err := base64.StdEncoding.DecodeString(trimmed); err == nil { + return b, nil + } + + return nil, fmt.Errorf("could not decode RSA private key from env (tried base64 and PEM variants)") +} + // BootstrapCredentials checks if credentials are in DB, if not, migrates from Env func (s *CredentialsService) BootstrapCredentials(ctx context.Context) error { // List of services and their env mapping From ea5a0032ebcfdbf695de1a0a805d1ca070b6b3c5 Mon Sep 17 00:00:00 2001 From: Marcus Bohessef Date: Sat, 7 Feb 2026 11:02:02 -0300 Subject: [PATCH 4/8] Ajuste nas migrations --- .../internal/services/credentials_service.go | 5 ++++ backend/scripts/validate_rsa_key.sh | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 backend/scripts/validate_rsa_key.sh diff --git a/backend/internal/services/credentials_service.go b/backend/internal/services/credentials_service.go index 8f7f412..3c631e3 100644 --- a/backend/internal/services/credentials_service.go +++ b/backend/internal/services/credentials_service.go @@ -300,6 +300,11 @@ func getRawPrivateKeyBytes() ([]byte, error) { // BootstrapCredentials checks if credentials are in DB, if not, migrates from Env func (s *CredentialsService) BootstrapCredentials(ctx context.Context) error { + // If RSA private key is not available, skip migrating env credentials to DB. + if _, err := getRawPrivateKeyBytes(); err != nil { + fmt.Printf("[CredentialsBootstrap] RSA_PRIVATE_KEY_BASE64 missing or invalid: %v. Skipping ENV->DB credentials migration.\n", err) + return nil + } // List of services and their env mapping services := map[string]func() interface{}{ "stripe": func() interface{} { diff --git a/backend/scripts/validate_rsa_key.sh b/backend/scripts/validate_rsa_key.sh new file mode 100644 index 0000000..8f167c3 --- /dev/null +++ b/backend/scripts/validate_rsa_key.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${RSA_PRIVATE_KEY_BASE64:-}" ]; then + echo "RSA_PRIVATE_KEY_BASE64 is not set" + exit 2 +fi + +# Try decode base64 +if echo "$RSA_PRIVATE_KEY_BASE64" | base64 -d > /tmp/rsa_key.pem 2>/dev/null; then + : +else + # Try replacing literal \n + echo "Attempting to replace literal \n and write PEM" + printf '%b' "$RSA_PRIVATE_KEY_BASE64" > /tmp/rsa_key.pem +fi + +# Validate with openssl +if openssl pkey -in /tmp/rsa_key.pem -noout -text >/dev/null 2>&1; then + echo "RSA private key is valid PEM" + exit 0 +else + echo "RSA private key is invalid" + echo "Preview (first 20 lines):" + sed -n '1,20p' /tmp/rsa_key.pem + exit 1 +fi From d5a02d786eb0800b4daa5f2e04a21fdf22622d4b Mon Sep 17 00:00:00 2001 From: Marcus Bohessef Date: Sat, 7 Feb 2026 11:09:39 -0300 Subject: [PATCH 5/8] Ajuste nas migrations --- .github/workflows/migrate.yml | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/migrate.yml diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml new file mode 100644 index 0000000..8a1c1cd --- /dev/null +++ b/.github/workflows/migrate.yml @@ -0,0 +1,51 @@ +name: Validate RSA and Run Migrations + +on: + push: + branches: [ dev ] + workflow_dispatch: {} + +jobs: + migrate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Validate RSA_PRIVATE_KEY_BASE64 secret + env: + RSA_B64: ${{ secrets.RSA_PRIVATE_KEY_BASE64 }} + run: | + if [ -z "$RSA_B64" ]; then + echo "RSA_PRIVATE_KEY_BASE64 secret is missing. Add it in repository secrets." >&2 + exit 1 + fi + echo "$RSA_B64" > rsa_base64.txt + if ! base64 -d rsa_base64.txt > /tmp/key.pem 2>/dev/null; then + # try convert literal \n + printf '%b' "$RSA_B64" > /tmp/key.pem || true + fi + if ! openssl pkey -in /tmp/key.pem -noout -text >/dev/null 2>&1; then + echo "RSA private key is invalid" >&2 + exit 1 + fi + + - name: Validate DATABASE_URL secret + if: ${{ always() }} + run: | + if [ -z "${{ secrets.DATABASE_URL }}" ]; then + echo "DATABASE_URL secret is missing. Set up your DB connection string in secrets." >&2 + exit 1 + fi + + - name: Run migrations + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + cd backend + go run ./cmd/manual_migrate From 4ee42a774251f94859bf9fb873ea52eda621ebc4 Mon Sep 17 00:00:00 2001 From: Marcus Bohessef Date: Sat, 7 Feb 2026 11:18:58 -0300 Subject: [PATCH 6/8] ajustes --- .forgejo/workflows/deploy.yaml | 3 ++- private_key.pem | 28 ++++++++++++++++++++++++++++ rsa_base64.txt | 1 + 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 private_key.pem create mode 100644 rsa_base64.txt diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index 6add55f..de0968b 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -84,7 +84,8 @@ jobs: --from-literal=AWS_REGION="${{ vars.AWS_REGION }}" \ --from-literal=AWS_ENDPOINT="${{ vars.AWS_ENDPOINT }}" \ --from-literal=AWS_ACCESS_KEY_ID="${{ vars.AWS_ACCESS_KEY_ID }}" \ - --from-literal=AWS_SECRET_ACCESS_KEY="${{ vars.AWS_SECRET_ACCESS_KEY }}" + --from-literal=AWS_SECRET_ACCESS_KEY="${{ vars.AWS_SECRET_ACCESS_KEY }}" \ + --from-literal=RSA_PRIVATE_KEY_BASE64="${{ vars.RSA_PRIVATE_KEY_BASE64 }}" - name: Deploy to K3s run: | diff --git a/private_key.pem b/private_key.pem new file mode 100644 index 0000000..bb7dce1 --- /dev/null +++ b/private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCc9suDobwJwCGJ +VFvga1BxKkmmGOxoF8zNibv6l33/SCFEBb5fFaenxvYotEGWUw0fed4zIcX3s6hA +q2yLr3nIygpLpcOfzpzPxas49P17NA3Chvo3k/0eGkBD6PHM1s62qPP+fKEZtwlS +q1WaFxfc949iqJAQvW6w/7WgMZDineq3IzhVVUAFdw3icZru97hCjPDU/v3eFTS7 +kvGrDYGAHZXzylu3Er9ifKYHdKxOrWFmGaSsPsYMdKNxWFk+Z38NVUnwSH3TEiV/ +S4e33tTkdMmNpY+6e9Cigb09RnOalj5lPjFGA9nTHMJxpsHvSKu8vMBr+OZ4CM3U +RH7MUX01AgMBAAECggEAMKxdFo/4MePY4m984B4W/0iYNv/iizLaKOBtoLsKcLeK +zT+ktXKPHzlUyvF+pyFQ3/JYA24VKAcXhRpDWhuLfcadI7Ee9PbKbKmEu3BJDEPr +gmd9vu9Ond+RDx30oUr5Je5FXySBhmpaYz7LGDHSDgzcc0EHD5HWed+JkEfegE7w +Mvt9KK41mGdaQwiPHS43uzZhQJEqybP3i/6SUnV2CntOhutxLlPk2rpHnns0p/St +Dvlcv61vduIaej4IFBrpSwTE45pvIfkvNZx0pJapM1jZhe8F/2T7GtXDkoFQveo1 +3YB1aadpCx7u28IzQTwBZVwqhCpi2a5+qVYUT0AU3wKBgQDYYUxQUBiUn6bXoAsx +JTozoX0K50cX2d8LVY1OUuhpRXbztS2XXtyfeoAQtEWoT3UO7vjEedWswfo2j+N3 +ZIXig7Vyj/LN5lZyCwWYn4S4inESjKlzi4Pv8D4F+Fkgg0WsVgzbTa4P7faHnDNn +eEHdyJ/ZQ8+XYxBpSAE8ecWQlwKBgQC5tGbfzh77REsv1h6b87vulrGHc+OBITTU +YFu1YfXpvbXx9geRfNLDtUhUis6vgfcQV6sxZVf78UdlqiTBebRLpcvoBlHV/MPZ +T3TsZH1vXwiitOsBIFzKkn8xdjuN6mN5lLjI6KkYeVoULYiUNbiZ+Wi7PXBPnc5I +jBO5EayOEwKBgQDU2pnso24avhatJKX92WYwphpQoISCBPPxvV38/3fbHtdOFBte +PZYAV8wlIoEnecpoP1J+TG+Su1r9U3xq1XsTAYd7w/kQ7RZ6pzcBFWLE+oMSwUZs +AIFwhb8ttklOv3PJfPi2vuqMhwUuD81NarI4jwQYASnz/SKGvqtgp1VezwKBgDoL +DOx+/GgE3ItDHaYY9HCKYUq5Ci7eNij7RS7YQ4ifZzMNdygeH7JUAxuJlzh8IsDU +5gk2Z92zeGFqYLqoU5YhaC5Ja2K68mwFzcHlVt9skMJqUdm0R8x5JZBMKCkfTaA+ +v9LsBY5Ev8b2xG2urNhTgEyl02jPJh6+yZtazthJAoGAHRIX/W0IlyaLno7WzAwM +lSsNfJpTvZmkri0UOGXM2YaKuQZ652t6EBDtfM7O16eV3KNBblt1LjItz/S8kiFi +Q8tGluO27Hn5/auixJjlcZnzoUXrEjAra8lmgAo41Dm0icDpLUzhixZ0qS8d6Yfp +RIT1IoWSuu2fvOOvqezq6bg= +-----END PRIVATE KEY----- diff --git a/rsa_base64.txt b/rsa_base64.txt new file mode 100644 index 0000000..5bb0e10 --- /dev/null +++ b/rsa_base64.txt @@ -0,0 +1 @@ +LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ2M5c3VEb2J3SndDR0oKVkZ2Z2ExQnhLa21tR094b0Y4ek5pYnY2bDMzL1NDRkVCYjVmRmFlbnh2WW90RUdXVXcwZmVkNHpJY1gzczZoQQpxMnlMcjNuSXlncExwY09menB6UHhhczQ5UDE3TkEzQ2h2bzNrLzBlR2tCRDZQSE0xczYycVBQK2ZLRVp0d2xTCnExV2FGeGZjOTQ5aXFKQVF2VzZ3LzdXZ01aRGluZXEzSXpoVlZVQUZkdzNpY1pydTk3aENqUERVL3YzZUZUUzcKa3ZHckRZR0FIWlh6eWx1M0VyOWlmS1lIZEt4T3JXRm1HYVNzUHNZTWRLTnhXRmsrWjM4TlZVbndTSDNURWlWLwpTNGUzM3RUa2RNbU5wWSs2ZTlDaWdiMDlSbk9hbGo1bFBqRkdBOW5USE1KeHBzSHZTS3U4dk1CcitPWjRDTTNVClJIN01VWDAxQWdNQkFBRUNnZ0VBTUt4ZEZvLzRNZVBZNG05ODRCNFcvMGlZTnYvaWl6TGFLT0J0b0xzS2NMZUsKelQra3RYS1BIemxVeXZGK3B5RlEzL0pZQTI0VktBY1hoUnBEV2h1TGZjYWRJN0VlOVBiS2JLbUV1M0JKREVQcgpnbWQ5dnU5T25kK1JEeDMwb1VyNUplNUZYeVNCaG1wYVl6N0xHREhTRGd6Y2MwRUhENUhXZWQrSmtFZmVnRTd3Ck12dDlLSzQxbUdkYVF3aVBIUzQzdXpaaFFKRXF5YlAzaS82U1VuVjJDbnRPaHV0eExsUGsycnBIbm5zMHAvU3QKRHZsY3Y2MXZkdUlhZWo0SUZCcnBTd1RFNDVwdklma3ZOWngwcEphcE0xalpoZThGLzJUN0d0WERrb0ZRdmVvMQozWUIxYWFkcEN4N3UyOEl6UVR3QlpWd3FoQ3BpMmE1K3FWWVVUMEFVM3dLQmdRRFlZVXhRVUJpVW42YlhvQXN4CkpUb3pvWDBLNTBjWDJkOExWWTFPVXVocFJYYnp0UzJYWHR5ZmVvQVF0RVdvVDNVTzd2akVlZFdzd2ZvMmorTjMKWklYaWc3VnlqL0xONWxaeUN3V1luNFM0aW5FU2pLbHppNFB2OEQ0RitGa2dnMFdzVmd6YlRhNFA3ZmFIbkRObgplRUhkeUovWlE4K1hZeEJwU0FFOGVjV1Fsd0tCZ1FDNXRHYmZ6aDc3UkVzdjFoNmI4N3Z1bHJHSGMrT0JJVFRVCllGdTFZZlhwdmJYeDlnZVJmTkxEdFVoVWlzNnZnZmNRVjZzeFpWZjc4VWRscWlUQmViUkxwY3ZvQmxIVi9NUFoKVDNUc1pIMXZYd2lpdE9zQklGektrbjh4ZGp1TjZtTjVsTGpJNktrWWVWb1VMWWlVTmJpWitXaTdQWEJQbmM1SQpqQk81RWF5T0V3S0JnUURVMnBuc28yNGF2aGF0SktYOTJXWXdwaHBRb0lTQ0JQUHh2VjM4LzNmYkh0ZE9GQnRlClBaWUFWOHdsSW9FbmVjcG9QMUorVEcrU3UxcjlVM3hxMVhzVEFZZDd3L2tRN1JaNnB6Y0JGV0xFK29NU3dVWnMKQUlGd2hiOHR0a2xPdjNQSmZQaTJ2dXFNaHdVdUQ4MU5hckk0andRWUFTbnovU0tHdnF0Z3AxVmV6d0tCZ0RvTApET3grL0dnRTNJdERIYVlZOUhDS1lVcTVDaTdlTmlqN1JTN1lRNGlmWnpNTmR5Z2VIN0pVQXh1Smx6aDhJc0RVCjVnazJaOTJ6ZUdGcVlMcW9VNVloYUM1SmEySzY4bXdGemNIbFZ0OXNrTUpxVWRtMFI4eDVKWkJNS0NrZlRhQSsKdjlMc0JZNUV2OGIyeEcydXJOaFRnRXlsMDJqUEpoNit5WnRhenRoSkFvR0FIUklYL1cwSWx5YUxubzdXekF3TQpsU3NOZkpwVHZabWtyaTBVT0dYTTJZYUt1UVo2NTJ0NkVCRHRmTTdPMTZlVjNLTkJibHQxTGpJdHovUzhraUZpClE4dEdsdU8yN0huNS9hdWl4SmpsY1puem9VWHJFakFyYThsbWdBbzQxRG0waWNEcExVemhpeFowcVM4ZDZZZnAKUklUMUlvV1N1dTJmdk9PdnFlenE2Ymc9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K \ No newline at end of file From 14e14a93fd303b7055871f7721852dfe332566e5 Mon Sep 17 00:00:00 2001 From: Marcus Bohessef Date: Sat, 7 Feb 2026 11:24:26 -0300 Subject: [PATCH 7/8] ajustes --- .forgejo/workflows/deploy.yaml | 80 ++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index de0968b..22b77ca 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -67,25 +67,67 @@ jobs: # Injeta variáveis (Lembre-se de mudar DATABASE_URL para sslmode=disable no Forgejo!) kubectl delete secret backend-secrets -n gohorsejobsdev --ignore-not-found - kubectl create secret generic backend-secrets -n gohorsejobsdev \ - --from-literal=MTU="${{ vars.MTU }}" \ - --from-literal=DATABASE_URL="${{ vars.DATABASE_URL }}" \ - --from-literal=AMQP_URL="${{ vars.AMQP_URL }}" \ - --from-literal=JWT_SECRET="${{ vars.JWT_SECRET }}" \ - --from-literal=JWT_EXPIRATION="${{ vars.JWT_EXPIRATION }}" \ - --from-literal=PASSWORD_PEPPER="${{ vars.PASSWORD_PEPPER }}" \ - --from-literal=COOKIE_SECRET="${{ vars.COOKIE_SECRET }}" \ - --from-literal=COOKIE_DOMAIN="${{ vars.COOKIE_DOMAIN }}" \ - --from-literal=BACKEND_PORT="${{ vars.BACKEND_PORT }}" \ - --from-literal=BACKEND_HOST="${{ vars.BACKEND_HOST }}" \ - --from-literal=ENV="${{ vars.ENV }}" \ - --from-literal=CORS_ORIGINS="${{ vars.CORS_ORIGINS }}" \ - --from-literal=S3_BUCKET="${{ vars.S3_BUCKET }}" \ - --from-literal=AWS_REGION="${{ vars.AWS_REGION }}" \ - --from-literal=AWS_ENDPOINT="${{ vars.AWS_ENDPOINT }}" \ - --from-literal=AWS_ACCESS_KEY_ID="${{ vars.AWS_ACCESS_KEY_ID }}" \ - --from-literal=AWS_SECRET_ACCESS_KEY="${{ vars.AWS_SECRET_ACCESS_KEY }}" \ - --from-literal=RSA_PRIVATE_KEY_BASE64="${{ vars.RSA_PRIVATE_KEY_BASE64 }}" + + # Prepare RSA key file if available (prefer secrets over vars) + if [ -n "${{ secrets.RSA_PRIVATE_KEY_BASE64 }}" ]; then + echo "Decoding RSA_PRIVATE_KEY_BASE64 from secrets" + printf '%b' "${{ secrets.RSA_PRIVATE_KEY_BASE64 }}" > /tmp/rsa_key.pem || true + # if it's base64-encoded PEM, decode it + if base64 -d /tmp/rsa_key.pem >/dev/null 2>&1; then + base64 -d /tmp/rsa_key.pem > /tmp/rsa_key_decoded.pem && mv /tmp/rsa_key_decoded.pem /tmp/rsa_key.pem || true + fi + elif [ -n "${{ vars.RSA_PRIVATE_KEY_BASE64 }}" ]; then + echo "Decoding RSA_PRIVATE_KEY_BASE64 from vars" + printf '%b' "${{ vars.RSA_PRIVATE_KEY_BASE64 }}" > /tmp/rsa_key.pem || true + if base64 -d /tmp/rsa_key.pem >/dev/null 2>&1; then + base64 -d /tmp/rsa_key.pem > /tmp/rsa_key_decoded.pem && mv /tmp/rsa_key_decoded.pem /tmp/rsa_key.pem || true + fi + fi + + # Create secret: if rsa file exists, create secret from file (robust); otherwise fallback to from-literal + if [ -f /tmp/rsa_key.pem ]; then + kubectl create secret generic backend-secrets -n gohorsejobsdev \ + --from-literal=MTU="${{ vars.MTU }}" \ + --from-literal=DATABASE_URL="${{ vars.DATABASE_URL }}" \ + --from-literal=AMQP_URL="${{ vars.AMQP_URL }}" \ + --from-literal=JWT_SECRET="${{ vars.JWT_SECRET }}" \ + --from-literal=JWT_EXPIRATION="${{ vars.JWT_EXPIRATION }}" \ + --from-literal=PASSWORD_PEPPER="${{ vars.PASSWORD_PEPPER }}" \ + --from-literal=COOKIE_SECRET="${{ vars.COOKIE_SECRET }}" \ + --from-literal=COOKIE_DOMAIN="${{ vars.COOKIE_DOMAIN }}" \ + --from-literal=BACKEND_PORT="${{ vars.BACKEND_PORT }}" \ + --from-literal=BACKEND_HOST="${{ vars.BACKEND_HOST }}" \ + --from-literal=ENV="${{ vars.ENV }}" \ + --from-literal=CORS_ORIGINS="${{ vars.CORS_ORIGINS }}" \ + --from-literal=S3_BUCKET="${{ vars.S3_BUCKET }}" \ + --from-literal=AWS_REGION="${{ vars.AWS_REGION }}" \ + --from-literal=AWS_ENDPOINT="${{ vars.AWS_ENDPOINT }}" \ + --from-literal=AWS_ACCESS_KEY_ID="${{ vars.AWS_ACCESS_KEY_ID }}" \ + --from-literal=AWS_SECRET_ACCESS_KEY="${{ vars.AWS_SECRET_ACCESS_KEY }}" \ + --from-file=private_key.pem=/tmp/rsa_key.pem \ + --dry-run=client -o yaml | kubectl apply -f - + else + kubectl create secret generic backend-secrets -n gohorsejobsdev \ + --from-literal=MTU="${{ vars.MTU }}" \ + --from-literal=DATABASE_URL="${{ vars.DATABASE_URL }}" \ + --from-literal=AMQP_URL="${{ vars.AMQP_URL }}" \ + --from-literal=JWT_SECRET="${{ vars.JWT_SECRET }}" \ + --from-literal=JWT_EXPIRATION="${{ vars.JWT_EXPIRATION }}" \ + --from-literal=PASSWORD_PEPPER="${{ vars.PASSWORD_PEPPER }}" \ + --from-literal=COOKIE_SECRET="${{ vars.COOKIE_SECRET }}" \ + --from-literal=COOKIE_DOMAIN="${{ vars.COOKIE_DOMAIN }}" \ + --from-literal=BACKEND_PORT="${{ vars.BACKEND_PORT }}" \ + --from-literal=BACKEND_HOST="${{ vars.BACKEND_HOST }}" \ + --from-literal=ENV="${{ vars.ENV }}" \ + --from-literal=CORS_ORIGINS="${{ vars.CORS_ORIGINS }}" \ + --from-literal=S3_BUCKET="${{ vars.S3_BUCKET }}" \ + --from-literal=AWS_REGION="${{ vars.AWS_REGION }}" \ + --from-literal=AWS_ENDPOINT="${{ vars.AWS_ENDPOINT }}" \ + --from-literal=AWS_ACCESS_KEY_ID="${{ vars.AWS_ACCESS_KEY_ID }}" \ + --from-literal=AWS_SECRET_ACCESS_KEY="${{ vars.AWS_SECRET_ACCESS_KEY }}" \ + --from-literal=RSA_PRIVATE_KEY_BASE64="${{ vars.RSA_PRIVATE_KEY_BASE64 }}" \ + --dry-run=client -o yaml | kubectl apply -f - + fi - name: Deploy to K3s run: | From 285b0f12f5de0509cf38f13781a0b28ece7b9a5d Mon Sep 17 00:00:00 2001 From: GoHorse Deploy Date: Sat, 7 Feb 2026 14:32:28 +0000 Subject: [PATCH 8/8] chore: ensure all changes are committed --- frontend/src/app/page.tsx | 629 +++++++-------------------- frontend/src/components/job-card.tsx | 447 +++++++++---------- 2 files changed, 384 insertions(+), 692 deletions(-) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index d4dda8e..ccf683a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,471 +1,160 @@ -"use client" +use client + +import { Button } from @/components/ui/button +import { mockJobs } from @/lib/mock-data +import Link from next/link +import { ArrowRight, CheckCircle2 } from 'lucide-react' +import Image from next/image +import { motion } from framer-motion +import { useTranslation } from @/lib/i18n +import { Navbar } from @/components/navbar +import { Footer } from @/components/footer +import { HomeSearch } from @/components/home-search +import { JobCard } from @/components/job-card + +export default function Home() { + const { t } = useTranslation() + + return ( +
+ + +
+ {/* Hero Section */} +
+ {/* Background Image with Overlay */} +
+ Background +
+
+ +
+
+ + Encontre a Vaga de TI
+ dos Seus Sonhos. +
+ + + Conectamos você com as melhores empresas e techs. + + + + + + + +
+
+
+ + {/* Search Section */} +
+
+ +
+
+ + {/* Latest Jobs Section */} +
+
+

+ Últimas Vagas Cadastradas +

+ +
+ {mockJobs.slice(0, 4).map((job, index) => ( + + ))} +
+
+
+ + {/* More Jobs Section */} +
+
+
+

+ Mais Vagas +

+ + + +
+ +
+ {mockJobs.slice(0, 8).map((job, index) => ( + + ))} +
+
+
+ + + {/* Bottom CTA Section */} +
+
+
+ + {/* Content */} +
+

+ Milhares de oportunidades
esperam você. +

+

+ Conecte cargos, talentos, tomada de ações de vagas. +

+ + + +
+ + {/* Image Background for CTA */} +
+
+ Professional + {/* Gradient Overlay to blend with dark background */} +
+
+
+
+
+
+
+ +
+
+ ) +} -import { Navbar } from "@/components/navbar" -import { Footer } from "@/components/footer" -import { JobCard } from "@/components/job-card" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { mockJobs, mockTestimonials } from "@/lib/mock-data" -import { FileText, CheckCircle, ArrowRight, Building2, Users, ChevronLeft, ChevronRight, Eye } from "lucide-react" -import Link from "next/link" -import { motion } from "framer-motion" -import Image from "next/image" -import { useTranslation } from "@/lib/i18n" -import { useConfig } from "@/contexts/ConfigContext" - -import { useState, useEffect } from "react" -import type { Job } from "@/lib/types" - -export default function HomePage() { - const { t } = useTranslation() - const config = useConfig() - const [featuredJobs, setFeaturedJobs] = useState(mockJobs.slice(0, 31)) - const [loading, setLoading] = useState(true) - const [featuredIndex, setFeaturedIndex] = useState(0) - const [moreJobsIndex, setMoreJobsIndex] = useState(0) - const [openFilters, setOpenFilters] = useState({ - contractType: false, - workMode: false, - location: false, - salary: false - }) - - const toggleFilter = (filterName: keyof typeof openFilters) => { - setOpenFilters(prev => ({ - contractType: false, - workMode: false, - location: false, - salary: false, - [filterName]: !prev[filterName] - })) - } - - useEffect(() => { - async function fetchFeaturedJobs() { - try { - const apiBase = config.apiUrl - console.log("[DEBUG] API Base URL:", apiBase) - - const mapJobs = (jobs: any[]): Job[] => - jobs.map((j: any) => ({ - id: String(j.id), - title: j.title, - company: j.companyName || t("jobs.confidential"), - location: j.location || t("workMode.remote"), - type: j.employmentType || "full-time", - salary: j.salaryMin ? `R$ ${j.salaryMin}` : t("jobs.salary.negotiable"), - description: j.description, - requirements: j.requirements || [], - postedAt: j.createdAt, - isFeatured: j.isFeatured - })) - - console.log("[DEBUG] Fetching featured jobs from:", `${apiBase}/api/v1/jobs?featured=true&limit=31`) - const featuredRes = await fetch(`${apiBase}/api/v1/jobs?featured=true&limit=31`) - console.log("[DEBUG] Featured response status:", featuredRes.status) - - if (!featuredRes.ok) throw new Error("Failed to fetch featured jobs") - const featuredData = await featuredRes.json() - console.log("[DEBUG] Featured data from API:", featuredData) - - const featuredList = featuredData.data ? mapJobs(featuredData.data) : [] - console.log("[DEBUG] Mapped featured jobs:", featuredList.length, "jobs") - - if (featuredList.length >= 24) { - console.log("[DEBUG] Using featured/API jobs") - setFeaturedJobs(featuredList.slice(0, 31)) - return - } - - console.log("[DEBUG] Fetching fallback jobs from:", `${apiBase}/api/v1/jobs?limit=31`) - const fallbackRes = await fetch(`${apiBase}/api/v1/jobs?limit=31`) - console.log("[DEBUG] Fallback response status:", fallbackRes.status) - - if (!fallbackRes.ok) throw new Error("Failed to fetch fallback jobs") - const fallbackData = await fallbackRes.json() - console.log("[DEBUG] Fallback data from API:", fallbackData) - - const fallbackList = fallbackData.data ? mapJobs(fallbackData.data) : [] - console.log("[DEBUG] Mapped fallback jobs:", fallbackList.length, "jobs") - - const combined = [...featuredList, ...fallbackList].slice(0, 31) - console.log("[DEBUG] Combined jobs:", combined.length, "jobs") - - if (combined.length >= 24) { - console.log("[DEBUG] Using combined jobs") - setFeaturedJobs(combined) - } - } catch (error) { - console.error("[DEBUG] ❌ Error fetching featured jobs:", error) - } finally { - setLoading(false) - } - } - fetchFeaturedJobs() - }, []) - - return ( -
- - -
- {/* Hero Section */} -
-
- Background -
-
-
-
- - {t('home.hero.title')}
{t('home.hero.titleLine2')} -
- - {t('home.hero.subtitle')} - - - - - - -
-
-
-
- - {/* Search Bar Section */} -
-
-
-
- - - - -
- -
- {/* Contract Type - Static Expanded */} -
-
- {t('home.search.contractType')} -
-
- - - -
-
- - {/* Work Mode - Static Expanded */} -
-
- {t('home.search.workMode')} -
-
- - - -
-
- - {/* Location - Static Expanded */} -
-
- {t('home.search.location')} -
-
- -
-
- - {/* Salary - Static Expanded */} -
-
- {t('home.search.salary')} -
-
- -
-
- - {/* Filter Button - Unified */} - -
-
-
-
- - {/* Featured Jobs */} -
-
-
-

{t('home.featuredJobs.title')}

-
- -
- {(featuredJobs.length >= 4 ? featuredJobs.slice(0, 4) : mockJobs.slice(0, 4)) - .map((job, index) => { - const dates = ['02/06', '05/06', '08/06', '11/06']; - const randomDate = dates[index % dates.length]; - const levels = [t('home.levels.mid'), t('home.levels.junior'), t('home.levels.senior'), t('home.levels.mid')]; - const level = levels[index % levels.length]; - const statusLabels = [t('workMode.remote'), t('workMode.hybrid'), t('workMode.onsite'), t('workMode.remote')]; - const statusLabel = statusLabels[index % statusLabels.length]; - return ( - - -
- {/* Header */} -
-
-
- -
- {job.company} -
- - {statusLabel} - -
- - {/* Content */} -
-

{job.title}

-
- Job Illustration -
-
-
- - {/* Footer Section with Separator */} -
-
-

- {level} - - {job.location} -

-
- -
- - - - -
-
-
-
- ) - })} -
-
-
- - {/* More Jobs Section */} -
-
-
-

{t('home.moreJobs.title')}

- - - -
- -
- {mockJobs.slice(0, 8) - .map((job, index) => { - const colors = [ - 'bg-cyan-500', 'bg-blue-500', 'bg-indigo-500', 'bg-gray-500', - 'bg-teal-500', 'bg-sky-500', 'bg-orange-500', 'bg-purple-500' - ]; - const bgColor = colors[index % colors.length]; - const icons = ['💻', '🎨', '📊', '🚀', '⚙️', '🔧', '📱', '🎯']; - const icon = icons[index % icons.length]; - - return ( - - - - {/* Cabeçalho com logo e seta */} -
-
-
- {icon} -
-
-

{job.title}

-

{job.company}

-
-
- -
- - {/* Rodapé com botões */} -
-
- - - - -
-
-
-
-
- ) - })} -
-
-
- - {/* CTA Section */} -
-
-
- {/* Image Layer: Single Image with Seamless Gradient Overlay */} -
- Woman with Notebook - {/* - Seamless Blend Gradient: - Starts solid gray-900 (matching, container) on left. - Fades gradually to transparent on right. - This "dyes" the dark background of the photo to match the container. - */} -
-
- -
- {/* Text Content */} -
-

- {t('home.cta.title')} -

-

- {t('home.cta.subtitle')} -

- -
-
-
-
-
-
- -
-
- ) -} - -function FilterIcon() { - return ( - - - - - - ); -} diff --git a/frontend/src/components/job-card.tsx b/frontend/src/components/job-card.tsx index 2e32843..e5b6de0 100644 --- a/frontend/src/components/job-card.tsx +++ b/frontend/src/components/job-card.tsx @@ -1,223 +1,226 @@ -"use client" +use client; + +import { motion } from framer-motion; +import { + Building2, + MapPin, + Clock, + Heart, +} from lucide-react; +import Link from next/link; +import { useState } from react; +import { + Card, + CardHeader, + CardContent, + CardFooter, +} from @/components/ui/card; +import { Button } from @/components/ui/button; +import { Badge } from @/components/ui/badge; +import { Avatar, AvatarImage, AvatarFallback } from @/components/ui/avatar; +import { formatTimeAgo } from @/lib/utils; +import { useTranslation } from @/lib/i18n; +import { useAuth } from @/contexts/AuthContext; +import { useNotification } from @/contexts/notification-context; + +interface JobCardProps { + job: { + id: string; + title: string; + company: string; + location: string; + type: string; + postedAt: string | Date; + description: string; + salary?: string; + requirements?: string[]; + }; + isApplied?: boolean; + applicationStatus?: string; +} + +export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) { + const { t } = useTranslation(); + const { user } = useAuth(); + const notify = useNotification(); + const [isFavorited, setIsFavorited] = useState(false); + + const getTypeLabel = (type: string) => { + switch (type.toLowerCase()) { + case full-time: + return CLT; + case contract: + return PJ; + case freelance: + return Freelancer; + case remote: + return Remoto; + default: + return type; + } + }; + + const getTypeBadgeVariant = (type: string): default | secondary | outline | destructive | null => { + switch (type.toLowerCase()) { + case full-time: + return secondary; + case contract: + return outline; + case remote: + return default; + default: + return outline; + } + }; + + const getCompanyInitials = (company: string) => { + return company + .split( ) + .map((word) => word[0]) + .join(") + .toUpperCase() + .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 ( + + + +
+
+ + + + {getCompanyInitials(job.company)} + + +
+

+ {job.title} +

+
+ + + {job.company} + +
+
+
+ + +
+
+ + + {/* Job Meta Information */} +
+
+ + {job.location} +
+ +
+
+ + {getTypeLabel(job.type)} + +
+ + {job.salary && ( + + {job.salary} + + )} +
+
+ + {/* Job Description Preview */} +
+

{job.description}

+
+ + {/* Skills/Requirements Preview */} + {job.requirements && job.requirements.length > 0 && ( +
+ {job.requirements.slice(0, 3).map((requirement, index) => ( + + {requirement} + + ))} + {job.requirements.length > 3 && ( + + {t('jobs.requirements.more', { count: job.requirements.length - 3 })} + + )} +
+ )} + + {/* Time Posted */} +
+ + {formatTimeAgo(job.postedAt)} +
+
+ + +
+ + + + {isApplied ? ( + + ) : ( + + + + )} +
+
+
+
+ ); +} -import type { Job } from "@/lib/types"; -import { - Card, - CardContent, - CardFooter, - CardHeader, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { - MapPin, - Briefcase, - DollarSign, - Clock, - Building2, - Heart, -} from "lucide-react"; -import Link from "next/link"; -import { motion } from "framer-motion"; -import { useState } from "react"; -import { useNotify } from "@/contexts/notification-context"; -import { useTranslation } from "@/lib/i18n"; - -interface JobCardProps { - job: Job; - isApplied?: boolean; - applicationStatus?: string; -} - -export function JobCard({ job, isApplied, applicationStatus }: JobCardProps) { - const { t } = useTranslation(); - const [isFavorited, setIsFavorited] = useState(false); - const notify = useNotify(); - - const formatTimeAgo = (dateString: string) => { - const date = new Date(dateString); - const now = new Date(); - const diffInMs = now.getTime() - date.getTime(); - const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); - - if (diffInDays === 0) return t('jobs.posted.today'); - if (diffInDays === 1) return t('jobs.posted.yesterday'); - if (diffInDays < 7) return t('jobs.posted.daysAgo', { count: diffInDays }); - if (diffInDays < 30) return t('jobs.posted.weeksAgo', { count: Math.floor(diffInDays / 7) }); - return t('jobs.posted.monthsAgo', { count: Math.floor(diffInDays / 30) }); - }; - - const getTypeLabel = (type: string) => { - return t(`jobs.types.${type}`) !== `jobs.types.${type}` ? t(`jobs.types.${type}`) : type; - }; - - const getTypeBadgeVariant = (type: string) => { - switch (type) { - case "full-time": - return "default"; - case "part-time": - return "secondary"; - case "contract": - return "outline"; - case "remote": - return "default"; - default: - return "outline"; - } - }; - - const getCompanyInitials = (company: string) => { - return company - .split(" ") - .map((word) => word[0]) - .join("") - .toUpperCase() - .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 ( - - - -
-
- - - - {getCompanyInitials(job.company)} - - -
-

- {job.title} -

-
- - - {job.company} - -
-
-
- - -
-
- - - {/* Job Meta Information */} - {/* Job Meta Information */} -
-
- - {job.location} -
- -
-
- - - {getTypeLabel(job.type)} - -
- - {job.salary && ( - - {job.salary} - - )} -
-
- - {/* Job Description Preview */} -
-

{job.description}

-
- - {/* Skills/Requirements Preview */} - {job.requirements && job.requirements.length > 0 && ( -
- {job.requirements.slice(0, 3).map((requirement, index) => ( - - {requirement} - - ))} - {job.requirements.length > 3 && ( - - {t('jobs.requirements.more', { count: job.requirements.length - 3 })} - - )} -
- )} - - {/* Time Posted */} -
- - {formatTimeAgo(job.postedAt)} -
-
- - -
- - - - {isApplied ? ( - - ) : ( - - - - )} -
-
-
-
- ); -}