Skip to main content

Overview

Handlers in SFLUV follow a consistent pattern:
  1. Extract user context (from JWT middleware)
  2. Parse request body/params
  3. Validate input
  4. Call database layer
  5. Return JSON response or error
Location: backend/handlers/

Handler Pattern

Standard Handler Signature

All handlers implement http.HandlerFunc:
func (a *AppService) HandlerName(w http.ResponseWriter, r *http.Request) {
    // Implementation
}

Example: Get User Profile

backend/handlers/app_user.go
func (a *AppService) GetUserAuthed(w http.ResponseWriter, r *http.Request) {
    // 1. Extract authenticated user from context
    userDid, ok := r.Context().Value("userDid").(string)
    if !ok {
        http.Error(w, "Unauthorized", http.StatusForbidden)
        return
    }
    
    // 2. Call database layer
    user, err := a.db.GetUserByDid(r.Context(), userDid)
    if err != nil {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    // 3. Fetch related data
    wallets, _ := a.db.GetWalletsByUser(r.Context(), userDid)
    locations, _ := a.db.GetLocationsByUser(r.Context(), userDid)
    
    // 4. Build response struct
    response := GetUserResponse{
        User:      user,
        Wallets:   wallets,
        Locations: locations,
        Proposer:  a.db.GetProposerByUserId(r.Context(), userDid),
        Improver:  a.db.GetImproverByUserId(r.Context(), userDid),
    }
    
    // 5. Return JSON
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(response)
}

Request Processing

1. Extracting User Context

Authenticated user DID is injected by AuthMiddleware:
userDid, ok := r.Context().Value("userDid").(string)
if !ok {
    http.Error(w, "Unauthorized", http.StatusForbidden)
    return
}
Role checks (admin, proposer, etc.) happen in middleware. Handlers assume authorization is already validated.

2. Parsing JSON Body

var req CreateWorkflowRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
    http.Error(w, "Invalid request body", http.StatusBadRequest)
    return
}

3. Parsing URL Parameters

Path params (chi):
workflowId := chi.URLParam(r, "workflow_id")
stepId := chi.URLParam(r, "step_id")
Query params:
address := r.URL.Query().Get("address")
page := r.URL.Query().Get("page")

4. Parsing Form Data

err := r.ParseForm()
if err != nil {
    http.Error(w, "Invalid form data", http.StatusBadRequest)
    return
}

wallet := r.FormValue("wallet")
email := r.FormValue("email")

Response Formatting

Success Response (JSON)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)

Created Response (201)

w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
    "id": newWorkflowId,
})

No Content Response (204)

w.WriteHeader(http.StatusNoContent)

Plain Text Response

w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "Success")

Error Handling

Standard HTTP Errors

// 400 Bad Request
http.Error(w, "Invalid workflow ID", http.StatusBadRequest)

// 403 Forbidden
http.Error(w, "Not authorized", http.StatusForbidden)

// 404 Not Found
http.Error(w, "Workflow not found", http.StatusNotFound)

// 500 Internal Server Error
http.Error(w, "Internal server error", http.StatusInternalServerError)

Logging Errors

user, err := a.db.GetUserByDid(r.Context(), userDid)
if err != nil {
    a.logger.Logf("Error fetching user %s: %v", userDid, err)
    http.Error(w, "Internal server error", http.StatusInternalServerError)
    return
}
Never expose internal error details (database errors, stack traces) to clients. Log internally, return generic messages.

Handler Examples

Example 1: Create Resource (POST)

Route: POST /workflows
func (a *AppService) CreateWorkflow(w http.ResponseWriter, r *http.Request) {
    userDid := r.Context().Value("userDid").(string)
    
    // Parse request
    var req CreateWorkflowRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    // Validate input
    if req.Title == "" || req.Description == "" {
        http.Error(w, "Title and description required", http.StatusBadRequest)
        return
    }
    
    // Create workflow
    workflowId := uuid.New().String()
    workflow := &structs.Workflow{
        Id:          workflowId,
        Title:       req.Title,
        Description: req.Description,
        ProposerId:  userDid,
        Status:      "pending",
    }
    
    // Save to database
    err := a.db.CreateWorkflow(r.Context(), workflow)
    if err != nil {
        a.logger.Logf("Error creating workflow: %v", err)
        http.Error(w, "Failed to create workflow", http.StatusInternalServerError)
        return
    }
    
    // Send email notification to admin
    a.notifyAdminNewWorkflow(workflow)
    
    // Return workflow ID
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]string{
        "id": workflowId,
    })
}

Example 2: Update Resource (PUT)

