+ Let's set up your profile. This information will appear on your expense reports. +
+ + + + +diff --git a/internal/database/db.go b/internal/database/db.go index 00fbd1f..ec1eafd 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -21,9 +21,12 @@ var DB *sql.DB // User represents a row in the users table. type User struct { - ID string - Email string - CreatedAt string + ID string + Email string + Name string + Department string + Onboarded bool + CreatedAt string } // OTP represents a row in the auth_otps table. @@ -95,6 +98,12 @@ func Init() (*sql.DB, error) { return nil, err } + // Run schema migrations for existing databases. + if err = migrateTables(DB); err != nil { + log.Printf("ERROR [%s] database: migration failed: %v", time.Now().Format(time.RFC3339), err) + return nil, err + } + log.Printf("INFO [%s] database: initialized successfully", time.Now().Format(time.RFC3339)) return DB, nil } @@ -105,6 +114,9 @@ func createTables(db *sql.DB) error { `CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + department TEXT NOT NULL DEFAULT '', + onboarded INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS auth_otps ( @@ -155,6 +167,22 @@ func createTables(db *sql.DB) error { return nil } +// migrateTables applies schema changes to existing databases that were +// created before the current version. Each migration is idempotent — +// errors from ALTER TABLE (e.g. column already exists) are ignored. +func migrateTables(db *sql.DB) error { + migrations := []string{ + "ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''", + "ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''", + "ALTER TABLE users ADD COLUMN onboarded INTEGER NOT NULL DEFAULT 0", + } + + for _, stmt := range migrations { + db.Exec(stmt) // ignore errors — columns may already exist + } + return nil +} + // --------------------------------------------------------------------------- // User queries // --------------------------------------------------------------------------- @@ -174,9 +202,9 @@ func CreateUser(db *sql.DB, id, email string) error { // GetUserByEmail returns the user with the given email, or nil if not found. func GetUserByEmail(db *sql.DB, email string) (*User, error) { - row := db.QueryRow("SELECT id, email, created_at FROM users WHERE email = ?", email) + row := db.QueryRow("SELECT id, email, name, department, onboarded, created_at FROM users WHERE email = ?", email) u := &User{} - if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Department, &u.Onboarded, &u.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } @@ -187,6 +215,34 @@ func GetUserByEmail(db *sql.DB, email string) (*User, error) { return u, nil } +// GetUserByID returns the user with the given ID, or nil if not found. +func GetUserByID(db *sql.DB, id string) (*User, error) { + row := db.QueryRow("SELECT id, email, name, department, onboarded, created_at FROM users WHERE id = ?", id) + u := &User{} + if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Department, &u.Onboarded, &u.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetUserByID(%s): %v", + time.Now().Format(time.RFC3339), id, err) + return nil, err + } + return u, nil +} + +// UpdateUserOnboarding saves the user's name and department and marks them as onboarded. +func UpdateUserOnboarding(db *sql.DB, userID, name, department string) error { + _, err := db.Exec( + "UPDATE users SET name = ?, department = ?, onboarded = 1 WHERE id = ?", + name, department, userID, + ) + if err != nil { + log.Printf("ERROR [%s] database: UpdateUserOnboarding(%s): %v", + time.Now().Format(time.RFC3339), userID, err) + } + return err +} + // --------------------------------------------------------------------------- // OTP queries // --------------------------------------------------------------------------- diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 844fe17..cf5a8f7 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -215,8 +215,12 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) { Expires: time.Now().Add(24 * time.Hour), }) - // Redirect to the dashboard via HTMX. - w.Header().Set("HX-Redirect", "/dashboard") + // Redirect to the appropriate page — onboarding if first login, dashboard otherwise. + if user.Onboarded { + w.Header().Set("HX-Redirect", "/dashboard") + } else { + w.Header().Set("HX-Redirect", "/onboarding") + } w.WriteHeader(http.StatusOK) } @@ -311,3 +315,72 @@ func collectOTP(r *http.Request) string { } return code } + +// --------------------------------------------------------------------------- +// Onboarding handlers +// --------------------------------------------------------------------------- + +// OnboardingPage renders the onboarding form that captures the user's name +// and department for report personalisation. Only shown on first login. +func (h *AuthHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if userID == "" { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusUnauthorized) + return + } + + // If already onboarded, redirect to dashboard. + user, _ := database.GetUserByID(h.DB, userID) + if user != nil && user.Onboarded { + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) + return + } + + tmpl := getTemplate("onboarding.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, nil); err != nil { + log.Printf("ERROR [%s] handlers: OnboardingPage: execute template: %v", time.Now().Format(time.RFC3339), err) + } +} + +// SaveOnboarding saves the user's name and department and marks onboarding +// as complete, then redirects to the dashboard. +func (h *AuthHandler) SaveOnboarding(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if userID == "" { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err := r.ParseForm(); err != nil { + renderOnboardingError(w, "Cannot parse form data.") + return + } + + name := strings.TrimSpace(r.FormValue("name")) + department := strings.TrimSpace(r.FormValue("department")) + if name == "" { + renderOnboardingError(w, "Name is required.") + return + } + if department == "" { + department = "-" + } + + if err := database.UpdateUserOnboarding(h.DB, userID, name, department); err != nil { + renderOnboardingError(w, "Failed to save. Please try again.") + return + } + + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) +} + +func renderOnboardingError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
+ Let's set up your profile. This information will appear on your expense reports. +
+ + + + +