chore: add onboarding flow — capture user name/department, inject into CSV/PDF reports
This commit is contained in:
parent
8759b31e47
commit
4bc9fe52ae
6 changed files with 234 additions and 14 deletions
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -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
42
templates/onboarding.html
Normal 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>
|
||||
Loading…
Reference in a new issue