Route: PUT /improvers/primary-rewards-account
func (a *AppService) UpdateImproverPrimaryRewardsAccount(w http.ResponseWriter, r *http.Request) {
    userDid := r.Context().Value("userDid").(string)
    
    // Parse request body
    var req struct {
        Address string `json:"address"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    // Validate wallet address
    if !isValidAddress(req.Address) {
        http.Error(w, "Invalid wallet address", http.StatusBadRequest)
        return
    }
    
    // Update in database
    err := a.db.UpdateImproverPrimaryRewardsAccount(r.Context(), userDid, req.Address)
    if err != nil {
        http.Error(w, "Failed to update", http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusNoContent)
}

Example 3: Get Resource (GET)

Route: GET /workflows/{workflow_id}
func (a *AppService) GetWorkflow(w http.ResponseWriter, r *http.Request) {
    workflowId := chi.URLParam(r, "workflow_id")
    
    // Fetch workflow
    workflow, err := a.db.GetWorkflowById(r.Context(), workflowId)
    if err != nil {
        http.Error(w, "Workflow not found", http.StatusNotFound)
        return
    }
    
    // Fetch related data
    steps, _ := a.db.GetWorkflowSteps(r.Context(), workflowId)
    votes, _ := a.db.GetWorkflowVotes(r.Context(), workflowId)
    
    // Build response
    response := WorkflowDetailResponse{
        Workflow: workflow,
        Steps:    steps,
        Votes:    votes,
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

Example 4: Delete Resource (DELETE)

Route: DELETE /proposers/workflows/{workflow_id}
func (a *AppService) DeleteProposerWorkflow(w http.ResponseWriter, r *http.Request) {
    userDid := r.Context().Value("userDid").(string)
    workflowId := chi.URLParam(r, "workflow_id")
    
    // Verify ownership
    workflow, err := a.db.GetWorkflowById(r.Context(), workflowId)
    if err != nil {
        http.Error(w, "Workflow not found", http.StatusNotFound)
        return
    }
    
    if workflow.ProposerId != userDid {
        http.Error(w, "Not authorized", http.StatusForbidden)
        return
    }
    
    // Only pending workflows can be deleted directly
    if workflow.Status != "pending" {
        http.Error(w, "Cannot delete approved workflow", http.StatusBadRequest)
        return
    }
    
    // Delete from database
    err = a.db.DeleteWorkflow(r.Context(), workflowId)
    if err != nil {
        http.Error(w, "Failed to delete", http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusNoContent)
}

Email Notifications

Mailgun is used for transactional emails. HTML templates are constructed inline.

Sending Email

func (a *AppService) sendEmail(to, subject, body string) error {
    mg := mailgun.NewMailgun(
        os.Getenv("MAILGUN_DOMAIN"),
        os.Getenv("MAILGUN_API_KEY"),
    )
    
    message := mg.NewMessage(
        "[email protected]",
        subject,
        "",  // Plain text (optional)
        to,
    )
    message.SetHtml(body)
    
    _, _, err := mg.Send(context.Background(), message)
    return err
}

Email Template Pattern

func (a *AppService) notifyAdminNewProposer(proposer *structs.Proposer) {
    subject := "New Proposer Request"
    body := fmt.Sprintf(`
        <html>
            <body style="font-family: Arial, sans-serif;">
                <h2>New Proposer Request</h2>
                <p><strong>User:</strong> %s</p>
                <p><strong>Reason:</strong> %s</p>
                <p><a href="%s/admin">Review in Admin Panel</a></p>
            </body>
        </html>
    `, proposer.UserId, proposer.Reason, os.Getenv("APP_BASE_URL"))
    
    adminEmail := os.Getenv("PROPOSER_ADMIN_EMAIL")
    a.sendEmail(adminEmail, subject, body)
}

File Organization

Handlers grouped by feature/role:
handlers/
├── app.go                    # AppService struct
├── app_user.go               # User endpoints
├── app_workflow.go           # Workflow CRUD, voting
├── app_wallet.go             # Wallet management
├── app_location.go           # Location CRUD
├── app_contact.go            # Contact book
├── app_affiliate.go          # Affiliate role requests
├── app_admin.go              # Admin user/role management
├── app_ponder.go             # Ponder subscriptions
├── app_verified_email.go     # Email verification
├── bot.go                    # BotService struct
├── bot_workflow.go           # Bot workflow helpers
├── w9.go                     # W9 compliance
├── unwrap.go                 # Token unwrapping
├── redeemer.go               # Merchant redeemer sync
├── minter.go                 # Minter role sync
├── ponder.go                 # PonderService
├── ponder_transactions.go    # Transaction history
└── affiliate_scheduler.go    # Background payout scheduler

Common Patterns

Role-Specific Data Fetching

// Fetch role-specific data only if user has role
var improver *structs.Improver
if user.IsImprover {
    improver, _ = a.db.GetImproverByUserId(ctx, userDid)
}

Pagination

page := r.URL.Query().Get("page")
limit := r.URL.Query().Get("limit")

pageInt, _ := strconv.Atoi(page)
limitInt, _ := strconv.Atoi(limit)
if limitInt == 0 {
    limitInt = 20  // Default page size
}

workflows, total := a.db.GetWorkflowsPaginated(ctx, pageInt, limitInt)

response := PaginatedResponse{
    Data:       workflows,
    Total:      total,
    Page:       pageInt,
    TotalPages: (total + limitInt - 1) / limitInt,
}

Webhook Validation

func (a *AppService) SubmitW9Webhook(w http.ResponseWriter, r *http.Request) {
    // Verify shared secret
    secret := r.Header.Get("X-W9-Secret")
    if secret == "" {
        secret = r.Header.Get("X-W9-Key")
    }
    
    expectedSecret := os.Getenv("W9_WEBHOOK_SECRET")
    if expectedSecret != "" && secret != expectedSecret {
        http.Error(w, "Invalid secret", http.StatusUnauthorized)
        return
    }
    
    // Process webhook payload
    // ...
}

Testing Handlers

Example Test

func TestCreateWorkflow(t *testing.T) {
    // Setup: Mock database and service
    mockDB := &MockAppDB{}
    service := &AppService{db: mockDB}
    
    // Create request
    body := `{"title":"Test Workflow","description":"Test"}`
    req := httptest.NewRequest("POST", "/workflows", strings.NewReader(body))
    req = req.WithContext(context.WithValue(req.Context(), "userDid", "test-user"))
    
    // Execute handler
    w := httptest.NewRecorder()
    service.CreateWorkflow(w, req)
    
    // Assert response
    assert.Equal(t, http.StatusCreated, w.Code)
    
    var response map[string]string
    json.NewDecoder(w.Body).Decode(&response)
    assert.NotEmpty(t, response["id"])
}

Next Steps

Middleware

Authentication and role-based authorization

Database Layer

How handlers query the database

Build docs developers (and LLMs) love