feat(core): consolidate auth service into backend and implement stripe billing
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.

Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
    - Added a new pricing page with monthly/yearly toggle and comparison table.
    - Integrated Stripe and Sentry for payments and error tracking.
    - Improved dashboard UX/UI and added i18n support for new features.
    - Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
This commit is contained in:
Tomas Dvorak
2026-05-09 18:25:25 +02:00
parent cf3315e8fc
commit 164a37e997
69 changed files with 4630 additions and 5260 deletions
+135
View File
@@ -0,0 +1,135 @@
package db
import (
"context"
"encoding/json"
)
func (r *PGRepository) ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, preset, locale, timezone, plan_code, subscription_status,
COALESCE(billing_provider, 'stripe'), billing_customer_id, billing_subscription_id
FROM tenants
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var tenants []TenantRecord
for rows.Next() {
var t TenantRecord
if err := rows.Scan(
&t.ID, &t.Slug, &t.Name, &t.Preset, &t.Locale, &t.Timezone,
&t.PlanCode, &t.SubscriptionStatus, &t.BillingProvider,
&t.BillingCustomerID, &t.BillingSubscription,
); err != nil {
return nil, 0, err
}
tenants = append(tenants, t)
}
return tenants, total, nil
}
func (r *PGRepository) ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error) {
var total int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx, `
SELECT id, email, name, email_verified, provider, role, created_at, last_login_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var u UserRecord
if err := rows.Scan(
&u.ID, &u.Email, &u.Name, &u.EmailVerified, &u.Provider, &u.Role,
&u.CreatedAt, &u.LastLoginAt,
); err != nil {
return nil, 0, err
}
users = append(users, u)
}
return users, total, nil
}
func (r *PGRepository) GetPlatformStats(ctx context.Context) (PlatformStats, error) {
var stats PlatformStats
// Total tenants
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&stats.TotalTenants)
// Total users
r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
// Active subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status IN ('active', 'trialing')
`).Scan(&stats.ActiveSubscriptions)
// Trial subscriptions
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM billing_snapshots
WHERE status = 'trialing'
`).Scan(&stats.TrialSubscriptions)
// Bookings this month
r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings
WHERE created_at >= date_trunc('month', CURRENT_DATE)
`).Scan(&stats.BookingsThisMonth)
return stats, nil
}
func (r *PGRepository) CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error {
var detailsJSON []byte
var err error
if params.Details != nil {
detailsJSON, err = json.Marshal(params.Details)
if err != nil {
return err
}
}
_, err = r.pool.Exec(ctx, `
INSERT INTO admin_audit_log (admin_user_id, action, resource_type, resource_id, details, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, nullableUUID(params.AdminUserID), params.Action, params.ResourceType, params.ResourceID, detailsJSON, params.IPAddress, params.UserAgent)
return err
}
func (r *PGRepository) UpdateUserRole(ctx context.Context, userID, role string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2
`, role, userID)
return err
}
func nullableUUID(s string) interface{} {
if s == "" {
return nil
}
return s
}
+137
View File
@@ -0,0 +1,137 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
func (r *PGRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) GetUserByID(ctx context.Context, userID string) (*UserRecord, error) {
var user UserRecord
var name, passwordHash *string
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
FROM users
WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
var user UserRecord
var lastLoginAt *time.Time
err := r.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, name, provider, role, email_verified)
VALUES ($1, $2, $3, $4, $5, false)
RETURNING id, email, name, password_hash, email_verified, provider, role, created_at, last_login_at
`, email, nullableString(passwordHash), nullableString(name), provider, role).Scan(
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
&user.EmailVerified, &user.Provider, &user.Role,
&user.CreatedAt, &lastLoginAt,
)
if err != nil {
return nil, err
}
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (r *PGRepository) UpdateLastLogin(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET last_login_at = NOW() WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) MarkEmailVerified(ctx context.Context, userID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE users SET email_verified = true WHERE id = $1
`, userID)
return err
}
func (r *PGRepository) CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO magic_links (token, user_id, email, expires_at)
VALUES ($1, $2, $3, $4)
`, token, userID, email, expiresAt)
return err
}
func (r *PGRepository) GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error) {
var ml MagicLinkRecord
err := r.pool.QueryRow(ctx, `
SELECT token, user_id, email, used, expires_at, created_at
FROM magic_links
WHERE token = $1
`, token).Scan(
&ml.Token, &ml.UserID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &ml, nil
}
func (r *PGRepository) MarkMagicLinkUsed(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx, `
UPDATE magic_links SET used = true WHERE token = $1
`, token)
return err
}
func nullableString(s string) interface{} {
if s == "" {
return nil
}
return s
}
+111
View File
@@ -38,6 +38,23 @@ type Repository interface {
UpdateTenantBillingState(ctx context.Context, tenantID string, planCode string, subscriptionStatus string, subscriptionID string) error
RecordBillingEvent(ctx context.Context, tenantID string, provider string, eventID string, eventType string, payload []byte) (bool, error)
// Auth methods
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
GetUserByID(ctx context.Context, userID string) (*UserRecord, error)
CreateUser(ctx context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error)
UpdateLastLogin(ctx context.Context, userID string) error
MarkEmailVerified(ctx context.Context, userID string) error
CreateMagicLink(ctx context.Context, token, userID, email string, expiresAt time.Time) error
GetMagicLink(ctx context.Context, token string) (*MagicLinkRecord, error)
MarkMagicLinkUsed(ctx context.Context, token string) error
// Admin methods
ListAllTenants(ctx context.Context, limit, offset int) ([]TenantRecord, int, error)
ListAllUsers(ctx context.Context, limit, offset int) ([]UserRecord, int, error)
GetPlatformStats(ctx context.Context) (PlatformStats, error)
CreateAdminAuditLog(ctx context.Context, params AdminAuditLogParams) error
UpdateUserRole(ctx context.Context, userID, role string) error
// Location / Zone Management
ListLocationsByTenant(ctx context.Context, tenantID string) ([]LocationRecord, error)
GetLocationByID(ctx context.Context, locationID string) (LocationRecord, error)
@@ -85,6 +102,46 @@ type TenantRecord struct {
BillingSubscription *string
}
type UserRecord struct {
ID uuid.UUID
Email string
Name *string
PasswordHash *string
EmailVerified bool
Provider string
Role string
CreatedAt time.Time
LastLoginAt *time.Time
}
type MagicLinkRecord struct {
Token string
UserID uuid.UUID
Email string
Used bool
ExpiresAt time.Time
CreatedAt time.Time
}
type PlatformStats struct {
TotalTenants int64 `json:"totalTenants"`
TotalUsers int64 `json:"totalUsers"`
ActiveSubscriptions int64 `json:"activeSubscriptions"`
TrialSubscriptions int64 `json:"trialSubscriptions"`
BookingsThisMonth int64 `json:"bookingsThisMonth"`
RevenueThisMonth int64 `json:"revenueThisMonthCents"`
}
type AdminAuditLogParams struct {
AdminUserID string
Action string
ResourceType string
ResourceID string
Details map[string]any
IPAddress string
UserAgent string
}
type TenantMembershipRecord struct {
Tenant TenantRecord
UserID string
@@ -1303,6 +1360,60 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
return pgx.ErrNoRows
}
// Auth methods for MemoryRepository
func (r *MemoryRepository) GetUserByEmail(_ context.Context, email string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) GetUserByID(_ context.Context, userID string) (*UserRecord, error) {
return nil, nil
}
func (r *MemoryRepository) CreateUser(_ context.Context, email, passwordHash, name, provider, role string) (*UserRecord, error) {
return &UserRecord{ID: uuid.New(), Email: email, Name: &name, Provider: provider, Role: role}, nil
}
func (r *MemoryRepository) UpdateLastLogin(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) MarkEmailVerified(_ context.Context, userID string) error {
return nil
}
func (r *MemoryRepository) CreateMagicLink(_ context.Context, token, userID, email string, expiresAt time.Time) error {
return nil
}
func (r *MemoryRepository) GetMagicLink(_ context.Context, token string) (*MagicLinkRecord, error) {
return nil, nil
}
func (r *MemoryRepository) MarkMagicLinkUsed(_ context.Context, token string) error {
return nil
}
// Admin methods for MemoryRepository
func (r *MemoryRepository) ListAllTenants(_ context.Context, limit, offset int) ([]TenantRecord, int, error) {
return []TenantRecord{r.tenant}, 1, nil
}
func (r *MemoryRepository) ListAllUsers(_ context.Context, limit, offset int) ([]UserRecord, int, error) {
return []UserRecord{}, 0, nil
}
func (r *MemoryRepository) GetPlatformStats(_ context.Context) (PlatformStats, error) {
return PlatformStats{TotalTenants: 1, TotalUsers: 1, ActiveSubscriptions: 1}, nil
}
func (r *MemoryRepository) CreateAdminAuditLog(_ context.Context, params AdminAuditLogParams) error {
return nil
}
func (r *MemoryRepository) UpdateUserRole(_ context.Context, userID, role string) error {
return nil
}
func Reference(prefix string, at time.Time) string {
return fmt.Sprintf("%s-%s-%s", prefix, at.UTC().Format("20060102150405"), strings.Split(uuid.NewString(), "-")[0])
}