Skip to content

Latest commit

 

History

History
818 lines (674 loc) · 22.3 KB

File metadata and controls

818 lines (674 loc) · 22.3 KB

Example Module: Notes

A step-by-step walkthrough for adding a complete CRUD feature to Golid. Follow this to build your first module in ~20 minutes.

None of these files exist in the repo — this is a reference spec. Copy the code, adjust the names, and you have a working module.


Overview

We'll build a "notes" module: authenticated users can create, list, view, update, and delete personal notes. This touches every layer of the stack:

Migration → Service → Handler → wire → API client → Frontend route → Seed data

1. Migration

Create backend/migrations/000006_notes.up.sql:

CREATE TABLE notes (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title       TEXT NOT NULL,
    content     TEXT NOT NULL DEFAULT '',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_notes_user_id ON notes(user_id);
CREATE TRIGGER set_notes_updated_at
    BEFORE UPDATE ON notes
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();

Create backend/migrations/000006_notes.down.sql:

DROP TRIGGER IF EXISTS set_notes_updated_at ON notes;
DROP TABLE IF EXISTS notes;

Run: migrate -path migrations -database "$DATABASE_URL" up


2. Service

Create backend/internal/service/note/note.go:

package note

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/golid-ai/golid/backend/internal/apperror"
	"github.com/golid-ai/golid/backend/internal/pagination"
)

type NoteService struct {
	pool              *pgxpool.Pool
	paginationDefault int
	paginationMax     int
}

func NewNoteService(pool *pgxpool.Pool, paginationDefault, paginationMax int) *NoteService {
	return &NoteService{pool: pool, paginationDefault: paginationDefault, paginationMax: paginationMax}
}

// Input/output types — defined in the service file, not a separate models file.

type CreateNoteInput struct {
	UserID  string
	Title   string
	Content string
}

type UpdateNoteInput struct {
	NoteID  string
	UserID  string
	Title   string
	Content string
}

