Code

The storage layer and the documents table

Seven files as tabs, bottom-up: the storage layer, then the table, then the wiring. storage.golocal.go00004_documents.sqldocuments.sqlconfig.gohandlers.gomain.go.

1. internal/storage/storage.go (new) — the Store interface. Three methods, and the package comment names the seam out loud: it is the same pattern as email.Sender, and it is here because Railway's container filesystem is ephemeral.

2. internal/storage/local.go (new) — LocalStore. The heart is Save: open the file, then io.Copy(io.MultiWriter(f, h), r) — one pass, hash and write together. On any copy or close error it removes the half-written file so a failed upload leaves nothing behind. Open/Delete run the key through safePath, which enforces keyPattern before joining it to the base dir. newKey is 16 random bytes as hex.

3. db/migrations/00004_documents.sql (new) — the documents table:

owner_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_hash    TEXT NOT NULL,                 -- SHA-256, hex
storage_key  TEXT NOT NULL,                 -- opaque key into the store
status       TEXT NOT NULL DEFAULT 'draft'
             CHECK (status IN ('draft', 'sent', 'completed')),

status is TEXT + CHECK, not a Postgres ENUM — the set is small and fixed, and extending a check constraint later beats an ALTER TYPE dance. The lifecycle is draft → sent → completed; today everything is a draft. ON DELETE CASCADE means deleting a user takes their documents with them.

4. internal/db/queries/documents.sql (new) — two queries for now: CreateDocument (insert, RETURNING *) and ListDocumentsByOwner (WHERE owner_id = $1 ORDER BY created_at DESC). The owner-scoped read and the draft-guarded delete arrive next lesson. Run sqlc generate and the typed CreateDocument/ListDocumentsByOwner methods appear.

5. internal/config/config.go (modify) — two additions: UploadDir (UPLOAD_DIR, default uploads) and MaxUploadBytes (25 << 20). The comment flags the Railway caveat you will act on in the deploy lesson — the container disk is ephemeral, so prod points UploadDir at a mounted volume.

6. internal/handlers/handlers.go (modify) — the Handlers struct gains a Store storage.Store field, and New takes it. A pure addition alongside the Mailer field from last lesson.

7. cmd/signflow/main.go (modify) — construct the store and hand it to New:

store, err := storage.NewLocalStore(cfg.UploadDir)
if err != nil {
	return err
}
log.Info("file storage: local disk", "dir", cfg.UploadDir)
h := handlers.New(cfg, queries, sessions, mailer, store, log)

NewLocalStore creates the directory if it is missing, so the first run just works.