chore: merge dev into hml resolving conflicts
This commit is contained in:
commit
bf41617ac6
11 changed files with 595 additions and 361 deletions
|
|
@ -67,24 +67,67 @@ jobs:
|
||||||
|
|
||||||
# Injeta variáveis (Lembre-se de mudar DATABASE_URL para sslmode=disable no Forgejo!)
|
# 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 delete secret backend-secrets -n gohorsejobsdev --ignore-not-found
|
||||||
kubectl create secret generic backend-secrets -n gohorsejobsdev \
|
|
||||||
--from-literal=MTU="${{ vars.MTU }}" \
|
# Prepare RSA key file if available (prefer secrets over vars)
|
||||||
--from-literal=DATABASE_URL="${{ vars.DATABASE_URL }}" \
|
if [ -n "${{ secrets.RSA_PRIVATE_KEY_BASE64 }}" ]; then
|
||||||
--from-literal=AMQP_URL="${{ vars.AMQP_URL }}" \
|
echo "Decoding RSA_PRIVATE_KEY_BASE64 from secrets"
|
||||||
--from-literal=JWT_SECRET="${{ vars.JWT_SECRET }}" \
|
printf '%b' "${{ secrets.RSA_PRIVATE_KEY_BASE64 }}" > /tmp/rsa_key.pem || true
|
||||||
--from-literal=JWT_EXPIRATION="${{ vars.JWT_EXPIRATION }}" \
|
# if it's base64-encoded PEM, decode it
|
||||||
--from-literal=PASSWORD_PEPPER="${{ vars.PASSWORD_PEPPER }}" \
|
if base64 -d /tmp/rsa_key.pem >/dev/null 2>&1; then
|
||||||
--from-literal=COOKIE_SECRET="${{ vars.COOKIE_SECRET }}" \
|
base64 -d /tmp/rsa_key.pem > /tmp/rsa_key_decoded.pem && mv /tmp/rsa_key_decoded.pem /tmp/rsa_key.pem || true
|
||||||
--from-literal=COOKIE_DOMAIN="${{ vars.COOKIE_DOMAIN }}" \
|
fi
|
||||||
--from-literal=BACKEND_PORT="${{ vars.BACKEND_PORT }}" \
|
elif [ -n "${{ vars.RSA_PRIVATE_KEY_BASE64 }}" ]; then
|
||||||
--from-literal=BACKEND_HOST="${{ vars.BACKEND_HOST }}" \
|
echo "Decoding RSA_PRIVATE_KEY_BASE64 from vars"
|
||||||
--from-literal=ENV="${{ vars.ENV }}" \
|
printf '%b' "${{ vars.RSA_PRIVATE_KEY_BASE64 }}" > /tmp/rsa_key.pem || true
|
||||||
--from-literal=CORS_ORIGINS="${{ vars.CORS_ORIGINS }}" \
|
if base64 -d /tmp/rsa_key.pem >/dev/null 2>&1; then
|
||||||
--from-literal=S3_BUCKET="${{ vars.S3_BUCKET }}" \
|
base64 -d /tmp/rsa_key.pem > /tmp/rsa_key_decoded.pem && mv /tmp/rsa_key_decoded.pem /tmp/rsa_key.pem || true
|
||||||
--from-literal=AWS_REGION="${{ vars.AWS_REGION }}" \
|
fi
|
||||||
--from-literal=AWS_ENDPOINT="${{ vars.AWS_ENDPOINT }}" \
|
fi
|
||||||
--from-literal=AWS_ACCESS_KEY_ID="${{ vars.AWS_ACCESS_KEY_ID }}" \
|
|
||||||
--from-literal=AWS_SECRET_ACCESS_KEY="${{ vars.AWS_SECRET_ACCESS_KEY }}"
|
# 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
|
- name: Deploy to K3s
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
51
.github/workflows/migrate.yml
vendored
Normal file
51
.github/workflows/migrate.yml
vendored
Normal file
|
|
@ -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
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
@ -44,60 +45,69 @@ func main() {
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
// List of migrations to run (in order)
|
// Discover migrations directory from several probable locations
|
||||||
migrations := []string{
|
possibleDirs := []string{
|
||||||
"024_create_external_services_credentials.sql",
|
"migrations",
|
||||||
"025_create_chat_tables.sql",
|
"backend/migrations",
|
||||||
"026_create_system_settings.sql",
|
"../migrations",
|
||||||
"027_create_email_system.sql",
|
"/home/yamamoto/lab/gohorsejobs/backend/migrations",
|
||||||
"028_add_avatar_url_to_users.sql",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, migFile := range migrations {
|
var migrationsDir string
|
||||||
log.Printf("Processing migration: %s", migFile)
|
for _, d := range possibleDirs {
|
||||||
|
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||||
|
migrationsDir = d
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try multiple paths
|
if migrationsDir == "" {
|
||||||
paths := []string{
|
log.Fatal("Could not find migrations directory; looked in common locations")
|
||||||
"migrations/" + migFile,
|
}
|
||||||
"backend/migrations/" + migFile,
|
|
||||||
"../migrations/" + migFile,
|
entries, err := os.ReadDir(migrationsDir)
|
||||||
"/home/yamamoto/lab/gohorsejobs/backend/migrations/" + migFile,
|
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
|
// Execute the whole file. lib/pq supports multi-statement Exec.
|
||||||
var readErr error
|
sqlText := string(content)
|
||||||
|
log.Printf("Executing migration file %s", fullPath)
|
||||||
for _, p := range paths {
|
_, err = db.Exec(sqlText)
|
||||||
content, readErr = os.ReadFile(p)
|
if err != nil {
|
||||||
if readErr == nil {
|
errStr := err.Error()
|
||||||
log.Printf("Found migration at: %s", p)
|
// Tolerable errors: object already exists, column doesn't exist in some contexts,
|
||||||
break
|
// 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)
|
||||||
|
|
||||||
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 == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("Executing: %s", trimmed)
|
log.Fatalf("Failed applying migration %s: %v", migFile, err)
|
||||||
_, 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.Printf("Migration %s applied successfully", migFile)
|
log.Printf("Migration %s applied successfully", migFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"strings"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -92,10 +93,10 @@ func (s *CredentialsService) GetDecryptedKey(ctx context.Context, serviceName st
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, error) {
|
func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, error) {
|
||||||
// 1. Decode Private Key from Env
|
// 1. Load Private Key bytes from env with fallbacks (base64, raw PEM, \n literals)
|
||||||
rawPrivateKey, err := base64.StdEncoding.DecodeString(os.Getenv("RSA_PRIVATE_KEY_BASE64"))
|
rawPrivateKey, err := getRawPrivateKeyBytes()
|
||||||
if err != nil {
|
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)
|
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
|
// EncryptPayload encrypts a payload using the derived public key
|
||||||
func (s *CredentialsService) EncryptPayload(payload string) (string, error) {
|
func (s *CredentialsService) EncryptPayload(payload string) (string, error) {
|
||||||
// 1. Decode Private Key from Env (to derive Public Key)
|
// 1. Load Private Key bytes from env with fallbacks (base64, raw PEM, \n literals)
|
||||||
// In a real scenario, you might store Public Key separately, but we can derive it.
|
rawPrivateKey, err := getRawPrivateKeyBytes()
|
||||||
rawPrivateKey, err := base64.StdEncoding.DecodeString(os.Getenv("RSA_PRIVATE_KEY_BASE64"))
|
|
||||||
if err != nil {
|
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)
|
block, _ := pem.Decode(rawPrivateKey)
|
||||||
|
|
@ -257,8 +257,54 @@ func (s *CredentialsService) EncryptPayload(payload string) (string, error) {
|
||||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
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
|
// BootstrapCredentials checks if credentials are in DB, if not, migrates from Env
|
||||||
func (s *CredentialsService) BootstrapCredentials(ctx context.Context) error {
|
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
|
// List of services and their env mapping
|
||||||
services := map[string]func() interface{}{
|
services := map[string]func() interface{}{
|
||||||
"stripe": func() interface{} {
|
"stripe": func() interface{} {
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,29 @@ CREATE TABLE IF NOT EXISTS regions (
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cities (
|
CREATE TABLE IF NOT EXISTS cities (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
region_id INT NOT NULL,
|
region_id INT,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
FOREIGN KEY (region_id) REFERENCES regions(id) ON DELETE CASCADE
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Indexes
|
-- Ensure column and constraints exist when table already existed without them
|
||||||
CREATE INDEX idx_regions_country ON regions(country_code);
|
ALTER TABLE cities
|
||||||
CREATE INDEX idx_cities_region ON cities(region_id);
|
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
|
-- Comments
|
||||||
COMMENT ON TABLE regions IS 'Global Regions (States, Provinces, Prefectures)';
|
COMMENT ON TABLE regions IS 'Global Regions (States, Provinces, Prefectures)';
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,25 @@
|
||||||
|
|
||||||
-- Increase status column length to support 'force_change_password' (21 chars)
|
-- Increase status column length to support 'force_change_password' (21 chars)
|
||||||
ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(50);
|
ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(50);
|
||||||
|
-- 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$$;
|
||||||
|
|
||||||
|
-- Update non-identifier fields for the superadmin row (if present)
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
identifier = 'lol',
|
|
||||||
email = 'lol@gohorsejobs.com',
|
email = 'lol@gohorsejobs.com',
|
||||||
full_name = 'Dr. Horse Expert',
|
full_name = 'Dr. Horse Expert',
|
||||||
name = 'Dr. Horse Expert',
|
name = 'Dr. Horse Expert',
|
||||||
status = 'force_change_password',
|
status = 'force_change_password',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE identifier = 'superadmin' OR email = 'admin@gohorsejobs.com';
|
WHERE identifier = 'superadmin' OR identifier = 'lol' OR email = 'admin@gohorsejobs.com';
|
||||||
|
|
|
||||||
27
backend/scripts/validate_rsa_key.sh
Normal file
27
backend/scripts/validate_rsa_key.sh
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
"use client"
|
use client
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from @/components/ui/button
|
||||||
import { mockJobs } from "@/lib/mock-data"
|
import { mockJobs } from @/lib/mock-data
|
||||||
import Link from "next/link"
|
import Link from next/link
|
||||||
import { ArrowRight, CheckCircle2 } from 'lucide-react'
|
import { ArrowRight, CheckCircle2 } from 'lucide-react'
|
||||||
import Image from "next/image"
|
import Image from next/image
|
||||||
import { motion } from "framer-motion"
|
import { motion } from framer-motion
|
||||||
import { useTranslation } from "@/lib/i18n"
|
import { useTranslation } from @/lib/i18n
|
||||||
import { Navbar } from "@/components/navbar"
|
import { Navbar } from @/components/navbar
|
||||||
import { Footer } from "@/components/footer"
|
import { Footer } from @/components/footer
|
||||||
import { HomeSearch } from "@/components/home-search"
|
import { HomeSearch } from @/components/home-search
|
||||||
import { JobCard } from "@/components/job-card"
|
import { JobCard } from @/components/job-card
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
|
<div className=min-h-screen bg-gray-50 flex flex-col font-sans>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="flex-grow">
|
<main className=flex-grow>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative h-[500px] flex items-center justify-center bg-[#1F2F40]">
|
<section className=relative min-h-[500px] flex items-center justify-center bg-[#1F2F40] py-16 md:py-0>
|
||||||
{/* Background Image with Overlay */}
|
{/* Background Image with Overlay */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className=absolute inset-0 z-0>
|
||||||
<Image
|
<Image
|
||||||
src="/10.png"
|
src=/10.png
|
||||||
alt="Background"
|
alt=Background
|
||||||
fill
|
fill
|
||||||
className="object-cover opacity-60 contrast-125"
|
className=object-cover opacity-60 contrast-125
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/90 to-transparent" />
|
<div className=absolute inset-0 bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/90 to-transparent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto px-4 relative z-10 text-center sm:text-left">
|
<div className=container mx-auto px-4 relative z-10 text-center sm:text-left>
|
||||||
<div className="max-w-3xl">
|
<div className=max-w-3xl>
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight tracking-tight"
|
className=text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight tracking-tight
|
||||||
>
|
>
|
||||||
Encontre a Vaga de TI <br className="hidden sm:block" />
|
Encontre a Vaga de TI <br className=hidden sm:block />
|
||||||
dos Seus Sonhos.
|
dos Seus Sonhos.
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
|
|
@ -50,9 +50,9 @@ export default function Home() {
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: 0.1 }}
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
className="text-lg md:text-xl text-gray-300 mb-8 max-w-xl leading-relaxed"
|
className=text-lg md:text-xl text-gray-300 mb-8 max-w-xl leading-relaxed mx-auto sm:mx-0
|
||||||
>
|
>
|
||||||
Conectamos você com as melhores empresas e techs.
|
Conectamos você com as melhores empresas e techs.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -60,8 +60,8 @@ export default function Home() {
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<Link href="/jobs">
|
<Link href=/jobs>
|
||||||
<Button className="h-12 px-8 bg-orange-500 hover:bg-orange-600 text-white font-bold text-lg rounded-md shadow-lg transition-transform hover:scale-105">
|
<Button className=h-12 px-8 bg-orange-500 hover:bg-orange-600 text-white font-bold text-lg rounded-md shadow-lg transition-transform hover:scale-105 w-full sm:w-auto>
|
||||||
Buscar Vagas
|
Buscar Vagas
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -71,20 +71,20 @@ export default function Home() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Search Section */}
|
{/* Search Section */}
|
||||||
<section className="px-4 mb-16">
|
<section className=px-4 mb-16>
|
||||||
<div className="container mx-auto">
|
<div className=container mx-auto>
|
||||||
<HomeSearch />
|
<HomeSearch />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Latest Jobs Section */}
|
{/* Latest Jobs Section */}
|
||||||
<section className="py-12 bg-gray-50">
|
<section className=py-12 bg-gray-50>
|
||||||
<div className="container mx-auto px-4">
|
<div className=container mx-auto px-4>
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-8">
|
<h2 className=text-2xl md:text-3xl font-bold text-gray-900 mb-8 text-center sm:text-left>
|
||||||
Últimas Vagas Cadastradas
|
Últimas Vagas Cadastradas
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className=grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6>
|
||||||
{mockJobs.slice(0, 4).map((job, index) => (
|
{mockJobs.slice(0, 4).map((job, index) => (
|
||||||
<JobCard key={job.id} job={job} />
|
<JobCard key={job.id} job={job} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -93,20 +93,20 @@ export default function Home() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* More Jobs Section */}
|
{/* More Jobs Section */}
|
||||||
<section className="py-12 bg-white">
|
<section className=py-12 bg-white>
|
||||||
<div className="container mx-auto px-4">
|
<div className=container mx-auto px-4>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className=flex flex-col sm:flex-row items-center justify-between mb-8 gap-4>
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">
|
<h2 className=text-2xl md:text-3xl font-bold text-gray-900>
|
||||||
Mais Vagas
|
Mais Vagas
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/jobs">
|
<Link href=/jobs className=w-full sm:w-auto>
|
||||||
<Button className="bg-orange-500 hover:bg-orange-600 text-white font-bold">
|
<Button className=bg-orange-500 hover:bg-orange-600 text-white font-bold w-full sm:w-auto>
|
||||||
Ver Todas Vagas
|
Ver Todas Vagas
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className=grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6>
|
||||||
{mockJobs.slice(0, 8).map((job, index) => (
|
{mockJobs.slice(0, 8).map((job, index) => (
|
||||||
<JobCard key={`more-${job.id}-${index}`} job={job} />
|
<JobCard key={`more-${job.id}-${index}`} job={job} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -116,36 +116,36 @@ export default function Home() {
|
||||||
|
|
||||||
|
|
||||||
{/* Bottom CTA Section */}
|
{/* Bottom CTA Section */}
|
||||||
<section className="py-16 bg-white">
|
<section className=py-16 bg-white>
|
||||||
<div className="container mx-auto px-4">
|
<div className=container mx-auto px-4>
|
||||||
<div className="bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]">
|
<div className=bg-[#1F2F40] rounded-[2rem] p-8 md:p-16 relative overflow-hidden text-center md:text-left flex flex-col md:flex-row items-center justify-between min-h-[400px]>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="relative z-10 max-w-xl">
|
<div className=relative z-10 max-w-xl>
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
|
<h2 className=text-3xl md:text-4xl font-bold text-white mb-4 leading-tight>
|
||||||
Milhares de oportunidades <br/> esperam você.
|
Milhares de oportunidades <br className=hidden sm:block/> esperam você.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base text-gray-300 mb-8">
|
<p className=text-base text-gray-300 mb-8>
|
||||||
Conecte cargos, talentos, tomada de ações de vagas.
|
Conecte cargos, talentos, tomada de ações de vagas.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/register/user">
|
<Link href=/register/user className=w-full sm:w-auto>
|
||||||
<Button className="h-12 px-8 bg-white text-gray-900 hover:bg-gray-100 font-bold text-lg rounded-md">
|
<Button className=h-12 px-8 bg-white text-gray-900 hover:bg-gray-100 font-bold text-lg rounded-md w-full sm:w-auto>
|
||||||
Cadastre-se
|
Cadastre-se
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Background for CTA */}
|
{/* Image Background for CTA */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className=absolute inset-0 z-0>
|
||||||
<div className="absolute right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2">
|
<div className=absolute right-0 top-0 h-full w-full md:w-2/3 lg:w-1/2>
|
||||||
<Image
|
<Image
|
||||||
src="/muie.jpeg"
|
src=/muie.jpeg
|
||||||
alt="Professional"
|
alt=Professional
|
||||||
fill
|
fill
|
||||||
className="object-cover object-center md:object-right opacity-40 md:opacity-100" // Opacity adjusted for mobile readability
|
className=object-cover object-center md:object-right opacity-40 md:opacity-100 // Opacity adjusted for mobile readability
|
||||||
/>
|
/>
|
||||||
{/* Gradient Overlay to blend with dark background */}
|
{/* Gradient Overlay to blend with dark background */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/30 to-transparent" />
|
<div className=absolute inset-0 bg-gradient-to-t md:bg-gradient-to-r from-[#1F2F40] via-[#1F2F40]/30 to-transparent />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,3 +157,4 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
transition={{ type: spring, stiffness: 300, damping: 20 }}
|
||||||
|
>
|
||||||
|
<Card className=relative hover:shadow-lg transition-all duration-300 border-l-4 border-l-primary/20 hover:border-l-primary h-full flex flex-col>
|
||||||
|
<CardHeader className=pb-4>
|
||||||
|
<div className=flex items-start justify-between>
|
||||||
|
<div className=flex items-center gap-3>
|
||||||
|
<Avatar className=h-12 w-12>
|
||||||
|
<AvatarImage
|
||||||
|
src={`https://avatar.vercel.sh/${job.company}`}
|
||||||
|
alt={job.company}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className=bg-primary/10 text-primary font-semibold>
|
||||||
|
{getCompanyInitials(job.company)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className=font-semibold text-lg text-balance leading-tight hover:text-primary transition-colors>
|
||||||
|
{job.title}
|
||||||
|
</h3>
|
||||||
|
<div className=flex items-center gap-2 mt-1>
|
||||||
|
<Building2 className=h-4 w-4 text-muted-foreground />
|
||||||
|
<span className=text-muted-foreground font-medium>
|
||||||
|
{job.company}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFavorite}
|
||||||
|
className=shrink-0 p-2 hover:bg-muted rounded-full transition-colors
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={`h-4 w-4 transition-colors ${isFavorited
|
||||||
|
? fill-red-500 text-red-500
|
||||||
|
: text-muted-foreground hover:text-red-500
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className=space-y-4 flex-1>
|
||||||
|
{/* Job Meta Information */}
|
||||||
|
<div className=flex flex-col gap-2 text-sm>
|
||||||
|
<div className=flex items-center gap-2 text-muted-foreground>
|
||||||
|
<MapPin className=h-4 w-4 shrink-0 />
|
||||||
|
<span className=truncate>{job.location}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className=flex items-center justify-between w-full flex-wrap gap-2>
|
||||||
|
<div className=flex items-center gap-2>
|
||||||
|
<Badge
|
||||||
|
variant={getTypeBadgeVariant(job.type)}
|
||||||
|
className=text-xs
|
||||||
|
>
|
||||||
|
{getTypeLabel(job.type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.salary && (
|
||||||
|
<span className=font-medium text-foreground whitespace-nowrap>
|
||||||
|
{job.salary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Description Preview */}
|
||||||
|
<div className=text-sm text-muted-foreground>
|
||||||
|
<p className=line-clamp-2>{job.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills/Requirements Preview */}
|
||||||
|
{job.requirements && job.requirements.length > 0 && (
|
||||||
|
<div className=flex flex-wrap gap-2>
|
||||||
|
{job.requirements.slice(0, 3).map((requirement, index) => (
|
||||||
|
<Badge key={index} variant=outline className=text-xs>
|
||||||
|
{requirement}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{job.requirements.length > 3 && (
|
||||||
|
<Badge
|
||||||
|
variant=outline
|
||||||
|
className=text-xs text-muted-foreground
|
||||||
|
>
|
||||||
|
{t('jobs.requirements.more', { count: job.requirements.length - 3 })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Posted */}
|
||||||
|
<div className=flex items-center gap-2 text-xs text-muted-foreground>
|
||||||
|
<Clock className=h-3 w-3 />
|
||||||
|
<span>{formatTimeAgo(job.postedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className=pt-4 border-t>
|
||||||
|
<div className=flex flex-col sm:flex-row gap-2 w-full>
|
||||||
|
<Link href={`/jobs/${job.id}`} className=w-full sm:flex-1>
|
||||||
|
<Button variant=outline className=w-full cursor-pointer>
|
||||||
|
{t('jobs.card.viewDetails')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{isApplied ? (
|
||||||
|
<Button className=w-full sm:flex-1 cursor-default bg-emerald-600 hover:bg-emerald-700 text-white variant=secondary>
|
||||||
|
{applicationStatus === 'pending' ? t('jobs.card.applied') :
|
||||||
|
applicationStatus === 'reviewing' ? t('jobs.card.reviewing') :
|
||||||
|
applicationStatus === 'interview' ? t('jobs.card.interview') :
|
||||||
|
t('jobs.card.applied')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link href={`/jobs/${job.id}/apply`} className=w-full sm:flex-1>
|
||||||
|
<Button className=w-full cursor-pointer>{t('jobs.card.apply')}</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ y: -2 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
|
||||||
>
|
|
||||||
<Card className="relative hover:shadow-lg transition-all duration-300 border-l-4 border-l-primary/20 hover:border-l-primary h-full flex flex-col">
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar className="h-12 w-12">
|
|
||||||
<AvatarImage
|
|
||||||
src={`https://avatar.vercel.sh/${job.company}`}
|
|
||||||
alt={job.company}
|
|
||||||
/>
|
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-semibold">
|
|
||||||
{getCompanyInitials(job.company)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-lg text-balance leading-tight hover:text-primary transition-colors">
|
|
||||||
{job.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground font-medium">
|
|
||||||
{job.company}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleFavorite}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={`h-4 w-4 transition-colors ${isFavorited
|
|
||||||
? "fill-red-500 text-red-500"
|
|
||||||
: "text-muted-foreground hover:text-red-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-4 flex-1">
|
|
||||||
{/* Job Meta Information */}
|
|
||||||
{/* Job Meta Information */}
|
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<MapPin className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">{job.location}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Briefcase className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<Badge
|
|
||||||
variant={getTypeBadgeVariant(job.type)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{getTypeLabel(job.type)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{job.salary && (
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{job.salary}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Job Description Preview */}
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<p className="line-clamp-2">{job.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skills/Requirements Preview */}
|
|
||||||
{job.requirements && job.requirements.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{job.requirements.slice(0, 3).map((requirement, index) => (
|
|
||||||
<Badge key={index} variant="outline" className="text-xs">
|
|
||||||
{requirement}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{job.requirements.length > 3 && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t('jobs.requirements.more', { count: job.requirements.length - 3 })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Time Posted */}
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
<span>{formatTimeAgo(job.postedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="pt-4 border-t">
|
|
||||||
<div className="flex gap-2 w-full">
|
|
||||||
<Link href={`/jobs/${job.id}`} className="flex-1">
|
|
||||||
<Button variant="outline" className="w-full cursor-pointer">
|
|
||||||
{t('jobs.card.viewDetails')}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
{isApplied ? (
|
|
||||||
<Button className="flex-1 w-full cursor-default bg-emerald-600 hover:bg-emerald-700 text-white" variant="secondary">
|
|
||||||
{applicationStatus === 'pending' ? t('jobs.card.applied') :
|
|
||||||
applicationStatus === 'reviewing' ? t('jobs.card.reviewing') :
|
|
||||||
applicationStatus === 'interview' ? t('jobs.card.interview') :
|
|
||||||
t('jobs.card.applied')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Link href={`/jobs/${job.id}/apply`} className="flex-1">
|
|
||||||
<Button className="w-full cursor-pointer">{t('jobs.card.apply')}</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
28
private_key.pem
Normal file
28
private_key.pem
Normal file
|
|
@ -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-----
|
||||||
1
rsa_base64.txt
Normal file
1
rsa_base64.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ2M5c3VEb2J3SndDR0oKVkZ2Z2ExQnhLa21tR094b0Y4ek5pYnY2bDMzL1NDRkVCYjVmRmFlbnh2WW90RUdXVXcwZmVkNHpJY1gzczZoQQpxMnlMcjNuSXlncExwY09menB6UHhhczQ5UDE3TkEzQ2h2bzNrLzBlR2tCRDZQSE0xczYycVBQK2ZLRVp0d2xTCnExV2FGeGZjOTQ5aXFKQVF2VzZ3LzdXZ01aRGluZXEzSXpoVlZVQUZkdzNpY1pydTk3aENqUERVL3YzZUZUUzcKa3ZHckRZR0FIWlh6eWx1M0VyOWlmS1lIZEt4T3JXRm1HYVNzUHNZTWRLTnhXRmsrWjM4TlZVbndTSDNURWlWLwpTNGUzM3RUa2RNbU5wWSs2ZTlDaWdiMDlSbk9hbGo1bFBqRkdBOW5USE1KeHBzSHZTS3U4dk1CcitPWjRDTTNVClJIN01VWDAxQWdNQkFBRUNnZ0VBTUt4ZEZvLzRNZVBZNG05ODRCNFcvMGlZTnYvaWl6TGFLT0J0b0xzS2NMZUsKelQra3RYS1BIemxVeXZGK3B5RlEzL0pZQTI0VktBY1hoUnBEV2h1TGZjYWRJN0VlOVBiS2JLbUV1M0JKREVQcgpnbWQ5dnU5T25kK1JEeDMwb1VyNUplNUZYeVNCaG1wYVl6N0xHREhTRGd6Y2MwRUhENUhXZWQrSmtFZmVnRTd3Ck12dDlLSzQxbUdkYVF3aVBIUzQzdXpaaFFKRXF5YlAzaS82U1VuVjJDbnRPaHV0eExsUGsycnBIbm5zMHAvU3QKRHZsY3Y2MXZkdUlhZWo0SUZCcnBTd1RFNDVwdklma3ZOWngwcEphcE0xalpoZThGLzJUN0d0WERrb0ZRdmVvMQozWUIxYWFkcEN4N3UyOEl6UVR3QlpWd3FoQ3BpMmE1K3FWWVVUMEFVM3dLQmdRRFlZVXhRVUJpVW42YlhvQXN4CkpUb3pvWDBLNTBjWDJkOExWWTFPVXVocFJYYnp0UzJYWHR5ZmVvQVF0RVdvVDNVTzd2akVlZFdzd2ZvMmorTjMKWklYaWc3VnlqL0xONWxaeUN3V1luNFM0aW5FU2pLbHppNFB2OEQ0RitGa2dnMFdzVmd6YlRhNFA3ZmFIbkRObgplRUhkeUovWlE4K1hZeEJwU0FFOGVjV1Fsd0tCZ1FDNXRHYmZ6aDc3UkVzdjFoNmI4N3Z1bHJHSGMrT0JJVFRVCllGdTFZZlhwdmJYeDlnZVJmTkxEdFVoVWlzNnZnZmNRVjZzeFpWZjc4VWRscWlUQmViUkxwY3ZvQmxIVi9NUFoKVDNUc1pIMXZYd2lpdE9zQklGektrbjh4ZGp1TjZtTjVsTGpJNktrWWVWb1VMWWlVTmJpWitXaTdQWEJQbmM1SQpqQk81RWF5T0V3S0JnUURVMnBuc28yNGF2aGF0SktYOTJXWXdwaHBRb0lTQ0JQUHh2VjM4LzNmYkh0ZE9GQnRlClBaWUFWOHdsSW9FbmVjcG9QMUorVEcrU3UxcjlVM3hxMVhzVEFZZDd3L2tRN1JaNnB6Y0JGV0xFK29NU3dVWnMKQUlGd2hiOHR0a2xPdjNQSmZQaTJ2dXFNaHdVdUQ4MU5hckk0andRWUFTbnovU0tHdnF0Z3AxVmV6d0tCZ0RvTApET3grL0dnRTNJdERIYVlZOUhDS1lVcTVDaTdlTmlqN1JTN1lRNGlmWnpNTmR5Z2VIN0pVQXh1Smx6aDhJc0RVCjVnazJaOTJ6ZUdGcVlMcW9VNVloYUM1SmEySzY4bXdGemNIbFZ0OXNrTUpxVWRtMFI4eDVKWkJNS0NrZlRhQSsKdjlMc0JZNUV2OGIyeEcydXJOaFRnRXlsMDJqUEpoNit5WnRhenRoSkFvR0FIUklYL1cwSWx5YUxubzdXekF3TQpsU3NOZkpwVHZabWtyaTBVT0dYTTJZYUt1UVo2NTJ0NkVCRHRmTTdPMTZlVjNLTkJibHQxTGpJdHovUzhraUZpClE4dEdsdU8yN0huNS9hdWl4SmpsY1puem9VWHJFakFyYThsbWdBbzQxRG0waWNEcExVemhpeFowcVM4ZDZZZnAKUklUMUlvV1N1dTJmdk9PdnFlenE2Ymc9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
|
||||||
Loading…
Reference in a new issue