refactor: migrate core tables to UUID v7 and update roadmap

This commit is contained in:
Tiago Yamamoto 2025-12-24 12:30:49 -03:00
parent 4b680f2c31
commit 1b4f1d1555
16 changed files with 175 additions and 106 deletions

View file

@ -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

View file

@ -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

View file

@ -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')),

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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),

View file

@ -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,

View file

@ -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,

View file

@ -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
);

View file

@ -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
);

View file

@ -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
View 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
```

View file

@ -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)

View file

@ -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