Inicial
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
"github.com/techxcar/backend/internal/config"
|
||||
"github.com/techxcar/backend/pkg/database"
|
||||
)
|
||||
|
||||
func listTenantsHandler(repo *Repository) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
list, err := repo.ListTenants(c.Context())
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao listar oficinas")
|
||||
}
|
||||
if list == nil {
|
||||
list = []*Tenant{}
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": list, "error": nil})
|
||||
}
|
||||
}
|
||||
|
||||
type createTenantRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
AdminPassword string `json:"admin_password"`
|
||||
AdminName string `json:"admin_name"`
|
||||
}
|
||||
|
||||
func createTenantHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req createTenantRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return fiber.NewError(400, "corpo do pedido inválido")
|
||||
}
|
||||
if req.Name == "" || req.Slug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" {
|
||||
return fiber.NewError(400, "todos os campos são obrigatórios")
|
||||
}
|
||||
existing, err := repo.GetTenantBySlug(c.Context(), req.Slug)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno")
|
||||
}
|
||||
if existing != nil {
|
||||
return fiber.NewError(409, "slug já existe")
|
||||
}
|
||||
|
||||
ten, err := repo.CreateTenant(c.Context(), req.Slug, req.Name)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao criar oficina")
|
||||
}
|
||||
|
||||
if db != nil {
|
||||
if err := db.ProvisionTenantSchema(c.Context(), ten.ID, "migrations/tenant"); err != nil {
|
||||
return fiber.NewError(500, "erro ao provisionar schema")
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.AdminPassword)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno")
|
||||
}
|
||||
if db != nil {
|
||||
if _, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin"); err != nil {
|
||||
return fiber.NewError(500, "erro ao criar utilizador admin")
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(201).JSON(fiber.Map{"data": ten, "error": nil})
|
||||
}
|
||||
}
|
||||
|
||||
func generateInviteHandler(repo *Repository) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
ten, err := repo.GetTenantByID(c.Context(), tenantID)
|
||||
if err != nil || ten == nil {
|
||||
return fiber.NewError(404, "oficina não encontrada")
|
||||
}
|
||||
|
||||
invite, err := repo.CreateInvite(c.Context(), &ten.ID, 72*time.Hour)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar convite")
|
||||
}
|
||||
|
||||
return c.Status(201).JSON(fiber.Map{
|
||||
"data": fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt},
|
||||
"error": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generatePlatformInviteHandler(repo *Repository) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
invite, err := repo.CreateInvite(c.Context(), nil, 72*time.Hour)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar convite")
|
||||
}
|
||||
return c.Status(201).JSON(fiber.Map{
|
||||
"data": fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt},
|
||||
"error": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getInviteHandler(repo *Repository) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := c.Params("token")
|
||||
invite, err := repo.GetInviteByToken(c.Context(), token)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno")
|
||||
}
|
||||
if invite == nil {
|
||||
return fiber.NewError(404, "convite não encontrado")
|
||||
}
|
||||
if invite.UsedAt != nil {
|
||||
return fiber.NewError(410, "convite já foi utilizado")
|
||||
}
|
||||
if invite.ExpiresAt.Before(time.Now()) {
|
||||
return fiber.NewError(410, "convite expirado")
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": invite, "error": nil})
|
||||
}
|
||||
}
|
||||
|
||||
type redeemRequest struct {
|
||||
TenantName string `json:"tenant_name"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
AdminPassword string `json:"admin_password"`
|
||||
AdminName string `json:"admin_name"`
|
||||
}
|
||||
|
||||
func tenantAccessHandler(repo *Repository, cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
claims, ok := c.Locals("claims").(*auth.Claims)
|
||||
if !ok {
|
||||
return fiber.NewError(401, "autenticação necessária")
|
||||
}
|
||||
|
||||
tenantID := c.Params("id")
|
||||
ten, err := repo.GetTenantByID(c.Context(), tenantID)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno")
|
||||
}
|
||||
if ten == nil {
|
||||
return fiber.NewError(404, "oficina não encontrada")
|
||||
}
|
||||
if ten.Status != "active" {
|
||||
return fiber.NewError(404, "oficina não encontrada ou inativa")
|
||||
}
|
||||
|
||||
token, err := auth.GenerateAccessToken(claims.UserID, ten.ID, "tenant_admin", cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao gerar token")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"data": fiber.Map{
|
||||
"access_token": token,
|
||||
"tenant": fiber.Map{
|
||||
"id": ten.ID,
|
||||
"name": ten.Name,
|
||||
"slug": ten.Slug,
|
||||
},
|
||||
},
|
||||
"error": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func redeemInviteHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := c.Params("token")
|
||||
invite, err := repo.GetInviteByToken(c.Context(), token)
|
||||
if err != nil || invite == nil {
|
||||
return fiber.NewError(404, "convite não encontrado")
|
||||
}
|
||||
if invite.UsedAt != nil || invite.ExpiresAt.Before(time.Now()) {
|
||||
return fiber.NewError(410, "convite inválido ou expirado")
|
||||
}
|
||||
|
||||
var req redeemRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return fiber.NewError(400, "corpo do pedido inválido")
|
||||
}
|
||||
if req.TenantName == "" || req.TenantSlug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" {
|
||||
return fiber.NewError(400, "todos os campos são obrigatórios")
|
||||
}
|
||||
if len(req.AdminPassword) < 8 {
|
||||
return fiber.NewError(400, "password deve ter pelo menos 8 caracteres")
|
||||
}
|
||||
|
||||
existing, _ := repo.GetTenantBySlug(c.Context(), req.TenantSlug)
|
||||
if existing != nil {
|
||||
return fiber.NewError(409, "slug já existe")
|
||||
}
|
||||
|
||||
ten, err := repo.CreateTenant(c.Context(), req.TenantSlug, req.TenantName)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao criar oficina")
|
||||
}
|
||||
|
||||
if db != nil {
|
||||
if err := db.ProvisionTenantSchema(c.Context(), ten.ID, "migrations/tenant"); err != nil {
|
||||
return fiber.NewError(500, "erro ao provisionar schema")
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.AdminPassword)
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro interno")
|
||||
}
|
||||
|
||||
var userID string
|
||||
if db != nil {
|
||||
user, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin")
|
||||
if err != nil {
|
||||
return fiber.NewError(500, "erro ao criar utilizador")
|
||||
}
|
||||
userID = user.ID
|
||||
} else {
|
||||
userID = "mock-user-id"
|
||||
}
|
||||
|
||||
if err := repo.UseInvite(c.Context(), invite.ID); err != nil {
|
||||
return fiber.NewError(500, "erro ao registar utilização do convite")
|
||||
}
|
||||
|
||||
access, _ := auth.GenerateAccessToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret)
|
||||
refresh, _ := auth.GenerateRefreshToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret)
|
||||
auth.SetRefreshCookie(c, refresh, cfg)
|
||||
|
||||
return c.Status(201).JSON(fiber.Map{
|
||||
"data": fiber.Map{"access_token": access, "tenant_slug": ten.Slug},
|
||||
"error": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package tenant_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
"github.com/techxcar/backend/internal/config"
|
||||
"github.com/techxcar/backend/internal/tenant"
|
||||
)
|
||||
|
||||
const handlerTestSecret = "test-secret-32-chars-minimum-ok!"
|
||||
|
||||
func buildAdminApp(repo *tenant.Repository, cfg *config.Config) *fiber.App {
|
||||
app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()})
|
||||
}})
|
||||
tenant.RegisterRoutes(app, repo, nil, cfg)
|
||||
return app
|
||||
}
|
||||
|
||||
func adminToken(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
tok, err := auth.GenerateAccessToken("sa-1", "", "super_admin", secret)
|
||||
require.NoError(t, err)
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestListTenants_requiresAuth(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
cfg := &config.Config{JWTSecret: handlerTestSecret}
|
||||
app := buildAdminApp(repo, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestListTenants_success(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
cfg := &config.Config{JWTSecret: handlerTestSecret}
|
||||
app := buildAdminApp(repo, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||
assert.Nil(t, result["error"])
|
||||
}
|
||||
|
||||
func TestTenantAccessHandler(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
cfg := &config.Config{JWTSecret: handlerTestSecret}
|
||||
app := buildAdminApp(repo, cfg)
|
||||
|
||||
ctx := t.Context()
|
||||
slug := "access-test-" + t.Name()
|
||||
ten, err := repo.CreateTenant(ctx, slug, "Tenant Access Test")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/admin/tenants/"+ten.ID+"/access", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var body struct {
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
} `json:"tenant"`
|
||||
} `json:"data"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Nil(t, body.Error)
|
||||
assert.NotEmpty(t, body.Data.AccessToken)
|
||||
assert.Equal(t, ten.ID, body.Data.Tenant.ID)
|
||||
assert.Equal(t, "Tenant Access Test", body.Data.Tenant.Name)
|
||||
assert.Equal(t, slug, body.Data.Tenant.Slug)
|
||||
|
||||
claims, err := auth.ValidateToken(body.Data.AccessToken, handlerTestSecret)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "sa-1", claims.UserID)
|
||||
assert.Equal(t, ten.ID, claims.TenantID)
|
||||
assert.Equal(t, "tenant_admin", claims.Role)
|
||||
}
|
||||
|
||||
func TestTenantAccessHandler_notFound(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
cfg := &config.Config{JWTSecret: handlerTestSecret}
|
||||
app := buildAdminApp(repo, cfg)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/admin/tenants/nonexistent-id/access", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 404, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestTenantAccessHandler_inactive(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
cfg := &config.Config{JWTSecret: handlerTestSecret}
|
||||
app := buildAdminApp(repo, cfg)
|
||||
|
||||
ctx := t.Context()
|
||||
slug := "inactive-test-" + t.Name()
|
||||
ten, err := repo.CreateTenant(ctx, slug, "Tenant Inactive Test")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID)
|
||||
})
|
||||
|
||||
// Update tenant status to something other than "active"
|
||||
_, err = db.Pool.Exec(ctx, "UPDATE public.tenants SET status = 'suspended' WHERE id = $1", ten.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/admin/tenants/"+ten.ID+"/access", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 404, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetInvite_notFound(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
cfg := &config.Config{JWTSecret: handlerTestSecret}
|
||||
app := buildAdminApp(repo, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/invites/nonexistent-token", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 404, resp.StatusCode)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/techxcar/backend/pkg/database"
|
||||
)
|
||||
|
||||
var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
type SuperAdmin struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TenantUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type Invite struct {
|
||||
ID string `json:"id"`
|
||||
TenantID *string `json:"tenant_id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *database.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) GetSuperAdminByEmail(ctx context.Context, email string) (*SuperAdmin, error) {
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, created_at FROM super_admins WHERE email = $1`, email)
|
||||
var a SuperAdmin
|
||||
err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant: get super admin: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateSuperAdmin(ctx context.Context, email, passwordHash string) (*SuperAdmin, error) {
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO super_admins (email, password_hash) VALUES ($1, $2)
|
||||
RETURNING id, email, password_hash, created_at`,
|
||||
email, passwordHash)
|
||||
var a SuperAdmin
|
||||
if err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("tenant: create super admin: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) {
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
`SELECT id, slug, name, status, created_at FROM tenants WHERE slug = $1`, slug)
|
||||
var t Tenant
|
||||
err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant: get by slug: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTenantByID(ctx context.Context, id string) (*Tenant, error) {
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
`SELECT id, slug, name, status, created_at FROM tenants WHERE id = $1`, id)
|
||||
var t Tenant
|
||||
err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant: get by id: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListTenants(ctx context.Context) ([]*Tenant, error) {
|
||||
rows, err := r.db.Pool.Query(ctx,
|
||||
`SELECT id, slug, name, status, created_at FROM tenants ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []*Tenant
|
||||
for rows.Next() {
|
||||
var t Tenant
|
||||
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("tenant: list scan: %w", err)
|
||||
}
|
||||
list = append(list, &t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("tenant: list rows: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateTenant(ctx context.Context, slug, name string) (*Tenant, error) {
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO tenants (slug, name) VALUES ($1, $2)
|
||||
RETURNING id, slug, name, status, created_at`,
|
||||
slug, name)
|
||||
var t Tenant
|
||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("tenant: create: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*TenantUser, error) {
|
||||
if !uuidRe.MatchString(tenantID) {
|
||||
return nil, fmt.Errorf("tenant: invalid tenant ID format")
|
||||
}
|
||||
schema := `"tenant_` + strings.ReplaceAll(tenantID, "-", "_") + `"`
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
fmt.Sprintf(`SELECT id, email, password_hash, role, name, active FROM %s.users WHERE email = $1`, schema),
|
||||
email)
|
||||
var u TenantUser
|
||||
err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant: get user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateTenantUser(ctx context.Context, tenantID, email, passwordHash, name, role string) (*TenantUser, error) {
|
||||
if !uuidRe.MatchString(tenantID) {
|
||||
return nil, fmt.Errorf("tenant: invalid tenant ID format")
|
||||
}
|
||||
schema := `"tenant_` + strings.ReplaceAll(tenantID, "-", "_") + `"`
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
fmt.Sprintf(`INSERT INTO %s.users (email, password_hash, name, role)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, email, password_hash, role, name, active`, schema),
|
||||
email, passwordHash, name, role)
|
||||
var u TenantUser
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active); err != nil {
|
||||
return nil, fmt.Errorf("tenant: create user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateInvite(ctx context.Context, tenantID *string, expiresIn time.Duration) (*Invite, error) {
|
||||
token := uuid.New().String()
|
||||
expiresAt := time.Now().Add(expiresIn)
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO invites (tenant_id, token, expires_at) VALUES ($1, $2, $3)
|
||||
RETURNING id, tenant_id, token, expires_at, used_at, created_at`,
|
||||
tenantID, token, expiresAt)
|
||||
var inv Invite
|
||||
if err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("tenant: create invite: %w", err)
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetInviteByToken(ctx context.Context, token string) (*Invite, error) {
|
||||
row := r.db.Pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, token, expires_at, used_at, created_at FROM invites WHERE token = $1`, token)
|
||||
var inv Invite
|
||||
err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant: get invite: %w", err)
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UseInvite(ctx context.Context, inviteID string) error {
|
||||
tag, err := r.db.Pool.Exec(ctx,
|
||||
`UPDATE invites SET used_at = NOW() WHERE id = $1`, inviteID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tenant: use invite: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("tenant: invite not found: %s", inviteID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package tenant_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/techxcar/backend/internal/tenant"
|
||||
"github.com/techxcar/backend/pkg/database"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *database.DB {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
|
||||
}
|
||||
db, err := database.New(url)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(db.Close)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestGetSuperAdminByEmail_notFound(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
admin, err := repo.GetSuperAdminByEmail(context.Background(), "nobody@example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, admin)
|
||||
}
|
||||
|
||||
func TestCreateAndGetSuperAdmin(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
email := "sa_" + time.Now().Format("20060102150405") + "@example.com"
|
||||
admin, err := repo.CreateSuperAdmin(ctx, email, "hash123")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, admin)
|
||||
assert.NotEmpty(t, admin.ID)
|
||||
assert.Equal(t, email, admin.Email)
|
||||
|
||||
found, err := repo.GetSuperAdminByEmail(ctx, email)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
assert.Equal(t, admin.ID, found.ID)
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Pool.Exec(ctx, "DELETE FROM super_admins WHERE id = $1", admin.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAndListTenants(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
slug := "test-" + time.Now().Format("20060102150405")
|
||||
ten, err := repo.CreateTenant(ctx, slug, "Test Workshop")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ten)
|
||||
assert.NotEmpty(t, ten.ID)
|
||||
assert.Equal(t, slug, ten.Slug)
|
||||
assert.Equal(t, "active", ten.Status)
|
||||
|
||||
list, err := repo.ListTenants(ctx)
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, v := range list {
|
||||
if v.ID == ten.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAndUseInvite(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := tenant.NewRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
invite, err := repo.CreateInvite(ctx, nil, 24*time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, invite)
|
||||
assert.NotEmpty(t, invite.Token)
|
||||
assert.Nil(t, invite.UsedAt)
|
||||
assert.True(t, invite.ExpiresAt.After(time.Now()))
|
||||
|
||||
found, err := repo.GetInviteByToken(ctx, invite.Token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
assert.Equal(t, invite.ID, found.ID)
|
||||
|
||||
err = repo.UseInvite(ctx, invite.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
used, err := repo.GetInviteByToken(ctx, invite.Token)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, used.UsedAt)
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Pool.Exec(ctx, "DELETE FROM invites WHERE id = $1", invite.ID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/techxcar/backend/internal/auth"
|
||||
"github.com/techxcar/backend/internal/config"
|
||||
"github.com/techxcar/backend/pkg/database"
|
||||
)
|
||||
|
||||
// loginAdapter adapts *Repository to satisfy auth.LoginRepository.
|
||||
type loginAdapter struct{ repo *Repository }
|
||||
|
||||
func (a *loginAdapter) GetSuperAdminByEmail(ctx context.Context, email string) (*auth.LoginSuperAdmin, error) {
|
||||
sa, err := a.repo.GetSuperAdminByEmail(ctx, email)
|
||||
if err != nil || sa == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &auth.LoginSuperAdmin{ID: sa.ID, Email: sa.Email, PasswordHash: sa.PasswordHash}, nil
|
||||
}
|
||||
|
||||
func (a *loginAdapter) GetTenantBySlug(ctx context.Context, slug string) (*auth.LoginTenant, error) {
|
||||
t, err := a.repo.GetTenantBySlug(ctx, slug)
|
||||
if err != nil || t == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &auth.LoginTenant{ID: t.ID, Status: t.Status}, nil
|
||||
}
|
||||
|
||||
func (a *loginAdapter) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*auth.LoginUser, error) {
|
||||
u, err := a.repo.GetTenantUserByEmail(ctx, tenantID, email)
|
||||
if err != nil || u == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &auth.LoginUser{ID: u.ID, Email: u.Email, PasswordHash: u.PasswordHash, Role: u.Role, Name: u.Name, Active: u.Active}, nil
|
||||
}
|
||||
|
||||
// LoginAdapter returns an auth.LoginRepository backed by repo.
|
||||
func LoginAdapter(repo *Repository) auth.LoginRepository {
|
||||
return &loginAdapter{repo: repo}
|
||||
}
|
||||
|
||||
func RegisterRoutes(app *fiber.App, repo *Repository, db *database.DB, cfg *config.Config) {
|
||||
// Public invite routes
|
||||
invites := app.Group("/api/v1/invites")
|
||||
invites.Get("/:token", getInviteHandler(repo))
|
||||
invites.Post("/:token/redeem", redeemInviteHandler(repo, db, cfg))
|
||||
|
||||
// Super-admin routes
|
||||
admin := app.Group("/api/v1/admin",
|
||||
auth.RequireAuth(cfg.JWTSecret),
|
||||
auth.RequireRole("super_admin"),
|
||||
)
|
||||
admin.Get("/tenants", listTenantsHandler(repo))
|
||||
admin.Post("/tenants", createTenantHandler(repo, db, cfg))
|
||||
admin.Post("/tenants/:id/invite", generateInviteHandler(repo))
|
||||
admin.Post("/tenants/:id/access", tenantAccessHandler(repo, cfg))
|
||||
admin.Post("/invites", generatePlatformInviteHandler(repo))
|
||||
}
|
||||
Reference in New Issue
Block a user