# Phase 5: Files - Pattern Map

**Mapped:** 2026-05-15
**Files analyzed:** 9 (new/modified)
**Analogs found:** 9 / 9

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/migrations/0005_files.sql` | migration | batch | `backend/migrations/0004_tasks.sql` | exact |
| `backend/internal/db/queries/files.sql` | query | CRUD | `backend/internal/db/queries/tasks.sql` | exact |
| `backend/internal/files/store.go` | service | file-I/O | `backend/internal/files/doc.go` (placeholder) | new (no analog body) |
| `backend/internal/web/handlers_files.go` | controller | request-response | `backend/internal/web/handlers_tasks.go` | exact |
| `backend/internal/web/router.go` | config | request-response | `backend/internal/web/router.go` (self, modify) | exact |
| `backend/templates/tablos/detail.templ` (= `backend/templates/tablos.templ`) | component | request-response | `backend/templates/tablos.templ` (self, modify) | exact |
| `backend/templates/files.templ` | component | request-response | `backend/templates/tasks.templ` | role-match |
| `backend/cmd/web/main.go` | config | request-response | `backend/cmd/web/main.go` (self, modify) | exact |
| `compose.yaml` | config | — | `backend/compose.yaml` (self, modify) | exact |

---

## Pattern Assignments

### `backend/migrations/0005_files.sql` (migration, batch)

**Analog:** `backend/migrations/0004_tasks.sql`

**Full migration structure** (lines 1–26):
```sql
-- migrations/0004_tasks.sql
-- Phase 4: Tasks (Kanban)

-- +goose Up

-- ENUM declaration order must match visual left-to-right column order (Pitfall 6).
CREATE TYPE task_status AS ENUM ('todo', 'in_progress', 'in_review', 'done');

