The storage layer and the documents table
Seven files as tabs, bottom-up: the storage layer, then the table, then the wiring. storage.go → local.go → 00004_documents.sql → documents.sql → config.go → handlers.go → main.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.