Implements automated onboarding emails to encourage user engagement: - Post-verification welcome email with 5 tips (sent after email verification) - "No Residence" email (2+ days after registration with no property) - "No Tasks" email (5+ days after first residence with no tasks) Key features: - Each onboarding email type sent only once per user (enforced by unique constraint) - Email open tracking via tracking pixel endpoint - Daily scheduled job at 10:00 AM UTC to process eligible users - Admin panel UI for viewing sent emails, stats, and manual sending - Admin can send any email type to users from the user detail Testing section New files: - internal/models/onboarding_email.go - Database model with tracking - internal/services/onboarding_email_service.go - Business logic and eligibility queries - internal/handlers/tracking_handler.go - Email open tracking endpoint - internal/admin/handlers/onboarding_handler.go - Admin API endpoints - admin/src/app/(dashboard)/onboarding-emails/ - Admin UI pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
451 lines
14 KiB
Go
451 lines
14 KiB
Go
package database
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/treytartt/casera-api/internal/config"
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
)
|
|
|
|
var db *gorm.DB
|
|
|
|
// Connect establishes a connection to the PostgreSQL database
|
|
func Connect(cfg *config.DatabaseConfig, debug bool) (*gorm.DB, error) {
|
|
// Configure GORM logger
|
|
logLevel := logger.Silent
|
|
if debug {
|
|
logLevel = logger.Info
|
|
}
|
|
|
|
gormConfig := &gorm.Config{
|
|
Logger: logger.Default.LogMode(logLevel),
|
|
NowFunc: func() time.Time {
|
|
return time.Now().UTC()
|
|
},
|
|
PrepareStmt: true, // Cache prepared statements
|
|
}
|
|
|
|
// Connect to database
|
|
var err error
|
|
db, err = gorm.Open(postgres.Open(cfg.DSN()), gormConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
|
|
// Get underlying sql.DB for connection pool settings
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err)
|
|
}
|
|
|
|
// Configure connection pool
|
|
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
|
sqlDB.SetConnMaxLifetime(cfg.MaxLifetime)
|
|
|
|
// Test connection
|
|
if err := sqlDB.Ping(); err != nil {
|
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
log.Info().
|
|
Str("host", cfg.Host).
|
|
Int("port", cfg.Port).
|
|
Str("database", cfg.Database).
|
|
Msg("Connected to PostgreSQL database")
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// Get returns the database instance
|
|
func Get() *gorm.DB {
|
|
return db
|
|
}
|
|
|
|
// Close closes the database connection
|
|
func Close() error {
|
|
if db != nil {
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sqlDB.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WithTransaction executes a function within a database transaction
|
|
func WithTransaction(fn func(tx *gorm.DB) error) error {
|
|
return db.Transaction(fn)
|
|
}
|
|
|
|
// Paginate returns a GORM scope for pagination
|
|
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
|
|
return func(db *gorm.DB) *gorm.DB {
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if pageSize <= 0 {
|
|
pageSize = 100
|
|
}
|
|
if pageSize > 1000 {
|
|
pageSize = 1000
|
|
}
|
|
|
|
offset := (page - 1) * pageSize
|
|
return db.Offset(offset).Limit(pageSize)
|
|
}
|
|
}
|
|
|
|
// Migrate runs database migrations for all models
|
|
func Migrate() error {
|
|
log.Info().Msg("Running database migrations...")
|
|
|
|
// Migrate all models in order (respecting foreign key constraints)
|
|
err := db.AutoMigrate(
|
|
// Lookup tables first (no foreign keys)
|
|
&models.ResidenceType{},
|
|
&models.TaskCategory{},
|
|
&models.TaskPriority{},
|
|
&models.TaskFrequency{},
|
|
&models.TaskStatus{},
|
|
&models.ContractorSpecialty{},
|
|
&models.TaskTemplate{}, // Task templates reference category and frequency
|
|
|
|
// User and auth tables
|
|
&models.User{},
|
|
&models.AuthToken{},
|
|
&models.UserProfile{},
|
|
&models.ConfirmationCode{},
|
|
&models.PasswordResetCode{},
|
|
&models.AppleSocialAuth{},
|
|
|
|
// Admin users (separate from app users)
|
|
&models.AdminUser{},
|
|
|
|
// Main entity tables (order matters for foreign keys!)
|
|
&models.Residence{},
|
|
&models.ResidenceShareCode{},
|
|
&models.Contractor{}, // Contractor before Task (Task references Contractor)
|
|
&models.Task{},
|
|
&models.TaskCompletion{},
|
|
&models.TaskCompletionImage{}, // Multiple images per completion
|
|
&models.Document{},
|
|
&models.DocumentImage{}, // Multiple images per document
|
|
|
|
// Notification tables
|
|
&models.Notification{},
|
|
&models.NotificationPreference{},
|
|
&models.APNSDevice{},
|
|
&models.GCMDevice{},
|
|
|
|
// Subscription tables
|
|
&models.SubscriptionSettings{},
|
|
&models.UserSubscription{},
|
|
&models.UpgradeTrigger{},
|
|
&models.FeatureBenefit{},
|
|
&models.Promotion{},
|
|
&models.TierLimits{},
|
|
|
|
// Onboarding email tracking
|
|
&models.OnboardingEmail{},
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
// Add unique constraint for onboarding emails (one email type per user)
|
|
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_emails_user_type ON onboarding_emails(user_id, email_type)`)
|
|
|
|
// Run GoAdmin migrations
|
|
if err := migrateGoAdmin(); err != nil {
|
|
return fmt.Errorf("failed to run GoAdmin migrations: %w", err)
|
|
}
|
|
|
|
log.Info().Msg("Database migrations completed successfully")
|
|
return nil
|
|
}
|
|
|
|
// migrateGoAdmin creates GoAdmin required tables
|
|
func migrateGoAdmin() error {
|
|
log.Info().Msg("Running GoAdmin migrations...")
|
|
|
|
// GoAdmin session table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_session (
|
|
id SERIAL PRIMARY KEY,
|
|
sid VARCHAR(50) NOT NULL DEFAULT '',
|
|
"values" TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_session_sid ON goadmin_session(sid)`)
|
|
|
|
// GoAdmin users table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_users (
|
|
id SERIAL PRIMARY KEY,
|
|
username VARCHAR(100) NOT NULL DEFAULT '',
|
|
password VARCHAR(100) NOT NULL DEFAULT '',
|
|
name VARCHAR(100) NOT NULL DEFAULT '',
|
|
avatar VARCHAR(255) DEFAULT '',
|
|
remember_token VARCHAR(100) DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_goadmin_users_username ON goadmin_users(username)`)
|
|
|
|
// GoAdmin roles table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_roles (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(50) NOT NULL DEFAULT '',
|
|
slug VARCHAR(50) NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_goadmin_roles_slug ON goadmin_roles(slug)`)
|
|
|
|
// GoAdmin permissions table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_permissions (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(50) NOT NULL DEFAULT '',
|
|
slug VARCHAR(50) NOT NULL DEFAULT '',
|
|
http_method VARCHAR(255) DEFAULT '',
|
|
http_path TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_goadmin_permissions_slug ON goadmin_permissions(slug)`)
|
|
|
|
// GoAdmin role_users table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_role_users (
|
|
id SERIAL PRIMARY KEY,
|
|
role_id INT NOT NULL,
|
|
user_id INT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_role_users_role_id ON goadmin_role_users(role_id)`)
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_role_users_user_id ON goadmin_role_users(user_id)`)
|
|
|
|
// GoAdmin user_permissions table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_user_permissions (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
permission_id INT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_user_permissions_user_id ON goadmin_user_permissions(user_id)`)
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_user_permissions_permission_id ON goadmin_user_permissions(permission_id)`)
|
|
|
|
// GoAdmin role_permissions table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_role_permissions (
|
|
id SERIAL PRIMARY KEY,
|
|
role_id INT NOT NULL,
|
|
permission_id INT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_role_permissions_role_id ON goadmin_role_permissions(role_id)`)
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_role_permissions_permission_id ON goadmin_role_permissions(permission_id)`)
|
|
|
|
// GoAdmin menu table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_menu (
|
|
id SERIAL PRIMARY KEY,
|
|
parent_id INT NOT NULL DEFAULT 0,
|
|
type INT NOT NULL DEFAULT 0,
|
|
"order" INT NOT NULL DEFAULT 0,
|
|
title VARCHAR(50) NOT NULL DEFAULT '',
|
|
icon VARCHAR(50) NOT NULL DEFAULT '',
|
|
uri VARCHAR(3000) NOT NULL DEFAULT '',
|
|
header VARCHAR(150) DEFAULT '',
|
|
plugin_name VARCHAR(150) NOT NULL DEFAULT '',
|
|
uuid VARCHAR(150) DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_menu_parent_id ON goadmin_menu(parent_id)`)
|
|
|
|
// GoAdmin role_menu table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_role_menu (
|
|
id SERIAL PRIMARY KEY,
|
|
role_id INT NOT NULL,
|
|
menu_id INT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_role_menu_role_id ON goadmin_role_menu(role_id)`)
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_role_menu_menu_id ON goadmin_role_menu(menu_id)`)
|
|
|
|
// GoAdmin operation_log table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_operation_log (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
path VARCHAR(255) NOT NULL DEFAULT '',
|
|
method VARCHAR(10) NOT NULL DEFAULT '',
|
|
ip VARCHAR(15) NOT NULL DEFAULT '',
|
|
input TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_operation_log_user_id ON goadmin_operation_log(user_id)`)
|
|
|
|
// GoAdmin site table
|
|
if err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS goadmin_site (
|
|
id SERIAL PRIMARY KEY,
|
|
key VARCHAR(100) NOT NULL DEFAULT '',
|
|
value TEXT NOT NULL,
|
|
description VARCHAR(3000) DEFAULT '',
|
|
state INT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec(`CREATE INDEX IF NOT EXISTS idx_goadmin_site_key ON goadmin_site(key)`)
|
|
|
|
// Seed default admin user (password: admin - bcrypt hash)
|
|
db.Exec(`
|
|
INSERT INTO goadmin_users (username, password, name, avatar)
|
|
VALUES ('admin', '$2a$10$t.GCU24EqIWLSl7F51Hdz.IkkgFK.Qa9/BzEc5Bi2C/I2bXf1nJgm', 'Administrator', '')
|
|
ON CONFLICT DO NOTHING
|
|
`)
|
|
// Update existing admin password if it exists with wrong hash
|
|
db.Exec(`
|
|
UPDATE goadmin_users SET password = '$2a$10$t.GCU24EqIWLSl7F51Hdz.IkkgFK.Qa9/BzEc5Bi2C/I2bXf1nJgm'
|
|
WHERE username = 'admin'
|
|
`)
|
|
|
|
// Seed default roles
|
|
db.Exec(`INSERT INTO goadmin_roles (name, slug) VALUES ('Administrator', 'administrator') ON CONFLICT DO NOTHING`)
|
|
db.Exec(`INSERT INTO goadmin_roles (name, slug) VALUES ('Operator', 'operator') ON CONFLICT DO NOTHING`)
|
|
|
|
// Seed default permissions
|
|
db.Exec(`INSERT INTO goadmin_permissions (name, slug, http_method, http_path) VALUES ('All permissions', '*', '', '*') ON CONFLICT DO NOTHING`)
|
|
db.Exec(`INSERT INTO goadmin_permissions (name, slug, http_method, http_path) VALUES ('Dashboard', 'dashboard', 'GET', '/') ON CONFLICT DO NOTHING`)
|
|
|
|
// Assign admin user to administrator role (if not already assigned)
|
|
db.Exec(`
|
|
INSERT INTO goadmin_role_users (role_id, user_id)
|
|
SELECT r.id, u.id FROM goadmin_roles r, goadmin_users u
|
|
WHERE r.slug = 'administrator' AND u.username = 'admin'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM goadmin_role_users ru
|
|
WHERE ru.role_id = r.id AND ru.user_id = u.id
|
|
)
|
|
`)
|
|
|
|
// Assign all permissions to administrator role (if not already assigned)
|
|
db.Exec(`
|
|
INSERT INTO goadmin_role_permissions (role_id, permission_id)
|
|
SELECT r.id, p.id FROM goadmin_roles r, goadmin_permissions p
|
|
WHERE r.slug = 'administrator' AND p.slug = '*'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM goadmin_role_permissions rp
|
|
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
|
)
|
|
`)
|
|
|
|
// Seed default menu items (only if menu is empty)
|
|
var menuCount int64
|
|
db.Raw(`SELECT COUNT(*) FROM goadmin_menu`).Scan(&menuCount)
|
|
if menuCount == 0 {
|
|
db.Exec(`
|
|
INSERT INTO goadmin_menu (parent_id, type, "order", title, icon, uri, plugin_name) VALUES
|
|
(0, 1, 1, 'Dashboard', 'fa-bar-chart', '/', ''),
|
|
(0, 1, 2, 'Admin', 'fa-tasks', '', ''),
|
|
(2, 1, 1, 'Users', 'fa-users', '/info/goadmin_users', ''),
|
|
(2, 1, 2, 'Roles', 'fa-user', '/info/goadmin_roles', ''),
|
|
(2, 1, 3, 'Permissions', 'fa-ban', '/info/goadmin_permissions', ''),
|
|
(2, 1, 4, 'Menu', 'fa-bars', '/menu', ''),
|
|
(2, 1, 5, 'Operation Log', 'fa-history', '/info/goadmin_operation_log', ''),
|
|
(0, 1, 3, 'MyCrib', 'fa-home', '', ''),
|
|
(8, 1, 1, 'Users', 'fa-user', '/info/users', ''),
|
|
(8, 1, 2, 'Residences', 'fa-building', '/info/residences', ''),
|
|
(8, 1, 3, 'Tasks', 'fa-tasks', '/info/tasks', ''),
|
|
(8, 1, 4, 'Contractors', 'fa-wrench', '/info/contractors', ''),
|
|
(8, 1, 5, 'Documents', 'fa-file', '/info/documents', ''),
|
|
(8, 1, 6, 'Notifications', 'fa-bell', '/info/notifications', '')
|
|
`)
|
|
|
|
// Assign all menus to administrator role
|
|
db.Exec(`
|
|
INSERT INTO goadmin_role_menu (role_id, menu_id)
|
|
SELECT r.id, m.id FROM goadmin_roles r, goadmin_menu m
|
|
WHERE r.slug = 'administrator'
|
|
`)
|
|
}
|
|
|
|
log.Info().Msg("GoAdmin migrations completed")
|
|
|
|
// Seed default Next.js admin user (email: admin@mycrib.com, password: admin123)
|
|
// bcrypt hash for "admin123": $2a$10$t5hGjdXQLxr9Z0193qx.Tef6hd1vYI3JvrfX/piKx2qS9UvQ41I9O
|
|
var adminCount int64
|
|
db.Raw(`SELECT COUNT(*) FROM admin_users WHERE email = 'admin@mycrib.com'`).Scan(&adminCount)
|
|
if adminCount == 0 {
|
|
log.Info().Msg("Seeding default admin user for Next.js admin panel...")
|
|
db.Exec(`
|
|
INSERT INTO admin_users (email, password, first_name, last_name, role, is_active, created_at, updated_at)
|
|
VALUES ('admin@mycrib.com', '$2a$10$t5hGjdXQLxr9Z0193qx.Tef6hd1vYI3JvrfX/piKx2qS9UvQ41I9O', 'Admin', 'User', 'super_admin', true, NOW(), NOW())
|
|
`)
|
|
log.Info().Msg("Default admin user created: admin@mycrib.com / admin123")
|
|
} else {
|
|
// Update existing admin password if needed
|
|
db.Exec(`
|
|
UPDATE admin_users SET password = '$2a$10$t5hGjdXQLxr9Z0193qx.Tef6hd1vYI3JvrfX/piKx2qS9UvQ41I9O'
|
|
WHERE email = 'admin@mycrib.com'
|
|
`)
|
|
log.Info().Msg("Updated admin@mycrib.com password to admin123")
|
|
}
|
|
|
|
return nil
|
|
}
|