CREATE TABLE tasks (
    id          uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    tablo_id    uuid        NOT NULL REFERENCES tablos(id) ON DELETE CASCADE,
    title       text        NOT NULL,
    description text,
    status      task_status NOT NULL DEFAULT 'todo',
    position    integer     NOT NULL DEFAULT 100,
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX tasks_tablo_id_status_idx ON tasks(tablo_id, status, position);

-- +goose Down
-- Table MUST be dropped before type (Pitfall 3 — type is still referenced by table column).
DROP TABLE IF EXISTS tasks;
DROP TYPE IF EXISTS task_status;
```

**Key conventions to copy:**
- Header comment: `-- migrations/0005_files.sql` + phase description line
- `-- +goose Up` / `-- +goose Down` section markers (required by goose)
- `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` for all new tables
- `tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE` — CASCADE is the pattern; file rows are cleaned when tablo is deleted
- `timestamptz NOT NULL DEFAULT now()` — always timestamptz, never timestamp
- Index comment explains composite key rationale
- `DROP TABLE IF EXISTS` in Down block (no DROP TYPE needed since `tablo_files` has no custom ENUM)

**Deviation for 0005:** No `updated_at` column (files are immutable — upload-once, delete-only). Use `bigint` for `size_bytes` (not `integer`). No custom ENUM type.

---

### `backend/internal/db/queries/files.sql` (query, CRUD)

**Analog:** `backend/internal/db/queries/tasks.sql`

**Full query file** (lines 1–30):
```sql
-- name: ListTasksByTablo :many
SELECT id, tablo_id, title, description, status, position, created_at, updated_at
FROM tasks
WHERE tablo_id = $1
ORDER BY status, position, created_at;

-- name: InsertTask :one
INSERT INTO tasks (tablo_id, title, description, status, position)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tablo_id, title, description, status, position, created_at, updated_at;

-- name: GetTaskByID :one
SELECT id, tablo_id, title, description, status, position, created_at, updated_at
FROM tasks
WHERE id = $1 AND tablo_id = $2;

-- name: DeleteTask :exec
DELETE FROM tasks WHERE id = $1 AND tablo_id = $2;
```

**Conventions to copy:**
- `-- name: QueryName :return_type` annotation on every query (required by sqlc)
- `:one` for INSERT RETURNING + single SELECT; `:many` for list; `:exec` for DELETE
- Always include `tablo_id = $N` in WHERE clauses for ownership (IDOR prevention)
- RETURNING clause lists all columns explicitly (not `RETURNING *`)
- ORDER BY in list queries: `created_at DESC` (newest first) for files

**Files query set needed:** `InsertTabloFile :one`, `ListFilesByTablo :many`, `GetTabloFileByID :one`, `DeleteTabloFile :exec`

---

### `backend/internal/files/store.go` (service, file-I/O)

**Analog:** No body analog in codebase (placeholder only at `backend/internal/files/doc.go`). Use RESEARCH.md Patterns 1–3 exclusively.

**Package declaration** (`doc.go` line 1–2):
```go
// Package files is a Phase 1 placeholder; the upload/storage implementation lands in Phase 5.
package files
```

**Implementation pattern** from RESEARCH.md (Patterns 1–3 — verified against aws-sdk-go-v2 docs):

Store struct and constructor:
```go
type Store struct {
    client *s3.Client
    bucket string
}

func NewStore(ctx context.Context, endpoint, bucket, region, accessKey, secretKey string, usePathStyle bool) (*Store, error) {
    cfg, err := config.LoadDefaultConfig(ctx,
        config.WithRegion(region),
        config.WithCredentialsProvider(
            credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
        ),
    )
    if err != nil {
        return nil, err
    }
    client := s3.NewFromConfig(cfg, func(o *s3.Options) {
        o.BaseEndpoint = aws.String(endpoint)
        o.UsePathStyle = usePathStyle  // true for MinIO, false or omit for R2
    })
    return &Store{client: client, bucket: bucket}, nil
}
```

Upload (sniff + stream pattern):
```go
func (s *Store) Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error) {
    var sniffBuf [512]byte
    n, readErr := io.ReadFull(file, sniffBuf[:])
    // Accept io.ErrUnexpectedEOF — normal for files < 512 bytes (Pitfall 3)
    if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) {
        return "", 0, readErr
    }
    contentType = http.DetectContentType(sniffBuf[:n])
    body := io.MultiReader(bytes.NewReader(sniffBuf[:n]), file)
    // Wrap body in a counting reader before passing to PutObject (Pitfall 8)
    // ...
}
```

PresignDownload:
```go
func (s *Store) PresignDownload(ctx context.Context, key string) (string, error) {
    presignClient := s3.NewPresignClient(s.client)
    req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
        Bucket: aws.String(s.bucket),
        Key:    aws.String(key),
    }, func(o *s3.PresignOptions) {
        o.Expires = 5 * time.Minute
    })
    if err != nil {
        return "", err
    }
    return req.URL, nil
}
```

Delete:
```go
func (s *Store) Delete(ctx context.Context, key string) error {
    _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
        Bucket: aws.String(s.bucket),
        Key:    aws.String(key),
    })
    return err
}
```

**Interface for test injection** (per RESEARCH.md validation architecture):
```go
type FileStorer interface {
    Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error)
    Delete(ctx context.Context, key string) error
    PresignDownload(ctx context.Context, key string) (string, error)
}
```

---

### `backend/internal/web/handlers_files.go` (controller, request-response)

**Analog:** `backend/internal/web/handlers_tasks.go`

**Imports pattern** (lines 1–19 of handlers_tasks.go):
```go
package web

