This commit is contained in:
Luciano Milani
2026-07-02 12:47:55 +01:00
commit 5de37bb512
132 changed files with 28495 additions and 0 deletions
+242
View File
@@ -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,
})
}
}
+160
View File
@@ -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)
}
+219
View File
@@ -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
}
+113
View File
@@ -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)
})
}
+60
View File
@@ -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))
}