type NoteDetail struct {
	ID        string `json:"id"`
	Title     string `json:"title"`
	Content   string `json:"content"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
}

type NoteListResult struct {
	Notes      []NoteDetail `json:"notes"`
	Total      int          `json:"total"`
	Page       int          `json:"page"`
	PerPage    int          `json:"per_page"`
	TotalPages int          `json:"total_pages"`
}

// Create inserts a new note.
func (s *NoteService) Create(ctx context.Context, input *CreateNoteInput) (*NoteDetail, error) {
	var note NoteDetail
	var createdAt, updatedAt time.Time

	err := s.pool.QueryRow(ctx,
		`INSERT INTO notes (user_id, title, content)
		 VALUES ($1, $2, $3)
		 RETURNING id, title, content, created_at, updated_at`,
		input.UserID, input.Title, input.Content,
	).Scan(&note.ID, &note.Title, &note.Content, &createdAt, &updatedAt)

	if err != nil {
		return nil, apperror.Internal(fmt.Errorf("create note: %w", err))
	}

	note.CreatedAt = createdAt.Format(time.RFC3339)
	note.UpdatedAt = updatedAt.Format(time.RFC3339)
	return &note, nil
}

// List returns paginated notes for a user.
func (s *NoteService) List(ctx context.Context, userID string, page, perPage int, search string) (*NoteListResult, error) {
	page, perPage = pagination.NormalizePagination(page, perPage, s.paginationDefault, s.paginationMax)
	offset := (page - 1) * perPage

	// Count total (with optional search filter)
	var total int
	if search != "" {
		err := s.pool.QueryRow(ctx,
			`SELECT COUNT(*) FROM notes WHERE user_id = $1 AND title ILIKE '%' || $2 || '%'`,
			userID, search,
		).Scan(&total)
		if err != nil {
			return nil, apperror.Internal(fmt.Errorf("count notes: %w", err))
		}
	} else {
		err := s.pool.QueryRow(ctx,
			`SELECT COUNT(*) FROM notes WHERE user_id = $1`, userID,
		).Scan(&total)
		if err != nil {
			return nil, apperror.Internal(fmt.Errorf("count notes: %w", err))
		}
	}

	// Fetch page (with optional search filter)
	var query string
	var args []any
	if search != "" {
		query = `SELECT id, title, content, created_at, updated_at
		 FROM notes WHERE user_id = $1 AND title ILIKE '%' || $2 || '%'
		 ORDER BY created_at DESC
		 LIMIT $3 OFFSET $4`
		args = []any{userID, search, perPage, offset}
	} else {
		query = `SELECT id, title, content, created_at, updated_at
		 FROM notes WHERE user_id = $1
		 ORDER BY created_at DESC
		 LIMIT $2 OFFSET $3`
		args = []any{userID, perPage, offset}
	}

	rows, err := s.pool.Query(ctx, query, args...)
	if err != nil {
		return nil, apperror.Internal(fmt.Errorf("list notes: %w", err))
	}
	defer rows.Close()

	var notes []NoteDetail
	for rows.Next() {
		var n NoteDetail
		var createdAt, updatedAt time.Time
		if err := rows.Scan(&n.ID, &n.Title, &n.Content, &createdAt, &updatedAt); err != nil {
			return nil, apperror.Internal(fmt.Errorf("scan note: %w", err))
		}
		n.CreatedAt = createdAt.Format(time.RFC3339)
		n.UpdatedAt = updatedAt.Format(time.RFC3339)
		notes = append(notes, n)
	}

	if err := rows.Err(); err != nil {
		return nil, apperror.Internal(fmt.Errorf("iterate notes: %w", err))
	}

	if notes == nil {
		notes = []NoteDetail{}
	}

	totalPages := (total + perPage - 1) / perPage
	return &NoteListResult{
		Notes:      notes,
		Total:      total,
		Page:       page,
		PerPage:    perPage,
		TotalPages: totalPages,
	}, nil
}

// GetByID returns a single note, verifying ownership.
func (s *NoteService) GetByID(ctx context.Context, noteID, userID string) (*NoteDetail, error) {
	var note NoteDetail
	var createdAt, updatedAt time.Time

	err := s.pool.QueryRow(ctx,
		`SELECT id, title, content, created_at, updated_at
		 FROM notes WHERE id = $1 AND user_id = $2`,
		noteID, userID,
	).Scan(&note.ID, &note.Title, &note.Content, &createdAt, &updatedAt)

	if errors.Is(err, pgx.ErrNoRows) {
		return nil, apperror.NotFound("Note not found")
	}
	if err != nil {
		return nil, apperror.Internal(fmt.Errorf("get note: %w", err))
	}

	note.CreatedAt = createdAt.Format(time.RFC3339)
	note.UpdatedAt = updatedAt.Format(time.RFC3339)
	return &note, nil
}

// Update modifies a note, verifying ownership.
func (s *NoteService) Update(ctx context.Context, input *UpdateNoteInput) (*NoteDetail, error) {
	var note NoteDetail
	var createdAt, updatedAt time.Time

	err := s.pool.QueryRow(ctx,
		`UPDATE notes SET title = $1, content = $2
		 WHERE id = $3 AND user_id = $4
		 RETURNING id, title, content, created_at, updated_at`,
		input.Title, input.Content, input.NoteID, input.UserID,
	).Scan(&note.ID, &note.Title, &note.Content, &createdAt, &updatedAt)

	if errors.Is(err, pgx.ErrNoRows) {
		return nil, apperror.NotFound("Note not found")
	}
	if err != nil {
		return nil, apperror.Internal(fmt.Errorf("update note: %w", err))
	}

	note.CreatedAt = createdAt.Format(time.RFC3339)
	note.UpdatedAt = updatedAt.Format(time.RFC3339)
	return &note, nil
}

// Delete removes a note, verifying ownership.
func (s *NoteService) Delete(ctx context.Context, noteID, userID string) error {
	result, err := s.pool.Exec(ctx,
		`DELETE FROM notes WHERE id = $1 AND user_id = $2`,
		noteID, userID,
	)
	if err != nil {
		return apperror.Internal(fmt.Errorf("delete note: %w", err))
	}
	if result.RowsAffected() == 0 {
		return apperror.NotFound("Note not found")
	}
	return nil
}

Key patterns used:

  • Constructor takes *pgxpool.Pool + pagination config
  • Input/output structs defined in the same file
  • Timestamps scanned as time.Time, formatted to RFC3339 strings for JSON
  • Ownership verified in every query (WHERE user_id = $2)
  • apperror for all errors, pgx.ErrNoRows checked explicitly
  • Parameterized queries only ($1, $2, never fmt.Sprintf with values)

3. Handler

Create backend/internal/handler/note.go:

package handler

import (
	"net/http"
	"strings"

	"github.com/labstack/echo/v4"

	"github.com/golid-ai/golid/backend/internal/apperror"
	"github.com/golid-ai/golid/backend/internal/service/note"
)

type NoteHandler struct {
	noteService       noteServicer
	paginationDefault int
	paginationMax     int
}

func NewNoteHandler(noteService *note.NoteService, paginationDefault, paginationMax int) *NoteHandler {
	return &NoteHandler{noteService: noteService, paginationDefault: paginationDefault, paginationMax: paginationMax}
}

type CreateNoteRequest struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}

type UpdateNoteRequest struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}

// Create handles POST /api/v1/notes
func (h *NoteHandler) Create(c echo.Context) error {
	userID, err := requireUserID(c)
	if err != nil {
		return err
	}

	var req CreateNoteRequest
	if err := c.Bind(&req); err != nil {
		return apperror.BadRequest("Invalid request body")
	}

	if err := validateNote(req.Title, req.Content); err != nil {
		return err
	}

	item, err := h.noteService.Create(c.Request().Context(), &note.CreateNoteInput{
		UserID:  userID,
		Title:   req.Title,
		Content: req.Content,
	})
	if err != nil {
		return err
	}

	return c.JSON(http.StatusCreated, item)
}

// List handles GET /api/v1/notes
func (h *NoteHandler) List(c echo.Context) error {
	userID, err := requireUserID(c)
	if err != nil {
		return err
	}

	page, perPage := ParsePagination(c, h.paginationDefault, h.paginationMax)
	search := strings.TrimSpace(c.QueryParam("search"))

	result, err := h.noteService.List(c.Request().Context(), userID, page, perPage, search)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, result)
}

// GetByID handles GET /api/v1/notes/:id
func (h *NoteHandler) GetByID(c echo.Context) error {
	userID, err := requireUserID(c)
	if err != nil {
		return err
	}

	item, err := h.noteService.GetByID(c.Request().Context(), c.Param("id"), userID)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, item)
}

// Update handles PUT /api/v1/notes/:id
func (h *NoteHandler) Update(c echo.Context) error {
	userID, err := requireUserID(c)
	if err != nil {
		return err
	}

	var req UpdateNoteRequest
	if err := c.Bind(&req); err != nil {
		return apperror.BadRequest("Invalid request body")
	}

	if err := validateNote(req.Title, req.Content); err != nil {
		return err
	}

	item, err := h.noteService.Update(c.Request().Context(), &note.UpdateNoteInput{
		NoteID:  c.Param("id"),
		UserID:  userID,
		Title:   req.Title,
		Content: req.Content,
	})
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, item)
}

// Delete handles DELETE /api/v1/notes/:id
func (h *NoteHandler) Delete(c echo.Context) error {
	userID, err := requireUserID(c)
	if err != nil {
		return err
	}

	if err := h.noteService.Delete(c.Request().Context(), c.Param("id"), userID); err != nil {
		return err
	}

	return c.JSON(http.StatusOK, map[string]string{"message": "Note deleted"})
}

func validateNote(title, content string) error {
	details := make(map[string]string)

	title = strings.TrimSpace(title)
	if title == "" {
		details["title"] = "Title is required"
	} else if len(title) > 200 {
		details["title"] = "Title must be 200 characters or fewer"
	}

	if len(content) > 50000 {
		details["content"] = "Content must be 50,000 characters or fewer"
	}

	if len(details) > 0 {
		return apperror.Validation("Validation failed", details)
	}
	return nil
}

Key patterns used:

  • Thin handler: validate, delegate to service, return JSON
  • requireUserID(c) at the top of every method
  • Request structs with json tags in the handler file
  • apperror.Validation with field-level details map
  • 201 Created for POST, 200 OK for GET/PUT/DELETE

4. Wire in internal/wire/

make new-module prints the exact wiring checklist. Add these to the wire package (same pattern as scaffold output):

backend/internal/wire/services.go (BuildServices):

noteService := note.NewNoteService(pool, cfg.PaginationDefault, cfg.PaginationMax)
// Add field to Services struct: Note *note.NoteService

backend/internal/wire/handlers.go (BuildHandlers):

noteHandler := handler.NewNoteHandler(svcs.Note, cfg.PaginationDefault, cfg.PaginationMax)
// Add field to Handlers struct: Note *handler.NoteHandler

backend/internal/wire/routes.go (registerProtectedRoutes):

protected.POST("/notes", h.Note.Create)
protected.GET("/notes", h.Note.List)
protected.GET("/notes/:id", h.Note.GetByID)
protected.PUT("/notes/:id", h.Note.Update)
protected.DELETE("/notes/:id", h.Note.Delete)

Add import "github.com/golid-ai/golid/backend/internal/service/note" to services.go when constructing the service.


5. Frontend API client

Add to frontend/src/lib/api.ts:

// Types
export interface Note {
  id: string;
  title: string;
  content: string;
  created_at: string;
  updated_at: string;
}

export interface NoteListResult {
  notes: Note[];
  total: number;
  page: number;
  per_page: number;
  total_pages: number;
}

// API module
export const notesApi = {
  create: (data: { title: string; content: string }) =>
    post<Note>("/notes", data),
  list: (page = 1, perPage = 20, search = "") =>
    get<NoteListResult>(`/notes?page=${page}&per_page=${perPage}${search ? `&search=${encodeURIComponent(search)}` : ""}`),
  getById: (id: string) =>
    get<Note>(`/notes/${id}`),
  update: (id: string, data: { title: string; content: string }) =>
    put<Note>(`/notes/${id}`, data),
  delete: (id: string) =>
    del<{ message: string }>(`/notes/${id}`),
};

6. Frontend route

Create frontend/src/routes/(private)/notes/index.tsx:

import { createSignal, onMount, onCleanup, batch, For, Show, createEffect, on } from "solid-js";
import { Switch, Match } from "solid-js";
import { Title } from "@solidjs/meta";
import { Button, Card, Input, Spinner } from "~/components";
import { DestructiveModal, Pagination } from "~/components";
import { notesApi, getErrorMessage } from "~/lib/api";
import type { Note } from "~/lib/api";
import { toast } from "~/lib/stores";

export default function NotesPage() {
  const [notes, setNotes] = createSignal<Note[]>([]);
  const [loading, setLoading] = createSignal(true);
  const [error, setError] = createSignal("");
  const [page, setPage] = createSignal(1);
  const [totalPages, setTotalPages] = createSignal(1);
  const [total, setTotal] = createSignal(0);
  const [search, setSearch] = createSignal("");

  // Create form
  const [title, setTitle] = createSignal("");
  const [content, setContent] = createSignal("");
  const [saving, setSaving] = createSignal(false);

  // Delete modal
  const [deleteTarget, setDeleteTarget] = createSignal<Note | null>(null);

  let alive = true;
  onCleanup(() => { alive = false; });

  const fetchNotes = async () => {
    setLoading(true);
    setError("");
    try {
      const result = await notesApi.list(page(), 20, search());
      if (!alive) return;
      batch(() => {
        setNotes(result.notes);
        setTotalPages(result.total_pages);
        setTotal(result.total || 0);
        setLoading(false);
      });
    } catch (err) {
      if (!alive) return;
      batch(() => {
        setError(getErrorMessage(err, "Failed to load notes"));
        setLoading(false);
      });
    }
  };

  onMount(() => { fetchNotes(); });

  createEffect(on(() => page(), () => { fetchNotes(); }, { defer: true }));

  let searchTimer: ReturnType<typeof setTimeout>;
  const handleSearch = (value: string) => {
    setSearch(value);
    clearTimeout(searchTimer);
    searchTimer = setTimeout(() => {
      setPage(1);
      fetchNotes();
    }, 300);
  };
  onCleanup(() => clearTimeout(searchTimer));

  const handleCreate = async () => {
    if (!title().trim()) return;
    setSaving(true);
    try {
      await notesApi.create({ title: title(), content: content() });
      if (!alive) return;
      batch(() => {
        setTitle("");
        setContent("");
        setSaving(false);
      });
      toast.success("Note created");
      fetchNotes();
    } catch (err) {
      if (!alive) return;
      setSaving(false);
      toast.error(getErrorMessage(err, "Failed to create note"));
    }
  };

  const handleDelete = async () => {
    const note = deleteTarget();
    if (!note) return;
    try {
      await notesApi.delete(note.id);
      if (!alive) return;
      setDeleteTarget(null);
      toast.success("Note deleted");
      fetchNotes();
    } catch (err) {
      if (!alive) return;
      toast.error(getErrorMessage(err, "Failed to delete note"));
    }
  };

  return (
    <div class="p-6 sm:p-10 max-w-[1400px] mx-auto w-full space-y-8">
      <Title>Notes | Golid</Title>

      <div>
        <h1 class="text-3xl font-bold font-montserrat text-foreground">Notes</h1>
        <p class="text-muted-foreground mt-1">Your personal notes.</p>
      </div>

      {/* Create form */}
      <Card class="p-6 space-y-4">
        <Input
          placeholder="Title"
          value={title()}
          onInput={(e) => setTitle(e.currentTarget.value)}
        />
        <textarea
          class="w-full rounded-lg border border-border bg-background p-3 text-sm min-h-[100px]"
          placeholder="Content (optional)"
          value={content()}
          onInput={(e) => setContent(e.currentTarget.value)}
        />
        <Button onClick={handleCreate} disabled={saving() || !title().trim()}>
          {saving() ? "Creating..." : "Create Note"}
        </Button>
      </Card>

      {/* Search */}
      <Input
        placeholder="Search notes..."
        value={search()}
        onInput={(e) => handleSearch(e.currentTarget.value)}
      />

      {/* Notes list — flat Switch/Match, never nested Show */}
      <Switch>
        <Match when={loading()}>
          <div class="flex justify-center py-12">
            <Spinner size="lg" />
          </div>
        </Match>
        <Match when={error()}>
          <Card class="p-6 text-center text-danger">{error()}</Card>
        </Match>
        <Match when={notes().length === 0}>
          <Card class="p-6 text-center text-muted-foreground">
            No notes yet. Create your first one above.
          </Card>
        </Match>
        <Match when={notes().length > 0}>
          <div class="space-y-3">
            <For each={notes()}>
              {(note) => (
                <Card class="p-4 flex items-start justify-between gap-4">
                  <div class="min-w-0">
                    <h3 class="font-semibold text-foreground">{note.title}</h3>
                    <Show when={note.content}>
                      <p class="text-sm text-muted-foreground mt-1 line-clamp-2">
                        {note.content}
                      </p>
                    </Show>
                  </div>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => setDeleteTarget(note)}
                  >
                    Delete
                  </Button>
                </Card>
              )}
            </For>
          </div>
        </Match>
      </Switch>

      <Show when={totalPages() > 1}>
        <Pagination
          page={page()}
          pageSize={20}
          totalItems={total()}
          onPageChange={setPage}
        />
      </Show>

      {/* Delete confirmation — never window.confirm() */}
      <DestructiveModal
        open={!!deleteTarget()}
        onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
        onConfirm={handleDelete}
        title="Delete note?"
        message={`"${deleteTarget()?.title}" will be permanently deleted.`}
        confirmText="Delete"
      />
    </div>
  );
}

Key patterns used:

  • onMount + createSignal + alive guard + batch (never createResource)
  • Switch/Match for content states (never nested Show)
  • DestructiveModal for delete (never window.confirm())
  • Signal-driven modal (deleteTarget signal)
  • Components from barrel import (~/components)
  • Page container: p-6 sm:p-10 max-w-[1400px] mx-auto w-full

7. Update constants

Add the route to frontend/src/lib/constants.ts:

export const PRIVATE_ROUTES = ["/dashboard", "/settings", "/components", "/notes"];

Without this, unauthenticated users hitting /notes will see an API error instead of being redirected to login.


8. Seed data

Add to backend/seeds/dev_seed.sql:

-- Notes for the test user
INSERT INTO notes (id, user_id, title, content, created_at, updated_at)
VALUES
  ('b0000000-0000-0000-0000-000000000001',
   'a0000000-0000-0000-0000-000000000002',
   'Welcome Note',
   'This is your first note. Edit or delete it.',
   NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days'),

  ('b0000000-0000-0000-0000-000000000002',
   'a0000000-0000-0000-0000-000000000002',
   'Project Ideas',
   'Build a todo app. Try the SSE demo. Deploy to Cloud Run.',
   NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day')
ON CONFLICT (id) DO UPDATE SET
  title = EXCLUDED.title,
  content = EXCLUDED.content;

Stable UUIDs (b0...) so re-running the seed is idempotent.


Checklist

After following all 8 steps:

  • cd backend && go build ./... compiles
  • cd backend && go test ./... passes
  • cd frontend && npm run build compiles
  • Login → navigate to /notes → create, view, delete works
  • Unauthenticated /notes redirects to login (PRIVATE_ROUTES)

Next steps

This example covers the basics. For a more complex module, you'd add:

  • Advanced filters — the scaffold generates basic search; add additional filters (date range, status) using the argIdx pattern from go-service.mdc
  • Pagination UI — use the Pagination component with the total_pages response field
  • Detail modal — use a signal-driven modal (activeNote signal) instead of a sub-route
  • Edit form — open a modal with pre-filled fields, call notesApi.update
  • SSE integration — broadcast a "note_created" event from the service, show a toast on the frontend
  • Background jobs — dispatch work via the job queue with goroutine fallback:
    if h.queue.IsConfigured() {
        task, err := queue.NewTask("notes:notify", payload)
        if err != nil {
            logger.Error("failed to create task", slog.String("error", err.Error()))
        } else if err := h.queue.Enqueue(task); err != nil {
            logger.Error("failed to enqueue task", slog.String("error", err.Error()))
        }
    } else {
        go func() { /* inline fallback */ }()
    }
  • Feature flags — gate new behavior behind a flag:
    if s.features.IsEnabled(ctx, "notes_v2") {
        // new logic
    }