import (
    "errors"
    "log/slog"
    "net/http"

    "backend/internal/auth"
    "backend/internal/db/sqlc"
    "backend/templates"

    "github.com/go-chi/chi/v5"
    "github.com/google/uuid"
    "github.com/gorilla/csrf"
    "github.com/jackc/pgx/v5"
)
```

**FilesDeps struct pattern** (mirrors TasksDeps, lines 21–24 of handlers_tasks.go):
```go
// TasksDeps holds dependencies for all task handlers.
type TasksDeps struct {
    Queries *sqlc.Queries
}
```
Copy this exactly:
```go
// FilesDeps holds dependencies for all file handlers.
type FilesDeps struct {
    Queries     *sqlc.Queries
    Files       FileStorer   // interface — allows stub injection in tests
    MaxUploadMB int          // parsed from MAX_UPLOAD_SIZE_MB env var (default 25)
}
```

**loadOwnedTabloForTask pattern** — copy for `loadOwnedTabloForFile` (lines 44–76):
```go
func loadOwnedTabloForTask(w http.ResponseWriter, r *http.Request, deps TasksDeps) (sqlc.Tablo, sqlc.Task, *auth.User, bool) {
    tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
    if !ok {
        return sqlc.Tablo{}, sqlc.Task{}, nil, false
    }
    taskID, err := uuid.Parse(chi.URLParam(r, "task_id"))
    if err != nil {
        http.NotFound(w, r)
        return sqlc.Tablo{}, sqlc.Task{}, nil, false
    }
    task, err := deps.Queries.GetTaskByID(r.Context(), sqlc.GetTaskByIDParams{
        ID:      taskID,
        TabloID: tablo.ID,
    })
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            http.NotFound(w, r)
            return sqlc.Tablo{}, sqlc.Task{}, nil, false
        }
        slog.Default().Error("tasks: GetTaskByID failed", "id", taskID, "err", err)
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return sqlc.Tablo{}, sqlc.Task{}, nil, false
    }
    return tablo, task, user, true
}
```

**Handler constructor pattern** — all handlers follow this shape (lines 80–98):
```go
func TaskNewFormHandler(deps TasksDeps) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
        if !ok {
            return
        }
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = templates.TaskCreateFormFragment(...).Render(r.Context(), w)
    }
}
```

**HX-Request detection pattern** (lines 150–166 of handlers_tasks.go):
```go
if r.Header.Get("HX-Request") == "true" {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Header().Set("HX-Retarget", "#add-task-slot-"+statusStr)
    w.Header().Set("HX-Reswap", "innerHTML")
    w.WriteHeader(http.StatusUnprocessableEntity)
    _ = templates.TaskCreateFormFragment(...).Render(ctx, w)
    return
}
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
```

**Delete handler pattern** (lines 285–309 of handlers_tasks.go):
```go
func TaskDeleteHandler(deps TasksDeps) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps)
        if !ok {
            return
        }
        if err := deps.Queries.DeleteTask(r.Context(), sqlc.DeleteTaskParams{
            ID:      task.ID,
            TabloID: tablo.ID,
        }); err != nil {
            slog.Default().Error("tasks delete: DeleteTask failed", "id", task.ID, "err", err)
            http.Error(w, "internal server error", http.StatusInternalServerError)
            return
        }
        if r.Header.Get("HX-Request") == "true" {
            w.Header().Set("Content-Type", "text/html; charset=utf-8")
            _ = templates.TaskCardGone(task.ID).Render(r.Context(), w)
            return
        }
        http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
    }
}
```

**FileDeleteHandler deviation:** Delete S3 first (log error, do not abort), always delete DB row. The file-specific ordering is: `deps.Files.Delete(ctx, file.S3Key)` → log if error → `deps.Queries.DeleteTabloFile(...)` → 500 if DB fails. See RESEARCH.md Pattern 6.

**Upload handler additions** not present in tasks.go:
- `r.Body = http.MaxBytesReader(w, r.Body, maxBytes)` — MUST be first line (before ParseMultipartForm)
- `r.ParseMultipartForm(2 << 20)` + `errors.As(err, &(*http.MaxBytesError)(nil))` for size error detection
- `r.FormFile("file")` + `io.ReadFull` sniff + `io.MultiReader` reconstruct
- `uuid.New()` for S3 key: `"files/" + tablo.ID.String() + "/" + fileUUID.String()`

---

### `backend/internal/web/router.go` (config, request-response — modify)

**Analog:** `backend/internal/web/router.go` (self)

**Current NewRouter signature** (line 47):
```go
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
```

**Updated signature** (add `fileDeps FilesDeps` before `csrfKey`):
```go
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
```

**Route registration pattern** (lines 93–106 of router.go — task routes as model):
```go
// Task routes — static segments BEFORE parametric (Pitfall 1).
r.Get("/tablos/{id}/tasks/new", TaskNewFormHandler(taskDeps))
r.Get("/tablos/{id}/tasks/cancel-new", TaskCancelNewHandler(taskDeps))
r.Post("/tablos/{id}/tasks", TaskCreateHandler(taskDeps))
r.Post("/tablos/{id}/tasks/reorder", TaskReorderHandler(taskDeps))
// Parametric task routes — must come after static task segments.
r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps))
r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps))
r.Post("/tablos/{id}/tasks/{task_id}", TaskUpdateHandler(taskDeps))
r.Get("/tablos/{id}/tasks/{task_id}/delete-confirm", TaskDeleteConfirmHandler(taskDeps))
r.Post("/tablos/{id}/tasks/{task_id}/delete", TaskDeleteHandler(taskDeps))
```

**New routes to add** (insert after existing task routes, following same static-before-parametric rule):
```go
// Tab entry points — GET /tablos/{id}/tasks (Tasks tab) must come BEFORE
// /tablos/{id}/tasks/new and other static task sub-routes (chi Pitfall 1).
r.Get("/tablos/{id}/tasks", TabloTasksTabHandler(taskDeps))   // NEW — Tasks tab entry

