Skip to content

Building Your First Feature

This tutorial walks you through adding a complete feature to the Wippidu app, touching every layer of the stack. You will learn the patterns used across the codebase by following a realistic example.

What we'll build: A simple "Feedback" endpoint where parents can submit feedback about the app.

Step 1: Add the model

Create or extend a GORM model in internal/model/. Models define the database schema.

// internal/model/feedback.go
package model

import "gorm.io/gorm"

type Feedback struct {
    gorm.Model
    UserID  uint   `gorm:"not null"`
    User    User   `gorm:"foreignKey:UserID"`
    Subject string `gorm:"size:200;not null"`
    Body    string `gorm:"type:text;not null"`
    Rating  int    `gorm:"default:0"`
}

Register it in internal/model/db.go inside AutoMigrate():

db.AutoMigrate(
    // ... existing models ...
    &Feedback{},
)

Step 2: Add the controller

Create a controller in internal/controller/. Follow the dual-response pattern:

// internal/controller/feedback.go
package controller

import (
    "net/http"
    "github.com/gin-gonic/gin"
    "your-module/internal/model"
)

func FeedbackForm(c *gin.Context) {
    isJSON := shouldReturnJSON(c)
    user := c.MustGet("User").(*model.User)

    if isJSON {
        c.JSON(http.StatusOK, gin.H{
            "success": true,
            "data":    gin.H{"user_id": user.ID},
        })
        return
    }

    c.HTML(http.StatusOK, "feedback.html", gin.H{
        "title": "Feedback",
        "user":  user,
        "lang":  c.GetString("lang"),
    })
}

func FeedbackSubmit(c *gin.Context) {
    isJSON := shouldReturnJSON(c)
    user := c.MustGet("User").(*model.User)

    subject := c.PostForm("subject")
    body := c.PostForm("body")

    if subject == "" || body == "" {
        if isJSON {
            c.JSON(http.StatusBadRequest, gin.H{
                "success": false,
                "error": gin.H{
                    "code":    "INVALID_INPUT",
                    "message": "Subject and body are required",
                },
            })
            return
        }
        c.HTML(http.StatusBadRequest, "feedback.html", gin.H{
            "title": "Feedback",
            "user":  user,
            "lang":  c.GetString("lang"),
            "error": "Subject and body are required",
        })
        return
    }

    feedback := model.Feedback{
        UserID:  user.ID,
        Subject: subject,
        Body:    body,
    }
    if err := model.DB.Create(&feedback).Error; err != nil {
        // handle error...
        return
    }

    if isJSON {
        c.JSON(http.StatusOK, gin.H{
            "success": true,
            "data":    gin.H{"id": feedback.ID},
        })
        return
    }

    c.HTML(http.StatusOK, "feedback.html", gin.H{
        "title":   "Feedback",
        "user":    user,
        "lang":    c.GetString("lang"),
        "success": true,
    })
}

Step 3: Register routes

Add routes in internal/route/auth.go:

// In MainRoutes() — HTML routes
auth.GET("/:lang/feedback", controller.FeedbackForm)
auth.POST("/:lang/feedback", controller.FeedbackSubmit)

// In APIRoutes() — JSON API routes
api.GET("/feedback", controller.FeedbackForm)
api.POST("/feedback", controller.FeedbackSubmit)

Step 4: Add i18n translations

Add keys to both internal/i18n/locales/en.json and de.json:

"feedback.title": {
    "other": "Feedback"
},
"feedback.subject": {
    "other": "Subject"
},
"feedback.body": {
    "other": "Your feedback"
},
"feedback.submit": {
    "other": "Submit"
},
"feedback.success": {
    "other": "Thank you for your feedback!"
}

Step 5: Create the template

Create cmd/app-server/templates/pages/feedback.html:

{{ template "layout/head" . }}

<h1>{{ t .lang "feedback.title" }}</h1>

{{ if .success }}
    <div class="alert alert-success">
        {{ t .lang "feedback.success" }}
    </div>
{{ end }}

{{ if .error }}
    <div class="alert alert-error">{{ .error }}</div>
{{ end }}

<form method="POST" action="/{{ .lang }}/feedback">
    <div class="form-group">
        <label for="subject">{{ t .lang "feedback.subject" }}</label>
        <input type="text" id="subject" name="subject" required>
    </div>
    <div class="form-group">
        <label for="body">{{ t .lang "feedback.body" }}</label>
        <textarea id="body" name="body" rows="5" required></textarea>
    </div>
    <button type="submit" class="btn btn-primary">
        {{ t .lang "feedback.submit" }}
    </button>
</form>

{{ template "layout/foot" . }}

Step 6: Write tests

Create internal/controller/feedback_test.go:

package controller

import (
    "net/http"
    "net/http/httptest"
    "net/url"
    "strings"
    "testing"

    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "your-module/internal/model"
)

func TestFeedbackSubmit_Success(t *testing.T) {
    db := SetupTestDBWithModels(t)
    model.DB = db

    user := CreateTestUser(t, db, "parent@example.com", "Parent")

    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)

    form := url.Values{}
    form.Add("subject", "Great app")
    form.Add("body", "I love this app!")

    req, _ := http.NewRequest(http.MethodPost, "/api/v1/feedback",
        strings.NewReader(form.Encode()))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    c.Request = req
    c.Set("User", user)

    FeedbackSubmit(c)

    assert.Equal(t, http.StatusOK, w.Code)

    // Verify database
    var feedback model.Feedback
    err := db.First(&feedback).Error
    require.NoError(t, err)
    assert.Equal(t, "Great app", feedback.Subject)
    assert.Equal(t, user.ID, feedback.UserID)
}

func TestFeedbackSubmit_MissingFields(t *testing.T) {
    db := SetupTestDBWithModels(t)
    model.DB = db

    user := CreateTestUser(t, db, "parent@example.com", "Parent")

    gin.SetMode(gin.TestMode)
    w := httptest.NewRecorder()
    c, _ := gin.CreateTestContext(w)

    form := url.Values{}
    form.Add("subject", "")
    form.Add("body", "")

    req, _ := http.NewRequest(http.MethodPost, "/api/v1/feedback",
        strings.NewReader(form.Encode()))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    c.Request = req
    c.Set("User", user)

    FeedbackSubmit(c)

    assert.Equal(t, http.StatusBadRequest, w.Code)
}

Run the tests:

cd backend
go test ./internal/controller -run Feedback -v

Summary

Every new feature in Wippidu follows this pattern:

  1. Model — Define the data structure in internal/model/
  2. Controller — Handle requests with shouldReturnJSON() dual-response pattern
  3. Routes — Register in MainRoutes() (HTML) and APIRoutes() (JSON)
  4. i18n — Add translation keys to de.json and en.json
  5. Template — Create the HTML template in templates/pages/
  6. Tests — Write tests covering success, validation, and authorization

Next steps