refactor: migrate core tables to UUID v7 and update roadmap
This commit is contained in:
parent
4b680f2c31
commit
1b4f1d1555
16 changed files with 175 additions and 106 deletions
|
|
@ -4,8 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||||
|
|
@ -20,7 +18,7 @@ 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) {
|
||||||
// companies table uses SERIAL id, we let DB generate it
|
// companies table uses UUID id, DB generates it
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO companies (name, slug, type, document, email, description, verified, active, 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, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
|
@ -28,7 +26,7 @@ func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (
|
||||||
`
|
`
|
||||||
|
|
||||||
slug := company.Name // TODO: slugify function
|
slug := company.Name // TODO: slugify function
|
||||||
var id int
|
var id string
|
||||||
err := r.db.QueryRowContext(ctx, query,
|
err := r.db.QueryRowContext(ctx, query,
|
||||||
company.Name,
|
company.Name,
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -45,7 +43,7 @@ func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
company.ID = strconv.Itoa(id)
|
company.ID = id
|
||||||
return company, nil
|
return company, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,54 +53,40 @@ func (r *CompanyRepository) FindByID(ctx context.Context, id string) (*entity.Co
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM companies WHERE id = $1`
|
FROM companies WHERE id = $1`
|
||||||
|
|
||||||
numID, err := strconv.Atoi(id)
|
row := r.db.QueryRowContext(ctx, query, 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{}
|
||||||
var dbID int
|
var dbID string
|
||||||
err = row.Scan(&dbID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
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)
|
c.ID = 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 companies
|
UPDATE companies
|
||||||
SET name=$1, document=$2, email=$3, active=$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 == "ACTIVE",
|
company.Status == "ACTIVE",
|
||||||
company.UpdatedAt,
|
company.UpdatedAt,
|
||||||
numID,
|
company.ID,
|
||||||
)
|
)
|
||||||
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 {
|
||||||
numID, err := strconv.Atoi(id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid company id: %s", id)
|
|
||||||
}
|
|
||||||
query := `DELETE FROM companies WHERE id = $1`
|
query := `DELETE FROM companies WHERE id = $1`
|
||||||
_, err = r.db.ExecContext(ctx, query, numID)
|
_, err := r.db.ExecContext(ctx, query, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,11 +104,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{}
|
||||||
var dbID int
|
var dbID string
|
||||||
if err := rows.Scan(&dbID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
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)
|
c.ID = dbID
|
||||||
companies = append(companies, c)
|
companies = append(companies, c)
|
||||||
}
|
}
|
||||||
return companies, nil
|
return companies, nil
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ package postgres
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||||
|
|
@ -25,23 +23,20 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Convert tenant_id from string to int
|
// TenantID is string (UUID) or empty
|
||||||
var tenantID *int
|
var tenantID *string
|
||||||
if user.TenantID != "" {
|
if user.TenantID != "" {
|
||||||
tid, err := strconv.Atoi(user.TenantID)
|
tenantID = &user.TenantID
|
||||||
if err == nil {
|
|
||||||
tenantID = &tid
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Insert User - users table has SERIAL id
|
// 1. Insert User - users table has UUID id
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO users (identifier, password_hash, role, full_name, email, name, tenant_id, status, 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, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
var id int
|
var id string
|
||||||
// Map the first role to the role column, default to 'jobSeeker'
|
// Map the first role to the role column, default to 'jobSeeker'
|
||||||
role := "jobSeeker"
|
role := "jobSeeker"
|
||||||
if len(user.Roles) > 0 {
|
if len(user.Roles) > 0 {
|
||||||
|
|
@ -64,7 +59,7 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user.ID = strconv.Itoa(id)
|
user.ID = id
|
||||||
|
|
||||||
// 2. Insert Roles into user_roles table
|
// 2. Insert Roles into user_roles table
|
||||||
if len(user.Roles) > 0 {
|
if len(user.Roles) > 0 {
|
||||||
|
|
@ -91,7 +86,7 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
|
||||||
row := r.db.QueryRowContext(ctx, query, email)
|
row := r.db.QueryRowContext(ctx, query, email)
|
||||||
|
|
||||||
u := &entity.User{}
|
u := &entity.User{}
|
||||||
var dbID int
|
var dbID string
|
||||||
err := row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
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 {
|
||||||
|
|
@ -99,42 +94,32 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
u.ID = strconv.Itoa(dbID)
|
u.ID = dbID
|
||||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
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) {
|
||||||
numID, err := strconv.Atoi(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid user id: %s", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''),
|
||||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at
|
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at
|
||||||
FROM users WHERE id = $1`
|
FROM users WHERE id = $1`
|
||||||
row := r.db.QueryRowContext(ctx, query, numID)
|
row := r.db.QueryRowContext(ctx, query, id)
|
||||||
|
|
||||||
u := &entity.User{}
|
u := &entity.User{}
|
||||||
var dbID int
|
var dbID string
|
||||||
err = row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
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.ID = dbID
|
||||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
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 users WHERE tenant_id = $1`
|
countQuery := `SELECT COUNT(*) FROM users WHERE tenant_id = $1`
|
||||||
if err := r.db.QueryRowContext(ctx, countQuery, numTenantID).Scan(&total); err != nil {
|
if err := r.db.QueryRowContext(ctx, countQuery, tenantID).Scan(&total); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +129,7 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
||||||
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, numTenantID, limit, offset)
|
rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -153,11 +138,11 @@ 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{}
|
||||||
var dbID int
|
var dbID string
|
||||||
if err := rows.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
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
|
||||||
}
|
}
|
||||||
u.ID = strconv.Itoa(dbID)
|
u.ID = dbID
|
||||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
|
|
@ -165,27 +150,18 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
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 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, numID)
|
_, err := r.db.ExecContext(ctx, query, user.Name, user.Email, user.Status, user.UpdatedAt, user.ID)
|
||||||
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 {
|
||||||
numID, err := strconv.Atoi(id)
|
_, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, 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 int) ([]entity.Role, error) {
|
func (r *UserRepository) getRoles(ctx context.Context, userID string) ([]entity.Role, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `SELECT role FROM 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
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
-- Description: Stores all system users (SuperAdmin, CompanyAdmin, Recruiter, JobSeeker)
|
-- Description: Stores all system users (SuperAdmin, CompanyAdmin, Recruiter, JobSeeker)
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
identifier VARCHAR(100) UNIQUE NOT NULL,
|
identifier VARCHAR(100) UNIQUE NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
role VARCHAR(20) NOT NULL CHECK (role IN ('superadmin', 'companyAdmin', 'recruiter', 'jobSeeker')),
|
role VARCHAR(20) NOT NULL CHECK (role IN ('superadmin', 'companyAdmin', 'recruiter', 'jobSeeker')),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
-- Description: Stores company information for employers
|
-- Description: Stores company information for employers
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS companies (
|
CREATE TABLE IF NOT EXISTS companies (
|
||||||
id SERIAL PRIMARY KEY,
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
slug VARCHAR(255) UNIQUE NOT NULL, -- URL friendly name
|
slug VARCHAR(255) UNIQUE NOT NULL, -- URL friendly name
|
||||||
type VARCHAR(50) DEFAULT 'company', -- company, agency, etc
|
type VARCHAR(50) DEFAULT 'company', -- company, agency, etc
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
-- Description: N:M relationship for multi-tenant - users can belong to multiple companies
|
-- Description: N:M relationship for multi-tenant - users can belong to multiple companies
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_companies (
|
CREATE TABLE IF NOT EXISTS user_companies (
|
||||||
id SERIAL PRIMARY KEY,
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
user_id INT NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
company_id INT NOT NULL,
|
company_id UUID NOT NULL,
|
||||||
role VARCHAR(20) NOT NULL CHECK (role IN ('companyAdmin', 'recruiter')),
|
role VARCHAR(20) NOT NULL CHECK (role IN ('companyAdmin', 'recruiter')),
|
||||||
permissions JSONB, -- Optional granular permissions
|
permissions JSONB, -- Optional granular permissions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
-- Description: Job postings created by companies
|
-- Description: Job postings created by companies
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id SERIAL PRIMARY KEY,
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
company_id INT NOT NULL,
|
company_id UUID NOT NULL,
|
||||||
created_by INT NOT NULL, -- user who created the job
|
created_by UUID NOT NULL, -- user who created the job
|
||||||
|
|
||||||
-- Job Details
|
-- Job Details
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
-- Description: Job applications (can be from registered users or guests)
|
-- Description: Job applications (can be from registered users or guests)
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS applications (
|
CREATE TABLE IF NOT EXISTS applications (
|
||||||
id SERIAL PRIMARY KEY,
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
job_id INT NOT NULL,
|
job_id UUID NOT NULL,
|
||||||
user_id INT, -- NULL for guest applications
|
user_id UUID, -- NULL for guest applications
|
||||||
|
|
||||||
-- Applicant Info (required for guest applications)
|
-- Applicant Info (required for guest applications)
|
||||||
name VARCHAR(255),
|
name VARCHAR(255),
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS favorite_jobs (
|
CREATE TABLE IF NOT EXISTS favorite_jobs (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INT NOT NULL,
|
user_id UUID NOT NULL,
|
||||||
job_id INT NOT NULL,
|
job_id UUID NOT NULL,
|
||||||
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
CREATE TABLE IF NOT EXISTS notifications (
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
type VARCHAR(50) NOT NULL, -- info, success, warning, error
|
type VARCHAR(50) NOT NULL, -- info, success, warning, error
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ DROP TABLE IF EXISTS tickets;
|
||||||
|
|
||||||
CREATE TABLE tickets (
|
CREATE TABLE tickets (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
subject VARCHAR(255) NOT NULL,
|
subject VARCHAR(255) NOT NULL,
|
||||||
status VARCHAR(50) NOT NULL DEFAULT 'open', -- open, in_progress, closed
|
status VARCHAR(50) NOT NULL DEFAULT 'open', -- open, in_progress, closed
|
||||||
priority VARCHAR(50) NOT NULL DEFAULT 'medium', -- low, medium, high
|
priority VARCHAR(50) NOT NULL DEFAULT 'medium', -- low, medium, high
|
||||||
|
|
@ -17,7 +17,7 @@ CREATE INDEX idx_tickets_status ON tickets(status);
|
||||||
CREATE TABLE ticket_messages (
|
CREATE TABLE ticket_messages (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Sender
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Sender
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS job_payments (
|
CREATE TABLE IF NOT EXISTS job_payments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||||
job_id INT NOT NULL,
|
job_id UUID NOT NULL,
|
||||||
user_id INT,
|
user_id UUID,
|
||||||
|
|
||||||
-- Stripe
|
-- Stripe
|
||||||
stripe_session_id VARCHAR(255),
|
stripe_session_id VARCHAR(255),
|
||||||
|
|
@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS job_payments (
|
||||||
paid_at TIMESTAMP,
|
paid_at TIMESTAMP,
|
||||||
expires_at TIMESTAMP,
|
expires_at TIMESTAMP,
|
||||||
|
|
||||||
-- Foreign key (INT to match jobs.id SERIAL)
|
-- Foreign key
|
||||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
-- Description: Add fields from core_users to users table for unified architecture
|
-- Description: Add fields from core_users to users table for unified architecture
|
||||||
|
|
||||||
-- Add tenant_id (references companies.id)
|
-- Add tenant_id (references companies.id)
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id INT REFERENCES companies(id);
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id UUID REFERENCES companies(id);
|
||||||
|
|
||||||
-- Add email if not exists (core_users had this)
|
-- Add email if not exists (core_users had this)
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||||
|
|
@ -15,7 +15,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';
|
||||||
|
|
||||||
-- Create user_roles table (replaces core_user_roles)
|
-- Create user_roles table (replaces core_user_roles)
|
||||||
CREATE TABLE IF NOT EXISTS user_roles (
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
role VARCHAR(50) NOT NULL,
|
role VARCHAR(50) NOT NULL,
|
||||||
PRIMARY KEY (user_id, role)
|
PRIMARY KEY (user_id, role)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
107
docs/API_SECURITY.md
Normal file
107
docs/API_SECURITY.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# 🔐 API Security & Access Levels
|
||||||
|
|
||||||
|
This document details the security layers, authentication methods, and role-based access control (RBAC) for the GoHorse Jobs API. Use this guide to verify and test route protection.
|
||||||
|
|
||||||
|
## 🛡️ Authentication Methods
|
||||||
|
|
||||||
|
1. **Bearer Token (JWT)**
|
||||||
|
* Header: `Authorization: Bearer <token>`
|
||||||
|
* Used by: Mobile apps, external integrations, simple API tests.
|
||||||
|
|
||||||
|
2. **HttpOnly Cookie**
|
||||||
|
* Cookie Name: `jwt`
|
||||||
|
* Used by: Web Frontend (Next.js), Backoffice.
|
||||||
|
* Properties: `HttpOnly`, `Secure` (in prod), `SameSite=Lax`.
|
||||||
|
|
||||||
|
## 🚦 Access Levels
|
||||||
|
|
||||||
|
| Level | Description | Middleware |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Public** | Open to everyone (Guests). No check performed. | None |
|
||||||
|
| **Authenticated** | Requires a valid JWT (Header or Cookie). | `HeaderAuthGuard` |
|
||||||
|
| **Role-Restricted** | Requires valid JWT + Specific Role claim. | `HeaderAuthGuard` + `RequireRoles(...)` |
|
||||||
|
|
||||||
|
## 🗺️ Route Permission Matrix
|
||||||
|
|
||||||
|
### 🟢 Public Routes
|
||||||
|
| Method | Route | Description | Notes |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `POST` | `/api/v1/auth/login` | User Login | Returns JWT + Cookie |
|
||||||
|
| `POST` | `/api/v1/auth/register` | Candidate Register | Creates `jobSeeker` user |
|
||||||
|
| `POST` | `/api/v1/companies` | Company Register | Creates company + `companyAdmin` |
|
||||||
|
| `GET` | `/api/v1/jobs` | List Jobs | Public search/list |
|
||||||
|
| `GET` | `/api/v1/jobs/{id}` | Get Job | Public details |
|
||||||
|
| `GET` | `/docs/*` | Swagger UI | API Documentation |
|
||||||
|
|
||||||
|
### 🟡 Authenticated Routes (Any Logged User)
|
||||||
|
**Requirement**: Valid JWT.
|
||||||
|
|
||||||
|
| Method | Route | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `GET` | `/api/v1/users/me` | Get Own Profile |
|
||||||
|
| `PATCH` | `/api/v1/users/{id}` | Update Own Profile (Self-check in handler) |
|
||||||
|
| `GET` | `/api/v1/notifications` | Get Own Notifications |
|
||||||
|
| `POST` | `/api/v1/applications` | Apply for Job (Candidate) |
|
||||||
|
| `POST` | `/api/v1/storage/upload-url` | Get S3 Upload URL |
|
||||||
|
| `POST` | `/api/v1/storage/download-url` | Get S3 Download URL |
|
||||||
|
| `DELETE` | `/api/v1/storage/files` | Delete S3 File |
|
||||||
|
|
||||||
|
### 🟠 Recruiter / CompanyAdmin Routes
|
||||||
|
**Requirement**: Role `companyAdmin` OR `recruiter`.
|
||||||
|
|
||||||
|
| Method | Route | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `POST` | `/api/v1/jobs` | Create Job |
|
||||||
|
| `PUT` | `/api/v1/jobs/{id}` | Update Job |
|
||||||
|
| `DELETE` | `/api/v1/jobs/{id}` | Delete Job |
|
||||||
|
| `GET` | `/api/v1/applications` | List Applications (for own jobs) |
|
||||||
|
| `PUT` | `/api/v1/applications/{id}/status` | Update Application Status |
|
||||||
|
|
||||||
|
### 🔴 Admin / SuperAdmin Routes (Backoffice)
|
||||||
|
**Requirement**: Role `superadmin` OR `admin`.
|
||||||
|
|
||||||
|
| Method | Route | Description | Middleware Check |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `GET` | `/api/v1/users` | List All Users | `adminOnly` |
|
||||||
|
| `POST` | `/api/v1/users` | Create User (Staff) | `adminOnly` |
|
||||||
|
| `DELETE` | `/api/v1/users/{id}` | Delete User | `adminOnly` |
|
||||||
|
| `GET` | `/api/v1/users/roles` | List System Roles | `adminOnly` |
|
||||||
|
| `GET` | `/api/v1/companies` | List Companies (Full) | `adminOnly` |
|
||||||
|
| `PATCH` | `/api/v1/companies/{id}/status` | Activate/Ban Company | `adminOnly` |
|
||||||
|
| `GET` | `/api/v1/jobs/moderation` | Moderate Jobs | `adminOnly` |
|
||||||
|
| `PATCH` | `/api/v1/jobs/{id}/status` | Approve/Reject Job | `adminOnly` |
|
||||||
|
| `POST` | `/api/v1/jobs/{id}/duplicate` | Admin Duplicate Job | `adminOnly` |
|
||||||
|
| `GET` | `/api/v1/tags` | List Tags | `adminOnly` |
|
||||||
|
| `POST` | `/api/v1/tags` | Create Tag | `adminOnly` |
|
||||||
|
| `PATCH` | `/api/v1/tags/{id}` | Update Tag | `adminOnly` |
|
||||||
|
| `GET` | `/api/v1/candidates` | List All Candidates | `adminOnly` |
|
||||||
|
| `GET` | `/api/v1/audit/logins` | View Audit Logs | `adminOnly` |
|
||||||
|
|
||||||
|
## 🧪 Testing Security
|
||||||
|
|
||||||
|
**1. Test Public Access (Should Succeed)**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8521/api/v1/jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Test Protected Route without Token (Should Fail 401)**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8521/api/v1/users/me
|
||||||
|
# Expected: 401 Unauthorized
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Test Admin Route as Candidate (Should Fail 403)**
|
||||||
|
1. Login as Candidate -> Get Token A
|
||||||
|
2. Call Admin Route:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer <TOKEN_A>" http://localhost:8521/api/v1/audit/logins
|
||||||
|
# Expected: 403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Test Admin Route as Admin (Should Succeed)**
|
||||||
|
1. Login as SuperAdmin -> Get Token B
|
||||||
|
2. Call Admin Route:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer <TOKEN_B>" http://localhost:8521/api/v1/audit/logins
|
||||||
|
# Expected: 200 OK
|
||||||
|
```
|
||||||
|
|
@ -125,20 +125,20 @@ erDiagram
|
||||||
|
|
||||||
| Table | ID Type | Description | Migration |
|
| Table | ID Type | Description | Migration |
|
||||||
|-------|---------|-------------|-----------|
|
|-------|---------|-------------|-----------|
|
||||||
| `users` | SERIAL | System users (candidates, recruiters, admins) | 001 |
|
| `users` | UUID v7 | System users (candidates, recruiters, admins) | 001/021 |
|
||||||
| `user_roles` | Composite | Additional roles per user | 020 |
|
| `user_roles` | Composite | Additional roles per user | 020 |
|
||||||
| `companies` | SERIAL | Employer organizations | 002 |
|
| `companies` | UUID v7 | Employer organizations | 002/021 |
|
||||||
| `user_companies` | SERIAL | User ↔ Company mapping | 003 |
|
| `user_companies` | UUID v7 | User ↔ Company mapping | 003/021 |
|
||||||
| `regions` | SERIAL | States/Provinces/Prefectures | 004 |
|
| `regions` | SERIAL | States/Provinces/Prefectures | 004 |
|
||||||
| `cities` | SERIAL | Cities within regions | 004 |
|
| `cities` | SERIAL | Cities within regions | 004 |
|
||||||
| `jobs` | SERIAL | Job postings | 005 |
|
| `jobs` | UUID v7 | Job postings | 005/021 |
|
||||||
| `applications` | SERIAL | Job applications | 006 |
|
| `applications` | UUID v7 | Job applications | 006/021 |
|
||||||
| `favorite_jobs` | SERIAL | Saved jobs by users | 007 |
|
| `favorite_jobs` | SERIAL | Saved jobs by users | 007 |
|
||||||
| `password_resets` | SERIAL | Password reset tokens | 008 |
|
| `password_resets` | SERIAL | Password reset tokens | 008 |
|
||||||
| `notifications` | UUID v4 | User notifications | 016 |
|
| `notifications` | UUID v7 | User notifications | 016 |
|
||||||
| `tickets` | UUID v4 | Support tickets | 017 |
|
| `tickets` | UUID v7 | Support tickets | 017 |
|
||||||
| `ticket_messages` | UUID v4 | Ticket messages | 017 |
|
| `ticket_messages` | UUID v7 | Ticket messages | 017 |
|
||||||
| `job_payments` | UUID v4 | Payment records | 019 |
|
| `job_payments` | UUID v7 | Payment records | 019 |
|
||||||
| `job_posting_prices` | SERIAL | Pricing configuration | 019 |
|
| `job_posting_prices` | SERIAL | Pricing configuration | 019 |
|
||||||
| `login_audits` | SERIAL | Login audit trail | 013 |
|
| `login_audits` | SERIAL | Login audit trail | 013 |
|
||||||
| `activity_logs` | SERIAL | Activity logging | 013 |
|
| `activity_logs` | SERIAL | Activity logging | 013 |
|
||||||
|
|
@ -600,8 +600,8 @@ The database uses a **hybrid ID strategy**:
|
||||||
|
|
||||||
| Strategy | Tables | Rationale |
|
| Strategy | Tables | Rationale |
|
||||||
|----------|--------|-----------|
|
|----------|--------|-----------|
|
||||||
| **SERIAL (INT)** | users, companies, jobs, applications, regions, cities | Legacy tables, simpler, faster, auto-increment |
|
| **UUID v7** | users, companies, jobs, applications, notifications, tickets, job_payments | Time-ordered, distributed-friendly, sortable, scalable |
|
||||||
| **UUID v7** | notifications, tickets, job_payments | Time-ordered, distributed-friendly, sortable |
|
| **SERIAL (INT)** | regions, cities, job_posting_prices, job_tags | Reference/Static data, low volume |
|
||||||
|
|
||||||
### UUID v7 (RFC 9562)
|
### UUID v7 (RFC 9562)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ Roadmap de desenvolvimento do projeto GoHorse Jobs.
|
||||||
- [x] Multi-tenancy básico
|
- [x] Multi-tenancy básico
|
||||||
- [x] PASSWORD_PEPPER para hash seguro
|
- [x] PASSWORD_PEPPER para hash seguro
|
||||||
- [x] 🆕 Schema unificado (eliminado core_*)
|
- [x] 🆕 Schema unificado (eliminado core_*)
|
||||||
|
- [x] 🆕 Migração UUID v7 (Tabelas Core)
|
||||||
|
- [x] 🆕 Seeder atualizado (UUID compatible)
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- [x] Login/Logout com cookies
|
- [x] Login/Logout com cookies
|
||||||
|
|
@ -53,7 +55,7 @@ Roadmap de desenvolvimento do projeto GoHorse Jobs.
|
||||||
- [x] Regiões (BR, US, JP)
|
- [x] Regiões (BR, US, JP)
|
||||||
- [x] Cidades
|
- [x] Cidades
|
||||||
- [x] Notificações
|
- [x] Notificações
|
||||||
- [x] 🆕 Schema unificado
|
- [x] 🆕 Schema unificado e UUID
|
||||||
|
|
||||||
### DevOps
|
### DevOps
|
||||||
- [x] Docker setup backend
|
- [x] Docker setup backend
|
||||||
|
|
@ -94,8 +96,8 @@ Roadmap de desenvolvimento do projeto GoHorse Jobs.
|
||||||
|
|
||||||
### Alta Prioridade
|
### Alta Prioridade
|
||||||
- [ ] Email transacional (welcome, reset, application)
|
- [ ] Email transacional (welcome, reset, application)
|
||||||
- [ ] Integração Stripe completa
|
- [ ] Integração Stripe completa (Webhook handlers)
|
||||||
- [ ] Busca avançada com filtros
|
- [ ] Busca avançada com filtros (Backend pronto, Frontend pendente)
|
||||||
- [ ] Internacionalização (i18n)
|
- [ ] Internacionalização (i18n)
|
||||||
- [ ] Testes E2E frontend
|
- [ ] Testes E2E frontend
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue