chore: add onboarding flow — capture user name/department, inject into CSV/PDF reports

This commit is contained in:
Claus Lohmar 2026-06-17 12:23:21 +00:00
parent 8759b31e47
commit 4bc9fe52ae
6 changed files with 234 additions and 14 deletions

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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, `<div id="onboarding-error" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div>`,
template.HTMLEscapeString(message))
}

View file

@ -47,6 +47,14 @@ func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
return
}
// Redirect to onboarding if the user hasn't completed it yet.
user, _ := database.GetUserByID(h.DB, userID)
if user != nil && !user.Onboarded {
w.Header().Set("HX-Redirect", "/onboarding")
w.WriteHeader(http.StatusOK)
return
}
events, err := database.GetEventsByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",

View file

@ -130,13 +130,22 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
return
}
// Fetch user info for report personalisation.
reportUser, _ := database.GetUserByID(h.DB, userID)
userName := ""
userDept := ""
if reportUser != nil {
userName = reportUser.Name
userDept = reportUser.Department
}
// 5. Generate the report in the requested format.
var reportAttachment *email.Attachment
switch format {
case "csv":
reportAttachment, err = generateCSV(event.Name, expenses)
reportAttachment, err = generateCSV(event.Name, expenses, userName, userDept)
case "pdf":
reportAttachment, err = generatePDF(event.Name, expenses)
reportAttachment, err = generatePDF(event.Name, expenses, userName, userDept)
}
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
@ -241,12 +250,21 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
return
}
// Fetch user info for report personalisation.
repUser, _ := database.GetUserByID(h.DB, userID)
uName := ""
uDept := ""
if repUser != nil {
uName = repUser.Name
uDept = repUser.Department
}
// Generate the report.
var reportAtt *email.Attachment
if format == "csv" {
reportAtt, err = generateCSV(event.Name, expenses)
reportAtt, err = generateCSV(event.Name, expenses, uName, uDept)
} else {
reportAtt, err = generatePDF(event.Name, expenses)
reportAtt, err = generatePDF(event.Name, expenses, uName, uDept)
}
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: generate %s: %v",
@ -505,10 +523,20 @@ func addToZip(zw *zip.Writer, name string, data []byte) {
// The CSV includes a header row and one data row per expense.
// If the expenses use a different currency than the base currency, both
// original and converted amounts are included.
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
func generateCSV(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
// Write user metadata row (prepared by).
if userName != "" {
metaLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
writer.Write([]string{metaLine})
writer.Write([]string{""}) // blank separator
}
// Determine if we need conversion columns.
hasConversion := false
for _, exp := range expenses {
@ -588,14 +616,25 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
// The PDF contains a title row, a header row, and one data row per expense.
// If the expenses use a different currency than the base currency, both
// original and converted amounts are included.
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
func generatePDF(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// Title: "Expense Report: <event name>"
pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, "Expense Report: "+eventName)
pdf.Ln(15)
pdf.Ln(8)
// User info block.
if userName != "" {
pdf.SetFont("Helvetica", "", 9)
infoLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
infoLine += fmt.Sprintf(" | Department: %s", userDept)
}
pdf.Cell(0, 6, infoLine)
pdf.Ln(10)
}
// Determine if we need conversion columns.
hasConversion := false

View file

@ -210,6 +210,8 @@ func main() {
// Events.
r.Get("/dashboard", eventHandler.Dashboard)
r.Get("/onboarding", authHandler.OnboardingPage)
r.Post("/onboarding", authHandler.SaveOnboarding)
r.Post("/events", eventHandler.CreateEvent)
r.Put("/events/{id}", eventHandler.UpdateEvent)
r.Get("/events/{id}/edit", eventHandler.EditEvent)

42
templates/onboarding.html Normal file
View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<title>Welcome - ReceiptNext</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=4">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<h1 class="app-title" style="font-size: 1.1rem;">Welcome to ReceiptNext</h1>
</header>
<main class="main-content">
<p style="color: var(--color-text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
Let's set up your profile. This information will appear on your expense reports.
</p>
<div id="onboarding-error"></div>
<form hx-post="/onboarding" hx-target="#onboarding-error" hx-swap="innerHTML">
<div class="form-group">
<label class="form-label" for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="Your name as it should appear on reports"
required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="department">Department / Employee ID</label>
<input type="text" id="department" name="department" placeholder="e.g. Finance, ENG-1234">
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Save & Continue
</button>
</form>
</main>
</div>
</body>
</html>