// File routes — static segments BEFORE parametric (Pitfall 6 in RESEARCH).
r.Get("/tablos/{id}/files", TabloFilesTabHandler(fileDeps))
r.Post("/tablos/{id}/files", FileUploadHandler(fileDeps))
// Parametric file routes — AFTER static file segment.
r.Get("/tablos/{id}/files/{file_id}/download", FileDownloadHandler(fileDeps))
r.Get("/tablos/{id}/files/{file_id}/delete-confirm", FileDeleteConfirmHandler(fileDeps))
r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps))
```

**IMPORTANT:** `r.Get("/tablos/{id}/tasks", ...)` must be registered BEFORE `r.Get("/tablos/{id}/tasks/new", ...)` — insert it at line 96 (before the existing task block).

---

### `backend/templates/tablos.templ` (component, request-response — modify)

**Analog:** `backend/templates/tablos.templ` (self)

**Current TabloDetailPage signature** (line 173):
```go
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task) {
```

**Updated signature** (add files + activeTab trailing params — Pitfall 4 in RESEARCH):
```go
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, files []sqlc.TabloFile, activeTab string) {
```

**All call sites that must be updated:**
- `handlers_tablos.go` line 205: `templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks)` → `templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview")`
- `handlers_tablos.go` line 311: same update
- Any test files calling `TabloDetailPage`

**Current detail page body** (lines 174–191) for reference — the tab bar wraps the existing zones:
```go
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task) {
    @Layout("Tablos — Xtablo", user, csrfToken) {
        <div class="mb-4">
            <a href="/" class="text-sm text-slate-600 hover:underline">&larr; Back to tablos</a>
        </div>
        <div class="tablo-title-zone">
            @TabloTitleDisplay(tablo, csrfToken)
        </div>
        <div class="tablo-desc-zone">
            @TabloDescDisplay(tablo, csrfToken)
        </div>
        <div class="tablo-delete-zone">
            @TabloDeleteButtonFragment(tablo, csrfToken)
        </div>
        <div class="mt-8">
            @KanbanBoard(tablo.ID, csrfToken, tasks)
        </div>
    }
}
```

**Tab bar pattern** — insert between delete-zone and content area. Tab links use `hx-get` + `hx-target="#tab-content"` + `hx-push-url`:
```html
<nav class="mt-8 flex border-b border-slate-200">
  <a
    href={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
    hx-get={ "/tablos/" + tablo.ID.String() }
    hx-target="#tab-content"
    hx-swap="innerHTML"
    hx-push-url={ "/tablos/" + tablo.ID.String() }
    class="tab-link ..."
  >Overview</a>
  <!-- Tasks and Files tabs follow same shape -->
</nav>
<div id="tab-content" class="mt-6">
  <!-- dispatch on activeTab -->
</div>
```

**Fragment-only components needed:** `TabloOverviewTabFragment`, `TabloTasksTabFragment`, `TabloFilesTabFragment` — each is what the `#tab-content` div contains when that tab is active. These are rendered either as the full-page content area (initial load) or as standalone fragments (HTMX request).

---

### `backend/templates/files.templ` (component, request-response — new)

**Analog:** `backend/templates/tasks.templ`

**Package/import pattern** (lines 1–8 of tasks.templ):
```go
package templates

import (
    "backend/internal/db/sqlc"
    "backend/internal/web/ui"
    "github.com/google/uuid"
)
```

