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"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
// companies table uses SERIAL id, we let DB generate it
|
||||
// companies table uses UUID id, DB generates it
|
||||
query := `
|
||||
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)
|
||||
|
|
@ -28,7 +26,7 @@ func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (
|
|||
`
|
||||
|
||||
slug := company.Name // TODO: slugify function
|
||||
var id int
|
||||
var id string
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
company.Name,
|
||||
slug,
|
||||
|
|
@ -45,7 +43,7 @@ func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
company.ID = strconv.Itoa(id)
|
||||
company.ID = id
|
||||
return company, nil
|
||||
}
|
||||
|
||||
|
|
@ -55,54 +53,40 @@ func (r *CompanyRepository) FindByID(ctx context.Context, id string) (*entity.Co
|
|||
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)
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
c := &entity.Company{}
|
||||
var dbID int
|
||||
err = row.Scan(&dbID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
||||
var dbID string
|
||||
err := row.Scan(&dbID, &c.Name, &c.Document, &c.Contact, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errors.New("company not found")
|
||||
}
|
||||
c.ID = strconv.Itoa(dbID)
|
||||
c.ID = dbID
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (r *CompanyRepository) Update(ctx context.Context, company *entity.Company) (*entity.Company, error) {
|
||||
company.UpdatedAt = time.Now()
|
||||
|
||||
numID, err := strconv.Atoi(company.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid company id: %s", company.ID)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE companies
|
||||
SET name=$1, document=$2, email=$3, active=$4, updated_at=$5
|
||||
WHERE id=$6
|
||||
`
|
||||
_, err = r.db.ExecContext(ctx, query,
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
company.Name,
|
||||
company.Document,
|
||||
company.Contact,
|
||||
company.Status == "ACTIVE",
|
||||
company.UpdatedAt,
|
||||
numID,
|
||||
company.ID,
|
||||
)
|
||||
return company, err
|
||||
}
|
||||
|
||||
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`
|
||||
_, err = r.db.ExecContext(ctx, query, numID)
|
||||
_, err := r.db.ExecContext(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -120,11 +104,11 @@ func (r *CompanyRepository) FindAll(ctx context.Context) ([]*entity.Company, err
|
|||
var companies []*entity.Company
|
||||
for rows.Next() {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
c.ID = strconv.Itoa(dbID)
|
||||
c.ID = dbID
|
||||
companies = append(companies, c)
|
||||
}
|
||||
return companies, nil
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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()
|
||||
|
||||
// Convert tenant_id from string to int
|
||||
var tenantID *int
|
||||
// TenantID is string (UUID) or empty
|
||||
var tenantID *string
|
||||
if user.TenantID != "" {
|
||||
tid, err := strconv.Atoi(user.TenantID)
|
||||
if err == nil {
|
||||
tenantID = &tid
|
||||
}
|
||||
tenantID = &user.TenantID
|
||||
}
|
||||
|
||||
// 1. Insert User - users table has SERIAL id
|
||||
// 1. Insert User - users table has UUID id
|
||||
query := `
|
||||
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)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
var id int
|
||||
var id string
|
||||
// Map the first role to the role column, default to 'jobSeeker'
|
||||
role := "jobSeeker"
|
||||
if len(user.Roles) > 0 {
|
||||
|
|
@ -64,7 +59,7 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.ID = strconv.Itoa(id)
|
||||
user.ID = id
|
||||
|
||||
// 2. Insert Roles into user_roles table
|
||||
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)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
|
@ -99,42 +94,32 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity
|
|||
}
|
||||
return nil, err
|
||||
}
|
||||
u.ID = strconv.Itoa(dbID)
|
||||
u.ID = dbID
|
||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
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, ''),
|
||||
COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at
|
||||
FROM users WHERE id = $1`
|
||||
row := r.db.QueryRowContext(ctx, query, numID)
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
u := &entity.User{}
|
||||
var dbID int
|
||||
err = row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
||||
var dbID string
|
||||
err := row.Scan(&dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ID = strconv.Itoa(dbID)
|
||||
u.ID = dbID
|
||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +129,7 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
|||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
@ -153,11 +138,11 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l
|
|||
var users []*entity.User
|
||||
for rows.Next() {
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
u.ID = strconv.Itoa(dbID)
|
||||
u.ID = dbID
|
||||
u.Roles, _ = r.getRoles(ctx, dbID)
|
||||
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) {
|
||||
numID, err := strconv.Atoi(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user id: %s", user.ID)
|
||||
}
|
||||
|
||||
user.UpdatedAt = time.Now()
|
||||
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
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
||||
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)
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, id)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
-- Description: Stores all system users (SuperAdmin, CompanyAdmin, Recruiter, JobSeeker)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
identifier VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('superadmin', 'companyAdmin', 'recruiter', 'jobSeeker')),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
-- Description: Stores company information for employers
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL, -- URL friendly name
|
||||
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
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
company_id INT NOT NULL,
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
user_id UUID NOT NULL,
|
||||
company_id UUID NOT NULL,
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('companyAdmin', 'recruiter')),
|
||||
permissions JSONB, -- Optional granular permissions
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
-- Description: Job postings created by companies
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INT NOT NULL,
|
||||
created_by INT NOT NULL, -- user who created the job
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
company_id UUID NOT NULL,
|
||||
created_by UUID NOT NULL, -- user who created the job
|
||||
|
||||
-- Job Details
|
||||
title VARCHAR(255) NOT NULL,
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
-- Description: Job applications (can be from registered users or guests)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
job_id INT NOT NULL,
|
||||
user_id INT, -- NULL for guest applications
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
job_id UUID NOT NULL,
|
||||
user_id UUID, -- NULL for guest applications
|
||||
|
||||
-- Applicant Info (required for guest applications)
|
||||
name VARCHAR(255),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
|
||||
CREATE TABLE IF NOT EXISTS favorite_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
job_id INT NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
job_id UUID NOT NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
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
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ DROP TABLE IF EXISTS tickets;
|
|||
|
||||
CREATE TABLE tickets (
|
||||
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,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'open', -- open, in_progress, closed
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
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,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
|
||||
CREATE TABLE IF NOT EXISTS job_payments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
job_id INT NOT NULL,
|
||||
user_id INT,
|
||||
job_id UUID NOT NULL,
|
||||
user_id UUID,
|
||||
|
||||
-- Stripe
|
||||
stripe_session_id VARCHAR(255),
|
||||
|
|
@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS job_payments (
|
|||
paid_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 (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
|
||||
|
||||
-- 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)
|
||||
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 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,
|
||||
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 |
|
||||
|-------|---------|-------------|-----------|
|
||||
| `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 |
|
||||
| `companies` | SERIAL | Employer organizations | 002 |
|
||||
| `user_companies` | SERIAL | User ↔ Company mapping | 003 |
|
||||
| `companies` | UUID v7 | Employer organizations | 002/021 |
|
||||
| `user_companies` | UUID v7 | User ↔ Company mapping | 003/021 |
|
||||
| `regions` | SERIAL | States/Provinces/Prefectures | 004 |
|
||||
| `cities` | SERIAL | Cities within regions | 004 |
|
||||
| `jobs` | SERIAL | Job postings | 005 |
|
||||
| `applications` | SERIAL | Job applications | 006 |
|
||||
| `jobs` | UUID v7 | Job postings | 005/021 |
|
||||
| `applications` | UUID v7 | Job applications | 006/021 |
|
||||
| `favorite_jobs` | SERIAL | Saved jobs by users | 007 |
|
||||
| `password_resets` | SERIAL | Password reset tokens | 008 |
|
||||
| `notifications` | UUID v4 | User notifications | 016 |
|
||||
| `tickets` | UUID v4 | Support tickets | 017 |
|
||||
| `ticket_messages` | UUID v4 | Ticket messages | 017 |
|
||||
| `job_payments` | UUID v4 | Payment records | 019 |
|
||||
| `notifications` | UUID v7 | User notifications | 016 |
|
||||
| `tickets` | UUID v7 | Support tickets | 017 |
|
||||
| `ticket_messages` | UUID v7 | Ticket messages | 017 |
|
||||
| `job_payments` | UUID v7 | Payment records | 019 |
|
||||
| `job_posting_prices` | SERIAL | Pricing configuration | 019 |
|
||||
| `login_audits` | SERIAL | Login audit trail | 013 |
|
||||
| `activity_logs` | SERIAL | Activity logging | 013 |
|
||||
|
|
@ -600,8 +600,8 @@ The database uses a **hybrid ID strategy**:
|
|||
|
||||
| Strategy | Tables | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| **SERIAL (INT)** | users, companies, jobs, applications, regions, cities | Legacy tables, simpler, faster, auto-increment |
|
||||
| **UUID v7** | notifications, tickets, job_payments | Time-ordered, distributed-friendly, sortable |
|
||||
| **UUID v7** | users, companies, jobs, applications, notifications, tickets, job_payments | Time-ordered, distributed-friendly, sortable, scalable |
|
||||
| **SERIAL (INT)** | regions, cities, job_posting_prices, job_tags | Reference/Static data, low volume |
|
||||
|
||||
### UUID v7 (RFC 9562)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ Roadmap de desenvolvimento do projeto GoHorse Jobs.
|
|||
- [x] Multi-tenancy básico
|
||||
- [x] PASSWORD_PEPPER para hash seguro
|
||||
- [x] 🆕 Schema unificado (eliminado core_*)
|
||||
- [x] 🆕 Migração UUID v7 (Tabelas Core)
|
||||
- [x] 🆕 Seeder atualizado (UUID compatible)
|
||||
|
||||
### Frontend
|
||||
- [x] Login/Logout com cookies
|
||||
|
|
@ -53,7 +55,7 @@ Roadmap de desenvolvimento do projeto GoHorse Jobs.
|
|||
- [x] Regiões (BR, US, JP)
|
||||
- [x] Cidades
|
||||
- [x] Notificações
|
||||
- [x] 🆕 Schema unificado
|
||||
- [x] 🆕 Schema unificado e UUID
|
||||
|
||||
### DevOps
|
||||
- [x] Docker setup backend
|
||||
|
|
@ -94,8 +96,8 @@ Roadmap de desenvolvimento do projeto GoHorse Jobs.
|
|||
|
||||
### Alta Prioridade
|
||||
- [ ] Email transacional (welcome, reset, application)
|
||||
- [ ] Integração Stripe completa
|
||||
- [ ] Busca avançada com filtros
|
||||
- [ ] Integração Stripe completa (Webhook handlers)
|
||||
- [ ] Busca avançada com filtros (Backend pronto, Frontend pendente)
|
||||
- [ ] Internacionalização (i18n)
|
||||
- [ ] Testes E2E frontend
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue