refactor: unify schema - eliminate core_* tables

BREAKING CHANGE: Removed core_companies, core_users, core_user_roles tables

Migrations:
- Create 020_unify_schema.sql: adds tenant_id, email, name to users table
- Create user_roles table (replaces core_user_roles)
- Disable 009_create_core_tables.sql (renamed to .disabled)
- Update 010_seed_super_admin.sql to use unified tables

Backend Repositories:
- company_repository.go: use companies table with INT id
- user_repository.go: use users/user_roles with INT id conversion

Seeders:
- All seeders now use companies/users/user_roles tables
- Removed all core_* table insertions
- Query companies by slug to get SERIAL id

This eliminates the redundancy between core_* and legacy tables.
This commit is contained in:
Tiago Yamamoto 2025-12-24 11:06:31 -03:00
parent af2719f2e6
commit 7d99e77468
11 changed files with 269 additions and 210 deletions

View file

@ -4,9 +4,10 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strconv"
"time" "time"
"github.com/google/uuid"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
) )
@ -19,70 +20,97 @@ func NewCompanyRepository(db *sql.DB) *CompanyRepository {
} }
func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (*entity.Company, error) { func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (*entity.Company, error) {
if company.ID == "" { // companies table uses SERIAL id, we let DB generate it
company.ID = uuid.New().String()
}
query := ` query := `
INSERT INTO core_companies (id, name, document, contact, status, created_at, updated_at) INSERT INTO companies (name, slug, type, document, email, description, verified, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id RETURNING id
` `
slug := company.Name // TODO: slugify function
var id int
err := r.db.QueryRowContext(ctx, query, err := r.db.QueryRowContext(ctx, query,
company.ID,
company.Name, company.Name,
slug,
"company",
company.Document, company.Document,
company.Contact, company.Contact, // mapped to email
company.Status, "{}", // description as JSON
true, // verified
company.Status == "ACTIVE",
company.CreatedAt, company.CreatedAt,
company.UpdatedAt, company.UpdatedAt,
).Scan(&company.ID) ).Scan(&id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
company.ID = strconv.Itoa(id)
return company, nil return company, nil
} }
func (r *CompanyRepository) FindByID(ctx context.Context, id string) (*entity.Company, error) { func (r *CompanyRepository) FindByID(ctx context.Context, id string) (*entity.Company, error) {
query := `SELECT id, name, document, contact, status, created_at, updated_at FROM core_companies WHERE id = $1` query := `SELECT id, name, document, email,
row := r.db.QueryRowContext(ctx, query, id) CASE WHEN active THEN 'ACTIVE' ELSE 'INACTIVE' END as status,
created_at, updated_at
FROM companies WHERE id = $1`
numID, err := strconv.Atoi(id)
if err != nil {
return nil, fmt.Errorf("invalid company id: %s", id)
}
row := r.db.QueryRowContext(ctx, query, numID)
c := &entity.Company{} c := &entity.Company{}
err := row.Scan(&c.ID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt) var dbID int
err = row.Scan(&dbID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, errors.New("company not found") return nil, errors.New("company not found")
} }
c.ID = strconv.Itoa(dbID)
return c, err return c, err
} }
func (r *CompanyRepository) Update(ctx context.Context, company *entity.Company) (*entity.Company, error) { func (r *CompanyRepository) Update(ctx context.Context, company *entity.Company) (*entity.Company, error) {
company.UpdatedAt = time.Now() company.UpdatedAt = time.Now()
numID, err := strconv.Atoi(company.ID)
if err != nil {
return nil, fmt.Errorf("invalid company id: %s", company.ID)
}
query := ` query := `
UPDATE core_companies UPDATE companies
SET name=$1, document=$2, contact=$3, status=$4, updated_at=$5 SET name=$1, document=$2, email=$3, active=$4, updated_at=$5
WHERE id=$6 WHERE id=$6
` `
_, err := r.db.ExecContext(ctx, query, _, err = r.db.ExecContext(ctx, query,
company.Name, company.Name,
company.Document, company.Document,
company.Contact, company.Contact,
company.Status, company.Status == "ACTIVE",
company.UpdatedAt, company.UpdatedAt,
company.ID, numID,
) )
return company, err return company, err
} }
func (r *CompanyRepository) Delete(ctx context.Context, id string) error { func (r *CompanyRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM core_companies WHERE id = $1` numID, err := strconv.Atoi(id)
_, err := r.db.ExecContext(ctx, query, id) if err != nil {
return fmt.Errorf("invalid company id: %s", id)
}
query := `DELETE FROM companies WHERE id = $1`
_, err = r.db.ExecContext(ctx, query, numID)
return err return err
} }
func (r *CompanyRepository) FindAll(ctx context.Context) ([]*entity.Company, error) { func (r *CompanyRepository) FindAll(ctx context.Context) ([]*entity.Company, error) {
query := `SELECT id, name, document, contact, status, created_at, updated_at FROM core_companies` query := `SELECT id, name, document, email,
CASE WHEN active THEN 'ACTIVE' ELSE 'INACTIVE' END as status,
created_at, updated_at
FROM companies`
rows, err := r.db.QueryContext(ctx, query) rows, err := r.db.QueryContext(ctx, query)
if err != nil { if err != nil {
return nil, err return nil, err
@ -92,9 +120,11 @@ func (r *CompanyRepository) FindAll(ctx context.Context) ([]*entity.Company, err
var companies []*entity.Company var companies []*entity.Company
for rows.Next() { for rows.Next() {
c := &entity.Company{} c := &entity.Company{}
if err := rows.Scan(&c.ID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil { var dbID int
if err := rows.Scan(&dbID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err return nil, err
} }
c.ID = strconv.Itoa(dbID)
companies = append(companies, c) companies = append(companies, c)
} }
return companies, nil return companies, nil

View file

@ -3,10 +3,10 @@ package postgres
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "fmt"
"strconv"
"time" "time"
"github.com/google/uuid"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
) )
@ -19,47 +19,58 @@ func NewUserRepository(db *sql.DB) *UserRepository {
} }
func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.User, error) { func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.User, error) {
if user.ID == "" {
user.ID = uuid.New().String()
}
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
// Serialize metadata // Convert tenant_id from string to int
metadata, err := json.Marshal(user.Metadata) var tenantID *int
if err != nil { if user.TenantID != "" {
return nil, err tid, err := strconv.Atoi(user.TenantID)
if err == nil {
tenantID = &tid
}
} }
// 1. Insert User // 1. Insert User - users table has SERIAL id
query := ` query := `
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, metadata, created_at, updated_at) INSERT INTO users (identifier, password_hash, role, full_name, email, name, tenant_id, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
` `
_, err = tx.ExecContext(ctx, query,
user.ID, var id int
user.TenantID, // Map the first role to the role column, default to 'jobSeeker'
role := "jobSeeker"
if len(user.Roles) > 0 {
role = user.Roles[0].Name
}
err = tx.QueryRowContext(ctx, query,
user.Email, // identifier = email for now
user.PasswordHash,
role,
user.Name, user.Name,
user.Email, user.Email,
user.PasswordHash, user.Name,
tenantID,
user.Status, user.Status,
metadata,
user.CreatedAt, user.CreatedAt,
user.UpdatedAt, user.UpdatedAt,
) ).Scan(&id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
user.ID = strconv.Itoa(id)
// 2. Insert Roles // 2. Insert Roles into user_roles table
if len(user.Roles) > 0 { if len(user.Roles) > 0 {
roleQuery := `INSERT INTO core_user_roles (user_id, role) VALUES ($1, $2)` roleQuery := `INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING`
for _, role := range user.Roles { for _, role := range user.Roles {
_, err := tx.ExecContext(ctx, roleQuery, user.ID, role.Name) _, err := tx.ExecContext(ctx, roleQuery, id, role.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -74,49 +85,66 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
} }
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) { func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE email = $1` query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at
FROM users WHERE email = $1 OR identifier = $1`
row := r.db.QueryRowContext(ctx, query, email) row := r.db.QueryRowContext(ctx, query, email)
u := &entity.User{} u := &entity.User{}
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt) var dbID int
err := row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil // Return nil if not found return nil, nil // Return nil if not found
} }
return nil, err return nil, err
} }
u.ID = strconv.Itoa(dbID)
u.Roles, _ = r.getRoles(ctx, u.ID) u.Roles, _ = r.getRoles(ctx, dbID)
return u, nil return u, nil
} }
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) { func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at FROM core_users WHERE id = $1` numID, err := strconv.Atoi(id)
row := r.db.QueryRowContext(ctx, query, id) if err != nil {
return nil, fmt.Errorf("invalid user id: %s", id)
}
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at
FROM users WHERE id = $1`
row := r.db.QueryRowContext(ctx, query, numID)
u := &entity.User{} u := &entity.User{}
err := row.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt) var dbID int
err = row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
u.ID = strconv.Itoa(dbID)
u.Roles, _ = r.getRoles(ctx, u.ID) u.Roles, _ = r.getRoles(ctx, dbID)
return u, nil return u, nil
} }
func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) { func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) {
numTenantID, err := strconv.Atoi(tenantID)
if err != nil {
return nil, 0, fmt.Errorf("invalid tenant id: %s", tenantID)
}
var total int var total int
countQuery := `SELECT COUNT(*) FROM core_users WHERE tenant_id = $1` countQuery := `SELECT COUNT(*) FROM users WHERE tenant_id = $1`
if err := r.db.QueryRowContext(ctx, countQuery, tenantID).Scan(&total); err != nil { if err := r.db.QueryRowContext(ctx, countQuery, numTenantID).Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
} }
query := `SELECT id, tenant_id, name, email, password_hash, status, created_at, updated_at query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
FROM core_users COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at
FROM users
WHERE tenant_id = $1 WHERE tenant_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $2 OFFSET $3` LIMIT $2 OFFSET $3`
rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset) rows, err := r.db.QueryContext(ctx, query, numTenantID, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -125,31 +153,40 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
var users []*entity.User var users []*entity.User
for rows.Next() { for rows.Next() {
u := &entity.User{} u := &entity.User{}
if err := rows.Scan(&u.ID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil { var dbID int
if err := rows.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, 0, err return nil, 0, err
} }
// Populate roles N+1? Ideally join, but for now simple u.ID = strconv.Itoa(dbID)
u.Roles, _ = r.getRoles(ctx, u.ID) u.Roles, _ = r.getRoles(ctx, dbID)
users = append(users, u) users = append(users, u)
} }
return users, total, nil return users, total, nil
} }
func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) { func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) {
// Not fully implemented for roles update for brevity, just fields numID, err := strconv.Atoi(user.ID)
if err != nil {
return nil, fmt.Errorf("invalid user id: %s", user.ID)
}
user.UpdatedAt = time.Now() user.UpdatedAt = time.Now()
query := `UPDATE core_users SET name=$1, email=$2, status=$3, updated_at=$4 WHERE id=$5` query := `UPDATE users SET name=$1, email=$2, status=$3, updated_at=$4 WHERE id=$5`
_, err := r.db.ExecContext(ctx, query, user.Name, user.Email, user.Status, user.UpdatedAt, user.ID) _, err = r.db.ExecContext(ctx, query, user.Name, user.Email, user.Status, user.UpdatedAt, numID)
return user, err return user, err
} }
func (r *UserRepository) Delete(ctx context.Context, id string) error { func (r *UserRepository) Delete(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM core_users WHERE id=$1`, id) numID, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid user id: %s", id)
}
_, err = r.db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, numID)
return err return err
} }
func (r *UserRepository) getRoles(ctx context.Context, userID string) ([]entity.Role, error) { func (r *UserRepository) getRoles(ctx context.Context, userID int) ([]entity.Role, error) {
rows, err := r.db.QueryContext(ctx, `SELECT role FROM core_user_roles WHERE user_id = $1`, userID) rows, err := r.db.QueryContext(ctx, `SELECT role FROM user_roles WHERE user_id = $1`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -1,38 +1,39 @@
-- Migration: Create Super Admin and System Tenant -- Migration: Create Super Admin and System Company
-- Description: Inserts the default System Tenant and Super Admin user. -- Description: Inserts the default System Company and Super Admin user.
-- Use fixed UUIDs for reproducibility in dev environments. -- Uses unified tables (companies, users, user_roles)
-- 1. Insert System Tenant -- 1. Insert System Company (for SuperAdmin context)
INSERT INTO core_companies (id, name, document, contact, status, created_at, updated_at) INSERT INTO companies (name, slug, type, document, email, description, verified, active)
VALUES ( VALUES (
'00000000-0000-0000-0000-000000000001', -- Fixed System Tenant ID 'GoHorse System',
'System Tenant (SuperAdmin Context)', 'gohorse-system',
'SYSTEM', 'system',
'admin@system.local', '00.000.000/0001-91',
'ACTIVE', 'admin@gohorsejobs.com',
CURRENT_TIMESTAMP, '{"tagline": "System Administration Tenant"}',
CURRENT_TIMESTAMP true,
) ON CONFLICT (id) DO NOTHING; true
) ON CONFLICT (slug) DO NOTHING;
-- 2. Insert Super Admin User -- 2. Insert Super Admin User
-- WARNING: This hash is generated WITHOUT PASSWORD_PEPPER. -- WARNING: This hash is generated WITHOUT PASSWORD_PEPPER.
-- For development only. Use seeder-api for proper user creation. -- For development only. Use seeder-api for proper user creation.
-- Password: "password123" (BCrypt hash without pepper) -- Password: "Admin@2025!" (should be created by seeder with proper pepper)
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, created_at, updated_at) INSERT INTO users (identifier, password_hash, role, full_name, email, name, status, tenant_id)
VALUES ( SELECT
'00000000-0000-0000-0000-000000000002', -- Fixed SuperAdmin User ID 'superadmin',
'00000000-0000-0000-0000-000000000001', -- Link to System Tenant '$2a$10$UWrE9xN39lVagJHlXZsxwOVI3NRSEd1VJ6UzMblW6LOxNmsOZtj9K', -- placeholder, seeder will update
'superadmin',
'Super Administrator', 'Super Administrator',
'admin@todai.jobs', 'admin@gohorsejobs.com',
'$2a$10$UWrE9xN39lVagJHlXZsxwOVI3NRSEd1VJ6UzMblW6LOxNmsOZtj9K', -- "password123" 'Super Administrator',
'ACTIVE', 'active',
CURRENT_TIMESTAMP, c.id
CURRENT_TIMESTAMP FROM companies c WHERE c.slug = 'gohorse-system'
) ON CONFLICT (id) DO NOTHING; ON CONFLICT (identifier) DO NOTHING;
-- 3. Assign Super Admin Role -- 3. Assign Super Admin Role
INSERT INTO core_user_roles (user_id, role) INSERT INTO user_roles (user_id, role)
VALUES ( SELECT u.id, 'SUPER_ADMIN'
'00000000-0000-0000-0000-000000000002', FROM users u WHERE u.identifier = 'superadmin'
'SUPER_ADMIN' ON CONFLICT (user_id, role) DO NOTHING;
) ON CONFLICT (user_id, role) DO NOTHING;

View file

@ -0,0 +1,33 @@
-- Migration: Unify schema - add missing fields to users table
-- Description: Add fields from core_users to users table for unified architecture
-- Add tenant_id (references companies.id)
ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES companies(id);
-- Add email if not exists (core_users had this)
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
-- Add name if not exists (mapped from full_name or separate)
ALTER TABLE users ADD COLUMN IF NOT EXISTS name VARCHAR(255);
-- Add status field for compatibility
ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';
-- Create user_roles table (replaces core_user_roles)
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL,
PRIMARY KEY (user_id, role)
);
-- Index for user_roles
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(role);
-- Index for tenant lookup
CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Comments
COMMENT ON COLUMN users.tenant_id IS 'Company ID this user belongs to (NULL for superadmin)';
COMMENT ON TABLE user_roles IS 'Additional roles for users (e.g., admin, recruiter)';

View file

@ -139,21 +139,14 @@ export async function seedAcmeCorp() {
'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6f/Wile_E._Coyote_with_Acme_Rocket.svg/200px-Wile_E._Coyote_with_Acme_Rocket.svg.png' 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6f/Wile_E._Coyote_with_Acme_Rocket.svg/200px-Wile_E._Coyote_with_Acme_Rocket.svg.png'
]); ]);
// Create Core Company entry
const acmeCoreId = 'acacacac-acac-acac-acac-acacacacacac'; // Memorable UUID
await pool.query(`
INSERT INTO core_companies (id, name, document, status)
VALUES ($1, $2, $3, 'ACTIVE')
ON CONFLICT (id) DO NOTHING
`, [acmeCoreId, 'ACME Corporation', acmeCNPJ]);
console.log(' ✓ ACME Corporation criada'); console.log(' ✓ ACME Corporation criada');
// 2. Get ACME company ID (use coreId for FK) // 2. Get ACME company ID from companies table
const acmeCompanyId = acmeCoreId; const acmeRes = await pool.query("SELECT id FROM companies WHERE slug = 'ACME Corporation'");
const acmeCompanyId = acmeRes.rows[0]?.id;
// 3. Get seed user from core_users (for FK) // 3. Get seed user from users table (for FK)
const seedUserRes = await pool.query("SELECT id FROM core_users LIMIT 1"); const seedUserRes = await pool.query("SELECT id FROM users LIMIT 1");
const seedUserId = seedUserRes.rows[0]?.id; const seedUserId = seedUserRes.rows[0]?.id;
// 4. Seed 69 jobs // 4. Seed 69 jobs

View file

@ -87,24 +87,16 @@ export async function seedCompanies() {
true, true,
true true
]); ]);
// Seed Core Company (One-to-one mapping)
const coreId = crypto.randomUUID();
await pool.query(`
INSERT INTO core_companies (id, name, document, status)
VALUES ($1, $2, $3, 'ACTIVE')
ON CONFLICT (id) DO NOTHING
`, [coreId, company.name, generateCNPJ(i)]);
} }
// Seed System Tenant for SuperAdmin // Seed System Company for SuperAdmin
await pool.query(` await pool.query(`
INSERT INTO core_companies (id, name, document, status) INSERT INTO companies (name, slug, type, document, email, description, verified, active)
VALUES ('00000000-0000-0000-0000-000000000000', 'GoHorse System', '00.000.000/0001-91', 'ACTIVE') VALUES ('GoHorse System', 'gohorse-system', 'system', '00.000.000/0001-91', 'admin@gohorsejobs.com', '{"tagline": "System Administration"}', true, true)
ON CONFLICT (id) DO NOTHING ON CONFLICT (slug) DO NOTHING
`); `);
console.log(`${companyData.length} companies seeded (Legacy & Core)`); console.log(`${companyData.length + 1} companies seeded`);
} catch (error) { } catch (error) {
console.error(' ❌ Error seeding companies:', error.message); console.error(' ❌ Error seeding companies:', error.message);
throw error; throw error;

View file

@ -263,7 +263,7 @@ async function createCompanyAndJobs(companyData, jobs) {
const regionsRes = await pool.query('SELECT id FROM regions LIMIT 1'); const regionsRes = await pool.query('SELECT id FROM regions LIMIT 1');
const defaultRegionId = regionsRes.rows[0]?.id || null; const defaultRegionId = regionsRes.rows[0]?.id || null;
// Create Company // Create Company (companies uses SERIAL id)
await pool.query(` await pool.query(`
INSERT INTO companies (name, slug, type, document, address, region_id, phone, email, website, description, verified, active) INSERT INTO companies (name, slug, type, document, address, region_id, phone, email, website, description, verified, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
@ -283,17 +283,11 @@ async function createCompanyAndJobs(companyData, jobs) {
true true
]); ]);
// Core Company // Get company ID from companies table (SERIAL id)
await pool.query(` const companyRes = await pool.query("SELECT id FROM companies WHERE slug = $1", [companyData.slug]);
INSERT INTO core_companies (id, name, document, status) const companyId = companyRes.rows[0]?.id;
VALUES ($1, $2, $3, 'ACTIVE')
ON CONFLICT (id) DO NOTHING
`, [companyData.coreId, companyData.name, companyData.cnpj]);
// Get company ID (use coreId for FK reference) const seedUserRes = await pool.query("SELECT id FROM users LIMIT 1");
const companyId = companyData.coreId;
const seedUserRes = await pool.query("SELECT id FROM core_users LIMIT 1");
const seedUserId = seedUserRes.rows[0]?.id || 1; const seedUserId = seedUserRes.rows[0]?.id || 1;
const workModes = ['onsite', 'hybrid', 'remote']; const workModes = ['onsite', 'hybrid', 'remote'];

View file

@ -177,18 +177,12 @@ export async function seedStarkIndustries() {
true true
]); ]);
// Core Company // Get company ID (SERIAL) from companies table
await pool.query(` const companyRes = await pool.query("SELECT id FROM companies WHERE slug = 'Stark Industries'");
INSERT INTO core_companies (id, name, document, status) const companyId = companyRes.rows[0]?.id;
VALUES ($1, $2, $3, 'ACTIVE')
ON CONFLICT (id) DO NOTHING
`, ['57575757-5757-5757-5757-575757575757', 'Stark Industries', '77.777.777/0001-77']);
// Get company ID (use coreId directly)
const companyId = '57575757-5757-5757-5757-575757575757';
// Get seed user // Get seed user
const seedUserRes = await pool.query("SELECT id FROM core_users LIMIT 1"); const seedUserRes = await pool.query("SELECT id FROM users LIMIT 1");
const seedUserId = seedUserRes.rows[0]?.id || 1; const seedUserId = seedUserRes.rows[0]?.id || 1;
// Create jobs // Create jobs
@ -265,7 +259,7 @@ export async function seedLosPollosHermanos() {
// Core Company // Core Company
await pool.query(` await pool.query(`
INSERT INTO core_companies (id, name, document, status) INSERT INTO companies (id, name, document, status)
VALUES ($1, $2, $3, 'ACTIVE') VALUES ($1, $2, $3, 'ACTIVE')
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
`, ['66666666-6666-6666-6666-666666666666', 'Los Pollos Hermanos', '66.666.666/0001-66']); `, ['66666666-6666-6666-6666-666666666666', 'Los Pollos Hermanos', '66.666.666/0001-66']);
@ -274,7 +268,7 @@ export async function seedLosPollosHermanos() {
// Get company ID (use coreId directly) // Get company ID (use coreId directly)
const companyId = '66666666-6666-6666-6666-666666666666'; const companyId = '66666666-6666-6666-6666-666666666666';
const seedUserRes = await pool.query("SELECT id FROM core_users LIMIT 1"); const seedUserRes = await pool.query("SELECT id FROM users LIMIT 1");
const seedUserId = seedUserRes.rows[0]?.id || 1; const seedUserId = seedUserRes.rows[0]?.id || 1;
const benefits = ['Frango Grátis (apenas o legal)', 'Plano de Saúde Premium', 'Seguro de Vida Reforçado', 'Carro da Empresa (GPS desativado)']; const benefits = ['Frango Grátis (apenas o legal)', 'Plano de Saúde Premium', 'Seguro de Vida Reforçado', 'Carro da Empresa (GPS desativado)'];
@ -352,7 +346,7 @@ export async function seedSpringfieldNuclear() {
// Core Company // Core Company
await pool.query(` await pool.query(`
INSERT INTO core_companies (id, name, document, status) INSERT INTO companies (id, name, document, status)
VALUES ($1, $2, $3, 'ACTIVE') VALUES ($1, $2, $3, 'ACTIVE')
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
`, ['88888888-8888-8888-8888-888888888888', 'Springfield Nuclear Power Plant', '88.888.888/0001-88']); `, ['88888888-8888-8888-8888-888888888888', 'Springfield Nuclear Power Plant', '88.888.888/0001-88']);
@ -361,7 +355,7 @@ export async function seedSpringfieldNuclear() {
// Get company ID (use coreId directly) // Get company ID (use coreId directly)
const companyId = '88888888-8888-8888-8888-888888888888'; const companyId = '88888888-8888-8888-8888-888888888888';
const seedUserRes = await pool.query("SELECT id FROM core_users LIMIT 1"); const seedUserRes = await pool.query("SELECT id FROM users LIMIT 1");
const seedUserId = seedUserRes.rows[0]?.id || 1; const seedUserId = seedUserRes.rows[0]?.id || 1;
const benefits = ['Tickets de Cafeteria', 'Dosímetro Pessoal', 'Desconto na Taverna do Moe', 'Estacionamento (longe do reator)']; const benefits = ['Tickets de Cafeteria', 'Dosímetro Pessoal', 'Desconto na Taverna do Moe', 'Estacionamento (longe do reator)'];

View file

@ -78,8 +78,8 @@ export async function seedNotifications() {
console.log('🔔 Seeding notifications...'); console.log('🔔 Seeding notifications...');
try { try {
// Get users from core_users // Get users from users
const usersRes = await pool.query('SELECT id FROM core_users LIMIT 5'); const usersRes = await pool.query('SELECT id FROM users LIMIT 5');
const users = usersRes.rows; const users = usersRes.rows;
if (users.length === 0) { if (users.length === 0) {

View file

@ -1,110 +1,105 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { pool } from '../db.js'; import { pool } from '../db.js';
// Get pepper from environment - MUST match backend PASSWORD_PEPPER // Get pepper from environment - MUST match backend PASSWORD_PEPPER
const PASSWORD_PEPPER = process.env.PASSWORD_PEPPER || ''; const PASSWORD_PEPPER = process.env.PASSWORD_PEPPER || '';
export async function seedUsers() { export async function seedUsers() {
console.log('👤 Seeding users (Core Architecture)...'); console.log('👤 Seeding users (Unified Architecture)...');
try { try {
// Fetch core companies to map users // Fetch companies to map users (now using companies table, not core_companies)
const companiesResult = await pool.query('SELECT id, name FROM core_companies'); const companiesResult = await pool.query('SELECT id, name, slug FROM companies');
const companyMap = {}; // name -> id const companyMap = {}; // name -> id
companiesResult.rows.forEach(c => companyMap[c.name] = c.id); companiesResult.rows.forEach(c => companyMap[c.name] = c.id);
const systemTenantId = '00000000-0000-0000-0000-000000000000';
// Get system tenant ID
const systemResult = await pool.query("SELECT id FROM companies WHERE slug = 'gohorse-system'");
const systemTenantId = systemResult.rows[0]?.id || null;
// 1. Create SuperAdmin (Requested: superadmin / Admin@2025!) // 1. Create SuperAdmin (Requested: superadmin / Admin@2025!)
const superAdminPassword = await bcrypt.hash('Admin@2025!' + PASSWORD_PEPPER, 10); const superAdminPassword = await bcrypt.hash('Admin@2025!' + PASSWORD_PEPPER, 10);
const superAdminId = crypto.randomUUID();
// User requested identifier 'superadmin'
const superAdminIdentifier = 'superadmin';
const result = await pool.query(` const result = await pool.query(`
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status) INSERT INTO users (identifier, password_hash, role, full_name, email, name, tenant_id, status)
VALUES ($1, $2, $3, $4, $5, 'ACTIVE') VALUES ($1, $2, 'superadmin', $3, $4, $5, $6, 'active')
ON CONFLICT (tenant_id, email) DO UPDATE SET password_hash = EXCLUDED.password_hash ON CONFLICT (identifier) DO UPDATE SET password_hash = EXCLUDED.password_hash
RETURNING id RETURNING id
`, [superAdminId, systemTenantId, 'System Administrator', superAdminIdentifier, superAdminPassword]); `, ['superadmin', superAdminPassword, 'System Administrator', 'admin@gohorsejobs.com', 'System Administrator', systemTenantId]);
const actualSuperAdminId = result.rows[0].id; const superAdminId = result.rows[0].id;
// Role // Role in user_roles table
await pool.query(` await pool.query(`
INSERT INTO core_user_roles (user_id, role) INSERT INTO user_roles (user_id, role)
VALUES ($1, 'superadmin') VALUES ($1, 'superadmin')
ON CONFLICT (user_id, role) DO NOTHING ON CONFLICT (user_id, role) DO NOTHING
`, [actualSuperAdminId]); `, [superAdminId]);
console.log(' ✓ SuperAdmin created (superadmin)'); console.log(' ✓ SuperAdmin created (superadmin)');
// 2. Create Company Admins // 2. Create Company Admins
const companyAdmins = [ const companyAdmins = [
// Requested: takeshi_yamamoto / Takeshi@2025 (Company Admin) { identifier: 'takeshi_yamamoto', fullName: 'Takeshi Yamamoto', company: 'TechCorp', email: 'takeshi@techcorp.com', pass: 'Takeshi@2025', roles: ['admin', 'company'] },
{ identifier: 'takeshi_yamamoto', fullName: 'Takeshi Yamamoto', company: 'TechCorp', email: 'takeshi_yamamoto', pass: 'Takeshi@2025', roles: ['admin', 'company'] },
{ identifier: 'kenji', fullName: 'Kenji Tanaka', company: 'AppMakers', email: 'kenji@appmakers.mobile', pass: 'Takeshi@2025', roles: ['admin', 'company'] }, { identifier: 'kenji', fullName: 'Kenji Tanaka', company: 'AppMakers', email: 'kenji@appmakers.mobile', pass: 'Takeshi@2025', roles: ['admin', 'company'] },
// Requested: maria_santos / User@2025 (Recruiter) - Placing in DesignHub { identifier: 'maria_santos', fullName: 'Maria Santos', company: 'DesignHub', email: 'maria@designhub.com', pass: 'User@2025', roles: ['recruiter', 'company'] }
{ identifier: 'maria_santos', fullName: 'Maria Santos', company: 'DesignHub', email: 'maria_santos', pass: 'User@2025', roles: ['recruiter', 'company'] }
]; ];
for (const admin of companyAdmins) { for (const admin of companyAdmins) {
const hash = await bcrypt.hash(admin.pass + PASSWORD_PEPPER, 10); const hash = await bcrypt.hash(admin.pass + PASSWORD_PEPPER, 10);
const userId = crypto.randomUUID();
const tenantId = companyMap[admin.company]; const tenantId = companyMap[admin.company];
if (!tenantId) { if (!tenantId) {
console.warn(`Original company ${admin.company} not found for user ${admin.fullName}, skipping.`); console.warn(` ⚠️ Company ${admin.company} not found for user ${admin.fullName}, skipping.`);
continue; continue;
} }
const result = await pool.query(` const result = await pool.query(`
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status) INSERT INTO users (identifier, password_hash, role, full_name, email, name, tenant_id, status)
VALUES ($1, $2, $3, $4, $5, 'ACTIVE') VALUES ($1, $2, $3, $4, $5, $6, $7, 'active')
ON CONFLICT (tenant_id, email) DO UPDATE SET password_hash = EXCLUDED.password_hash ON CONFLICT (identifier) DO UPDATE SET password_hash = EXCLUDED.password_hash
RETURNING id RETURNING id
`, [userId, tenantId, admin.fullName, admin.email, hash]); `, [admin.identifier, hash, admin.roles[0], admin.fullName, admin.email, admin.fullName, tenantId]);
const actualUserId = result.rows[0].id; const userId = result.rows[0].id;
for (const role of admin.roles) { for (const role of admin.roles) {
await pool.query(` await pool.query(`
INSERT INTO core_user_roles (user_id, role) INSERT INTO user_roles (user_id, role)
VALUES ($1, $2) VALUES ($1, $2)
ON CONFLICT (user_id, role) DO NOTHING ON CONFLICT (user_id, role) DO NOTHING
`, [actualUserId, role]); `, [userId, role]);
} }
console.log(` ✓ User created: ${admin.email}`); console.log(` ✓ User created: ${admin.identifier}`);
} }
// 3. Create Candidates (Job Seekers) // 3. Create Candidates (Job Seekers)
// Requested: paulo_santos / User@2025
const candidates = [ const candidates = [
{ fullName: 'Paulo Santos', email: 'paulo_santos', pass: 'User@2025' }, { identifier: 'paulo_santos', fullName: 'Paulo Santos', email: 'paulo@email.com', pass: 'User@2025' },
{ fullName: 'Maria Reyes', email: 'maria@email.com', pass: 'User@2025' } { identifier: 'maria_email', fullName: 'Maria Reyes', email: 'maria@email.com', pass: 'User@2025' }
]; ];
for (const cand of candidates) { for (const cand of candidates) {
const hash = await bcrypt.hash(cand.pass + PASSWORD_PEPPER, 10); const hash = await bcrypt.hash(cand.pass + PASSWORD_PEPPER, 10);
const userId = crypto.randomUUID();
const result = await pool.query(` const result = await pool.query(`
INSERT INTO core_users (id, tenant_id, name, email, password_hash, status) INSERT INTO users (identifier, password_hash, role, full_name, email, name, status)
VALUES ($1, $2, $3, $4, $5, 'ACTIVE') VALUES ($1, $2, 'jobSeeker', $3, $4, $5, 'active')
ON CONFLICT (tenant_id, email) DO UPDATE SET password_hash = EXCLUDED.password_hash ON CONFLICT (identifier) DO UPDATE SET password_hash = EXCLUDED.password_hash
RETURNING id RETURNING id
`, [userId, systemTenantId, cand.fullName, cand.email, hash]); `, [cand.identifier, hash, cand.fullName, cand.email, cand.fullName]);
const actualUserId = result.rows[0].id; const userId = result.rows[0].id;
await pool.query(` await pool.query(`
INSERT INTO core_user_roles (user_id, role) INSERT INTO user_roles (user_id, role)
VALUES ($1, 'candidate') VALUES ($1, 'candidate')
ON CONFLICT (user_id, role) DO NOTHING ON CONFLICT (user_id, role) DO NOTHING
`, [actualUserId]); `, [userId]);
console.log(` ✓ Candidate created: ${cand.email}`);
console.log(` ✓ Candidate created: ${cand.identifier}`);
} }
console.log('👤 Seeding legacy candidates...'); console.log('👤 Seeding legacy candidates...');
@ -120,7 +115,7 @@ export async function seedUsers() {
title: 'Full Stack Developer', title: 'Full Stack Developer',
experience: '5 years of experience', experience: '5 years of experience',
skills: ['React', 'Node.js', 'TypeScript', 'AWS', 'Docker'], skills: ['React', 'Node.js', 'TypeScript', 'AWS', 'Docker'],
bio: 'Developer passionate about building innovative solutions. Experience in React, Node.js, and cloud computing.', bio: 'Developer passionate about building innovative solutions.',
objective: 'Grow as a full stack developer building scalable products.' objective: 'Grow as a full stack developer building scalable products.'
}, },
{ {
@ -133,7 +128,7 @@ export async function seedUsers() {
title: 'UX/UI Designer', title: 'UX/UI Designer',
experience: '3 years of experience', experience: '3 years of experience',
skills: ['Figma', 'Adobe XD', 'UI Design', 'Prototyping', 'Design Systems'], skills: ['Figma', 'Adobe XD', 'UI Design', 'Prototyping', 'Design Systems'],
bio: 'Designer focused on creating memorable experiences. Specialist in design systems and prototyping.', bio: 'Designer focused on creating memorable experiences.',
objective: 'Design intuitive experiences for web and mobile products.' objective: 'Design intuitive experiences for web and mobile products.'
}, },
{ {
@ -146,7 +141,7 @@ export async function seedUsers() {
title: 'Data Engineer', title: 'Data Engineer',
experience: '7 years of experience', experience: '7 years of experience',
skills: ['Python', 'SQL', 'Spark', 'Machine Learning', 'Data Visualization'], skills: ['Python', 'SQL', 'Spark', 'Machine Learning', 'Data Visualization'],
bio: 'Data engineer with a strong background in machine learning and big data. Passionate about turning data into insights.', bio: 'Data engineer with a strong background in machine learning.',
objective: 'Build robust data pipelines and analytics products.' objective: 'Build robust data pipelines and analytics products.'
}, },
{ {
@ -159,7 +154,7 @@ export async function seedUsers() {
title: 'Product Manager', title: 'Product Manager',
experience: '6 years of experience', experience: '6 years of experience',
skills: ['Product Management', 'Agile', 'Scrum', 'Data Analysis', 'User Research'], skills: ['Product Management', 'Agile', 'Scrum', 'Data Analysis', 'User Research'],
bio: 'Product Manager with experience in digital products and agile methodologies. Focused on delivering user value.', bio: 'Product Manager with experience in digital products.',
objective: 'Lead cross-functional teams to deliver customer-centric products.' objective: 'Lead cross-functional teams to deliver customer-centric products.'
}, },
{ {
@ -172,7 +167,7 @@ export async function seedUsers() {
title: 'DevOps Engineer', title: 'DevOps Engineer',
experience: '4 years of experience', experience: '4 years of experience',
skills: ['Docker', 'Kubernetes', 'AWS', 'Terraform', 'CI/CD'], skills: ['Docker', 'Kubernetes', 'AWS', 'Terraform', 'CI/CD'],
bio: 'DevOps engineer specialized in automation and cloud infrastructure. Experience with Kubernetes and CI/CD.', bio: 'DevOps engineer specialized in automation and cloud infrastructure.',
objective: 'Improve delivery pipelines and cloud reliability.' objective: 'Improve delivery pipelines and cloud reliability.'
} }
]; ];
@ -181,20 +176,9 @@ export async function seedUsers() {
const hash = await bcrypt.hash('User@2025' + PASSWORD_PEPPER, 10); const hash = await bcrypt.hash('User@2025' + PASSWORD_PEPPER, 10);
await pool.query(` await pool.query(`
INSERT INTO users ( INSERT INTO users (
identifier, identifier, password_hash, role, full_name, email, name, phone,
password_hash, city, state, title, experience, skills, objective, bio, status
role, ) VALUES ($1, $2, 'jobSeeker', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 'active')
full_name,
email,
phone,
city,
state,
title,
experience,
skills,
objective,
bio
) VALUES ($1, $2, 'jobSeeker', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (identifier) DO UPDATE SET ON CONFLICT (identifier) DO UPDATE SET
full_name = EXCLUDED.full_name, full_name = EXCLUDED.full_name,
email = EXCLUDED.email, email = EXCLUDED.email,
@ -211,6 +195,7 @@ export async function seedUsers() {
hash, hash,
cand.fullName, cand.fullName,
cand.email, cand.email,
cand.fullName,
cand.phone, cand.phone,
cand.city, cand.city,
cand.state, cand.state,