**Delete confirmation pattern** (lines 285–326 of tasks.templ — copy structure for file delete confirm):
```go
templ TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {
    <div class="task-card-zone" id={ "task-" + task.ID.String() }>
        <div class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2">
            <p class="text-sm font-semibold text-slate-800">Delete task?</p>
            <p class="text-xs text-slate-600">This cannot be undone.</p>
            <div class="flex items-center gap-2">
                <form
                    method="POST"
                    action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete") }
                    hx-post={ ... }
                    hx-target="closest .task-card-zone"
                    hx-swap="outerHTML"
                >
                    @ui.CSRFField(csrfToken)
                    @ui.Button(ui.ButtonProps{
                        Label:   "Yes, delete",
                        Variant: ui.ButtonVariantDanger,
                        Tone:    ui.ButtonToneSolid,
                        ...
                    })
                </form>
                @ui.Button(ui.ButtonProps{
                    Label:   "Keep task",
                    Variant: ui.ButtonVariantNeutral,
                    Tone:    ui.ButtonToneSoft,
                    ...
                })
            </div>
        </div>
    </div>
}
```

**ui.Card pattern** for file list rows (from tablos.templ lines 70–93):
```go
@ui.Card(templ.Attributes{"id": "tablo-" + tablo.ID.String()}) {
    <div class="flex items-start justify-between">
        ...
    </div>
}
```

**OOB removal pattern** (from TaskCardGone, lines 343–345 of tasks.templ):
```go
templ TaskCardGone(taskID uuid.UUID) {
    <div id={ "task-" + taskID.String() } class="task-card-zone"></div>
}
```
File analog: `FileRowGone(fileID uuid.UUID)` — same empty-div-with-id pattern.

**Upload form** uses `method="POST"` + `enctype="multipart/form-data"` + `hx-post` + `hx-encoding="multipart/form-data"` (HTMX multipart requires this attribute). Input: `<input type="file" name="file" ...>`. Add `@ui.CSRFField(csrfToken)`.

---

### `backend/cmd/web/main.go` (config — modify)

**Analog:** `backend/cmd/web/main.go` (self)

**Current deps wiring** (lines 78–82):
```go
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
tabloDeps := web.TablosDeps{Queries: q}
taskDeps := web.TasksDeps{Queries: q}

router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, csrfKey, env)
```

**Pattern for reading env vars** (lines 27–35 — copy pattern for S3 env vars):
```go
env := os.Getenv("ENV")
if env == "" {
    env = "development"
}
port := os.Getenv("PORT")
if port == "" {
    port = "8080"
}
dsn := os.Getenv("DATABASE_URL")
```

**Fast-fail pattern for required vars** (lines 41–44):
```go
if dsn == "" {
    slog.Error("DATABASE_URL is required but unset")
    os.Exit(1)
}
```

**S3 wiring to add** (insert before `router := ...`):
```go
// S3 / files store
s3Endpoint := os.Getenv("S3_ENDPOINT")
s3Bucket   := os.Getenv("S3_BUCKET")
// ... read remaining vars ...
maxUploadMB := 25
if v := os.Getenv("MAX_UPLOAD_SIZE_MB"); v != "" {
    // strconv.Atoi + fallback
}
filesStore, err := files.NewStore(ctx, s3Endpoint, s3Bucket, ...)
if err != nil {
    slog.Error("s3 client init failed", "err", err)
    os.Exit(1)
}
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}

router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
```

---

### `compose.yaml` (config — modify)

**Analog:** `backend/compose.yaml` (self)

**Existing postgres service** (lines 1–20 — copy structure exactly):
```yaml
services:
  postgres:
    image: postgres:16-alpine
    container_name: xtablo-backend-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: xtablo
      POSTGRES_USER: xtablo
      POSTGRES_PASSWORD: xtablo
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  postgres_data:
```

**MinIO services to add** (per RESEARCH.md Pattern 7 — verified against MinIO Docker Hub):
```yaml
  minio:
    image: minio/minio:latest
    container_name: xtablo-backend-minio
    restart: unless-stopped
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    ports:
      - "9000:9000"   # S3 API
      - "9001:9001"   # Console UI
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s
      timeout: 5s
      retries: 10

  minio-init:
    image: minio/mc:latest
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://minio:9000 minioadmin minioadmin;
      mc mb --ignore-existing local/xtablo-dev;
      echo 'bucket ready';
      "
    restart: "no"   # init container — must NOT be unless-stopped (Pitfall 7)
```

