package db
import (
"encoding/json"
"fmt"
"log"
"time"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// MCPContext is the minimal user context needed for MCP tool execution.
type MCPContext struct {
AccountStatus string `json:"account_status"`
PlanID string `json:"plan_id"`
DailyUsed int `json:"daily_used"`
DailyLimit int `json:"daily_limit"`
EnabledModules []string `json:"enabled_modules"`
EnabledTools map[string][]string `json:"enabled_tools"`
ModuleDescriptions map[string]string `json:"module_descriptions"`
}
// MyProfile is the user profile returned to Console.
type MyProfile struct {
ID string `json:"id"`
AccountStatus string `json:"account_status"`
PlanID string `json:"plan_id"`
DisplayName *string `json:"display_name"`
AvatarURL *string `json:"avatar_url"`
Email *string `json:"email"`
Role string `json:"role"`
Settings json.RawMessage `json:"settings"`
ConnectedCount int `json:"connected_count"`
}
// FindByID looks up a user by their internal UUID.
func FindByID(db *gorm.DB, userID string) (*User, error) {
var user User
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// FindByClerkID looks up a user by their Clerk ID.
func FindByClerkID(db *gorm.DB, clerkID string) (*User, error) {
var user User
if err := db.Where("clerk_id = ?", clerkID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// FindOrCreateByClerkID resolves a Clerk ID to an internal user UUID.
// If the user doesn't exist, creates one automatically.
func FindOrCreateByClerkID(db *gorm.DB, clerkID, email string) (string, error) {
var user User
err := db.Session(&gorm.Session{Logger: db.Logger.LogMode(logger.Silent)}).Where("clerk_id = ?", clerkID).First(&user).Error
if err == nil {
return user.ID, nil
}
if err != gorm.ErrRecordNotFound {
return "", fmt.Errorf("lookup by clerk_id: %w", err)
}
// Auto-create user (ID is generated by DB default gen_random_uuid())
user = User{
ClerkID: &clerkID,
Email: &email,
AccountStatus: "active",
}
if err := db.Create(&user).Error; err != nil {
// Race condition: another request may have created the user
var existing User
if err2 := db.Where("clerk_id = ?", clerkID).First(&existing).Error; err2 == nil {
return existing.ID, nil
}
return "", fmt.Errorf("create user: %w", err)
}
log.Printf("[user] Auto-created user %s for clerk_id=%s", user.ID, clerkID)
return user.ID, nil
}
// GetMCPContext returns the minimal context for MCP tool access checks.
func GetMCPContext(db *gorm.DB, userID string) (*MCPContext, error) {
var user User
if err := db.Select("account_status", "plan_id", "settings").
Where("id = ?", userID).First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
// Get plan daily limit
var plan Plan
if err := db.Select("daily_limit").
Where("id = ?", user.PlanID).First(&plan).Error; err != nil {
return nil, fmt.Errorf("plan not found: %w", err)
}
// Count today's usage
today := time.Now().UTC().Truncate(24 * time.Hour)
var dailyUsed int64
db.Model(&UsageLog{}).Where("user_id = ? AND created_at >= ?", userID, today).Count(&dailyUsed)
// Get enabled tools grouped by module (only where credentials exist)
type toolRow struct {
ModuleName string `gorm:"column:module_name"`
ToolID string `gorm:"column:tool_id"`
}
var rows []toolRow
db.Table("mcpist.tool_settings ts").
Select("m.name AS module_name, ts.tool_id").
Joins("JOIN mcpist.modules m ON m.id = ts.module_id").
Joins("JOIN mcpist.user_credentials uc ON uc.user_id = ts.user_id AND uc.module = m.name").
Where("ts.user_id = ? AND ts.enabled = true AND m.status IN ('active', 'beta')", userID).
Find(&rows)
enabledModulesSet := map[string]bool{}
enabledTools := map[string][]string{}
for _, r := range rows {
enabledModulesSet[r.ModuleName] = true
enabledTools[r.ModuleName] = append(enabledTools[r.ModuleName], r.ToolID)
}
enabledModules := make([]string, 0, len(enabledModulesSet))
for m := range enabledModulesSet {
enabledModules = append(enabledModules, m)
}
// Get custom module descriptions
type descRow struct {
ModuleName string `gorm:"column:module_name"`
Description string `gorm:"column:description"`
}
var descRows []descRow
db.Table("mcpist.module_settings ms").
Select("m.name AS module_name, ms.description").
Joins("JOIN mcpist.modules m ON m.id = ms.module_id").
Where("ms.user_id = ? AND ms.description != ''", userID).
Find(&descRows)
moduleDescriptions := make(map[string]string, len(descRows))
for _, d := range descRows {
moduleDescriptions[d.ModuleName] = d.Description
}
return &MCPContext{
AccountStatus: user.AccountStatus,
PlanID: user.PlanID,
DailyUsed: int(dailyUsed),
DailyLimit: plan.DailyLimit,
EnabledModules: enabledModules,
EnabledTools: enabledTools,
ModuleDescriptions: moduleDescriptions,
}, nil
}
// GetMyProfile returns the user's profile for the Console.
// Admin role is determined by the user's role column in the database.
func GetMyProfile(db *gorm.DB, userID string) (*MyProfile, error) {
var user User
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
var connectedCount int64
db.Model(&UserCredential{}).Where("user_id = ?", userID).Count(&connectedCount)
return &MyProfile{
ID: user.ID,
AccountStatus: user.AccountStatus,
PlanID: user.PlanID,
DisplayName: user.DisplayName,
AvatarURL: user.AvatarURL,
Email: user.Email,
Role: user.Role,
Settings: json.RawMessage(user.Settings),
ConnectedCount: int(connectedCount),
}, nil
}
// CreateUser creates a new user with the given Clerk ID.
func CreateUser(db *gorm.DB, id, clerkID, email string) error {
user := User{
ID: id,
ClerkID: &clerkID,
Email: &email,
}
return db.Create(&user).Error
}
// UpdateSettings updates the user's settings JSON.
func UpdateSettings(db *gorm.DB, userID string, settings json.RawMessage) error {
return db.Model(&User{}).Where("id = ?", userID).Update("settings", JSONB(settings)).Error
}
// CompleteOnboarding activates a user's account.
func CompleteOnboarding(db *gorm.DB, userID string, eventID string) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check idempotency
var count int64
tx.Model(&ProcessedWebhookEvent{}).Where("event_id = ?", eventID).Count(&count)
if count > 0 {
return nil
}
if err := tx.Model(&User{}).Where("id = ?", userID).
Update("account_status", gorm.Expr("?::mcpist.account_status", "active")).Error; err != nil {
return err
}
return tx.Create(&ProcessedWebhookEvent{
EventID: eventID,
UserID: userID,
}).Error
})
}
// GetStripeCustomerID returns the Stripe customer ID for a user.
func GetStripeCustomerID(db *gorm.DB, userID string) (*string, error) {
var user User
if err := db.Select("stripe_customer_id").Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return user.StripeCustomerID, nil
}
// LinkStripeCustomer links a Stripe customer ID to a user.
func LinkStripeCustomer(db *gorm.DB, userID, customerID string) error {
return db.Model(&User{}).Where("id = ?", userID).Update("stripe_customer_id", customerID).Error
}
// GetUserByStripeCustomer finds a user by Stripe customer ID.
func GetUserByStripeCustomer(db *gorm.DB, customerID string) (*User, error) {
var user User
if err := db.Where("stripe_customer_id = ?", customerID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// ActivateSubscription upgrades a user's plan within a transaction.
func ActivateSubscription(db *gorm.DB, userID, planID, eventID string) error {
return db.Transaction(func(tx *gorm.DB) error {
var count int64
tx.Model(&ProcessedWebhookEvent{}).Where("event_id = ?", eventID).Count(&count)
if count > 0 {
return nil
}
if err := tx.Model(&User{}).Where("id = ?", userID).Update("plan_id", planID).Error; err != nil {
return err
}
return tx.Create(&ProcessedWebhookEvent{
EventID: eventID,
UserID: userID,
}).Error
})
}