mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(core): consolidate auth service into backend and implement stripe billing
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user