**Add `minio_data:` under `volumes:` block alongside `postgres_data:`.**

---

## Shared Patterns

### Auth / Ownership (loadOwnedTablo)
**Source:** `backend/internal/web/handlers_tablos.go` lines 150–182
**Apply to:** All file handlers (TabloFilesTabHandler, FileUploadHandler, FileDownloadHandler, FileDeleteHandler, FileDeleteConfirmHandler)

```go
func loadOwnedTablo(w http.ResponseWriter, r *http.Request, deps TablosDeps) (sqlc.Tablo, *auth.User, bool) {
    _, user, _ := auth.Authed(r.Context())
    tabloID, err := uuid.Parse(chi.URLParam(r, "id"))
    if err != nil {
        http.NotFound(w, r)
        return sqlc.Tablo{}, nil, false
    }
    tablo, err := deps.Queries.GetTabloByID(r.Context(), sqlc.GetTabloByIDParams{ID: tabloID, UserID: user.ID})
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            http.NotFound(w, r)
            return sqlc.Tablo{}, nil, false
        }
        slog.Default().Error("tablos: GetTabloByID failed", "id", tabloID, "err", err)
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return sqlc.Tablo{}, nil, false
    }
    return tablo, user, true
}
```

### Error Logging
**Source:** `backend/internal/web/handlers_tasks.go` (throughout)
**Apply to:** All handler error paths

```go
slog.Default().Error("files upload: PutObject failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
```
Convention: log key is `"<domain> <action>: <operation> failed"`. Always include `"tablo_id"` and `"err"` keys.

### HTMX Fragment vs Full-Page
**Source:** `backend/internal/web/handlers_tasks.go` (throughout), `backend/internal/web/handlers_tablos.go`
**Apply to:** TabloFilesTabHandler, TabloTasksTabHandler (tab entry points)

```go
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
    _ = templates.FilesTabFragment(tablo, files, csrf.Token(r)).Render(r.Context(), w)
    return
}
// Full page for direct navigation / browser refresh
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, files, "files").Render(r.Context(), w)
```

### CSRF Field in All Forms
**Source:** `backend/templates/tablos.templ` line 108, `backend/templates/tasks.templ` lines 35, 183, 244
**Apply to:** All POST forms in files.templ (upload form, delete confirm form)

```go
@ui.CSRFField(csrfToken)
```

### Post/Redirect/Get (non-HTMX fallback)
**Source:** `backend/internal/web/handlers_tasks.go` line 248, 307
**Apply to:** FileUploadHandler, FileDeleteHandler non-HTMX paths

```go
http.Redirect(w, r, "/tablos/"+tablo.ID.String()+"/files", http.StatusSeeOther)
// Always 303 SeeOther on POST success — NOT 302 (Pitfall 9)
```

### ui.Button component usage
**Source:** `backend/templates/tablos.templ` lines 339–353, `backend/templates/tasks.templ` lines 258–278
**Apply to:** Upload form submit button, delete confirm buttons in files.templ

```go
@ui.Button(ui.ButtonProps{
    Label:   "Upload",
    Variant: ui.ButtonVariantDefault,
    Tone:    ui.ButtonToneSolid,
    Size:    ui.SizeMD,
    Type:    "submit",
})
@ui.Button(ui.ButtonProps{
    Label:   "Yes, delete",
    Variant: ui.ButtonVariantDanger,
    Tone:    ui.ButtonToneSolid,
    Size:    ui.SizeMD,
    Type:    "submit",
})
```

### templ.SafeURL for action attributes
**Source:** `backend/templates/tablos.templ` lines 217, 367; `backend/templates/tasks.templ` line 177
**Apply to:** All `action=` attributes in files.templ forms

```go
action={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/files") }
```

---

## No Analog Found

No files in this phase lack a codebase analog. `backend/internal/files/store.go` has no body analog, but RESEARCH.md Patterns 1–3 provide verified, concrete implementation patterns with line-level specificity.

---

## Metadata

**Analog search scope:** `backend/` directory — migrations, queries, handlers, templates, cmd, compose.yaml
**Files scanned:** 12 source files read directly
**Pattern extraction date:** 2026-05-15
