diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..de0125e --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Arkie Library Backend environment example +# Copy to .env for your own reference, but note: the Go app does not auto-load .env. +# Export these values in your shell, Docker, process manager, or deployment system. + +# HTTP +HTTP_ADDR=:8080 +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# PostgreSQL +DATABASE_URL=postgres://ark:ark@localhost:5432/arkdb?sslmode=disable + +# Auth +# Change this in production. Use a long random string. +JWT_SECRET=dev-insecure-change-me + +# Wallet auth schema DDL at startup. +# true is convenient for dev; false is safer for public/read-only DB users after migrations are applied. +RUN_WALLET_AUTH_SCHEMA=true + +# Local uploads +UPLOAD_DIR=./uploads +UPLOAD_MULTIPART_MEM_MB=64 + +# Optional first admin seed. Only creates an admin when the admins table is empty. +SEED_ADMIN=false +ADMIN_EMAIL=admin@ark.local +ADMIN_PASSWORD=admin123 + +# Optional S3 uploads. Leave S3_BUCKET empty to keep uploads local. +S3_BUCKET= +AWS_REGION=ap-southeast-1 +S3_UPLOAD_PREFIX=uploads +# Optional CDN/website base URL, e.g. https://cdn.example.com +S3_PUBLIC_BASE_URL= diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..647fd1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AI Agent Instructions + +This file is first-stop context for AI coding agents working in `Arkie-Library-Backend`. + +## Repository identity + +- Project: Arkie Library Backend / ARK database API. +- Go module: `github.com/arkie/ark-database`. +- Stack: Go, chi router, PostgreSQL/pgx, JWT, bcrypt, optional AWS S3 uploads, Ethereum wallet signature verification. +- Frontend repo expects backend API at `/api` and uploaded assets under `/uploads`. + +## Branch and safety rules + +- Current branch observed during initialization: `terry-staging`. +- Do not commit or push unless Terry explicitly asks. +- Before branch-changing, pulling, rebasing, or destructive operations, run: + +```bash +git status --short --branch +``` + +- Do not commit secrets. `.env.example` is safe; real `.env` files should stay local/deployment-only. + +## Required checks + +Before proposing deploy/push, run: + +```bash +gofmt -w . +go test ./... +``` + +Optional extra check: + +```bash +go vet ./... +``` + +## Architecture quick map + +- `cmd/server/main.go`: starts everything. Loads config, connects DB, ensures upload dir, optionally seeds admin, sets up S3, registers routes. +- `internal/config/config.go`: all environment variables and defaults. +- `internal/db/db.go`: creates and pings a `pgxpool.Pool`. +- `internal/auth/`: JWT helpers. Admin tokens use issuer `ark-admin`; wallet user tokens use issuer `ark-user`. +- `internal/handlers/public.go`: public category/resource/search/metric endpoints. +- `internal/handlers/admin.go`: admin login, dashboard, resource CRUD, tags. +- `internal/handlers/wallet_auth.go`: wallet nonce, signature verification, wallet profile. +- `internal/handlers/upload.go`: admin multipart upload to local disk or S3. +- `migrations/`: SQL schema files; there is no Go migration runner yet. + +## Important implementation patterns + +- HTTP handlers are plain `net/http` functions using chi route params. +- DB access comes from request context via `handlers.WithPool` and `poolFrom(r)`. +- JSON responses use `writeJSON` in `internal/handlers/util.go`. +- JSON request bodies use `jsonDecode`, limited to 1 MiB. +- Resource IDs are UUIDs (`github.com/google/uuid`). +- Tags are stored in `tags` + `resource_tags`; new tag slugs are deterministic hashes from `tagSlug`. +- Public resource listing only returns `resources.status = 'published'` and `is_public = TRUE`. +- Admin routes require `Authorization: Bearer ` from `/api/admin/login`. +- Wallet routes produce normal user JWTs, not admin JWTs. + +## Database/migration notes + +- `migrations/001_init.sql` is designed for a fresh database and is not fully idempotent. +- `migrations/002_wallet_auth.sql` is idempotent. +- `RUN_WALLET_AUTH_SCHEMA` defaults to true and lets startup create the wallet nonce table. In production with read-only/public DB users, apply migration separately and set it to false. + +## Deployment/runtime notes + +- Dockerfile builds a static Go binary in a `golang:1.24-alpine` builder and runs it on Alpine. +- `UPLOAD_DIR` defaults to `./uploads`; Docker sets it to `/app/uploads`. +- When `S3_BUCKET` is set and AWS config loads, uploads go to S3. Otherwise uploads stay local. +- `JWT_SECRET` default is insecure and must be changed outside development. + +## Agent behavior preferences + +- Answer Terry concisely in Chinese unless code/docs require English. +- Prefer small direct changes over broad rewrites. +- Keep this README/AGENTS/docs updated when discovering non-obvious backend behavior. +- Search project memory before making decisions about workflow, deployment, or conventions. diff --git a/README.md b/README.md new file mode 100644 index 0000000..26e2777 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Arkie Library Backend + +Go backend for the ARK Library / ARK Database website. + +If Go is new for you: start with **[docs/GO_FOR_BEGINNERS.md](docs/GO_FOR_BEGINNERS.md)**, then read **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. + +## What this service does + +- Serves public library data under `/api`. +- Serves uploaded files under `/uploads`. +- Provides admin login and admin CRUD APIs for resources. +- Supports wallet sign-in with Ethereum personal-sign messages. +- Stores data in PostgreSQL. +- Stores uploads locally by default, or in S3 when configured. + +## Tech stack + +- Go module: `github.com/arkie/ark-database` +- HTTP router: `github.com/go-chi/chi/v5` +- Database: PostgreSQL via `github.com/jackc/pgx/v5/pgxpool` +- Auth: JWT via `github.com/golang-jwt/jwt/v5` +- Password hashing: bcrypt +- Wallet signature verification: `github.com/ethereum/go-ethereum` +- Optional uploads: AWS S3 SDK v2 + +## Folder map + +```text +cmd/server/main.go # application entry point: config, DB, router, endpoints +internal/config/config.go # environment variables and default values +internal/db/db.go # PostgreSQL connection pool +internal/auth/ # JWT signing/parsing for admin and wallet users +internal/handlers/ # HTTP handlers: public, admin, upload, wallet auth +internal/seed/seed.go # optional initial admin user +migrations/ # SQL schema files, apply manually +static/ # bundled static assets copied into uploads +``` + +## Quick local run + +### 1) Start PostgreSQL + +Example with Docker: + +```bash +docker run --name ark-postgres \ + -e POSTGRES_USER=ark \ + -e POSTGRES_PASSWORD=ark \ + -e POSTGRES_DB=arkdb \ + -p 5432:5432 \ + -d postgres:16 +``` + +### 2) Apply schema once + +`migrations/001_init.sql` is for a fresh empty database. Do not run it twice on the same DB unless you reset the DB first. + +```bash +cd Arkie-Library-Backend +export DATABASE_URL='postgres://ark:ark@localhost:5432/arkdb?sslmode=disable' +psql "$DATABASE_URL" -f migrations/001_init.sql +``` + +Wallet nonce schema is normally auto-created at app startup because `RUN_WALLET_AUTH_SCHEMA` defaults to true. If you want to apply it manually and disable startup DDL: + +```bash +psql "$DATABASE_URL" -f migrations/002_wallet_auth.sql +export RUN_WALLET_AUTH_SCHEMA=false +``` + +### 3) Configure env + +Copy the example and edit values as needed: + +```bash +cp .env.example .env +``` + +This project does not auto-load `.env`; either export variables in your shell or run with a dotenv helper. + +### 4) Run the server + +```bash +go run ./cmd/server +``` + +Check health: + +```bash +curl http://localhost:8080/healthz +``` + +## Useful commands + +```bash +# Format all Go files +gofmt -w . + +# Compile and run all package tests +go test ./... + +# Run the backend +go run ./cmd/server + +# Build a local binary +go build -o ./bin/server ./cmd/server +``` + +## Environment variables + +See `.env.example` for all variables. Important ones: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `HTTP_ADDR` | `:8080` | Listen address | +| `DATABASE_URL` | `postgres://ark:ark@localhost:5432/arkdb?sslmode=disable` | PostgreSQL connection | +| `JWT_SECRET` | `dev-insecure-change-me` | JWT signing key; change in production | +| `UPLOAD_DIR` | `./uploads` | Local upload directory | +| `CORS_ORIGINS` | empty | Comma-separated allowed browser origins | +| `SEED_ADMIN` | false | Set `true`/`1` to create first admin if none exists | +| `ADMIN_EMAIL` | `admin@ark.local` | Seed admin email | +| `ADMIN_PASSWORD` | `admin123` | Seed admin password | +| `S3_BUCKET` | empty | Enables S3 uploads when set | + +## API docs + +See **[docs/API.md](docs/API.md)**. + +## Agent notes + +AI/coding agent instructions are in **[AGENTS.md](AGENTS.md)**. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..e2a839a --- /dev/null +++ b/docs/API.md @@ -0,0 +1,360 @@ +# API Reference + +Base URL in local development: `http://localhost:8080`. + +Frontend usually calls paths like `/api/resources`; uploaded files are available under `/uploads/...`. + +## Response style + +Most responses are JSON. Errors use plain text via `http.Error`, for example `unauthorized` or `bad json`. + +## Health + +### `GET /healthz` + +Returns plain text: + +```text +ok +``` + +### `GET /health` + +Same as `/healthz`. + +## Public categories/resources + +### `GET /api/categories` + +Query/header language behavior: + +- `?lang=zh-CN` returns Simplified Chinese name when available. +- `?lang=en` returns English name when available. +- otherwise Traditional Chinese is preferred. + +Response: + +```json +[ + { + "id": 1, + "slug": "project-ppt", + "name": "項目資料(PPT)", + "description": "ARK 項目介紹、簡報與對外展示資料", + "iconKey": "folder", + "sortOrder": 1, + "updatedAt": "2026-01-01T00:00:00Z" + } +] +``` + +### `GET /api/resources` + +Returns paginated public resources. + +Query params: + +| Param | Meaning | +| --- | --- | +| `page` | page number, default 1 | +| `limit` | page size, default 20, max 100 | +| `q` | search title/description/tag name | +| `type` | resource type; `all` means no filter | +| `language` | exact resource language filter | +| `category` | category slug | +| `tag` | tag slug or tag name, case-insensitive | +| `sort` | `latest` default, `published`, `recommended`, `popular` | +| `lang` | category display language, e.g. `en`, `zh-CN` | + +Response: + +```json +{ + "items": [ + { + "id": "uuid", + "title": "ARK 項目介紹簡報(示例)", + "description": "適合線下宣講與新人培訓。", + "type": "ppt", + "language": "zh-TW", + "categoryId": 1, + "categorySlug": "project-ppt", + "categoryName": "項目資料(PPT)", + "coverImage": "/uploads/placeholder-cover.svg", + "fileUrl": "/uploads/placeholder-cover.svg", + "isDownloadable": true, + "isRecommended": true, + "publishedAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "tags": ["官方推薦"] + } + ], + "page": 1, + "limit": 20, + "total": 1 +} +``` + +### `GET /api/resources/recommended?limit=12` + +Returns recommended resources: + +```json +{ "items": [] } +``` + +Max limit is 50. + +### `GET /api/resources/latest?limit=12` + +Returns latest resources: + +```json +{ "items": [] } +``` + +### `GET /api/resources/popular?limit=12` + +Returns resources ordered by download + favorite + share count: + +```json +{ "items": [] } +``` + +### `GET /api/resources/{id}` + +Returns one published public resource. `{id}` must be a UUID. + +### `GET /api/resources/{id}/related` + +Returns up to 8 resources from the same category: + +```json +{ "items": [] } +``` + +## Public activity endpoints + +### `POST /api/search-log` + +Request: + +```json +{ "query": "ark" } +``` + +Response: + +```json +{ "ok": true } +``` + +### `POST /api/resources/{id}/view` + +Increments view count. + +Response: + +```json +{ "ok": true } +``` + +### `POST /api/resources/{id}/download` + +Increments download count. + +### `POST /api/resources/{id}/share` + +Increments share count. + +### `POST /api/resources/{id}/favorite` + +Request: + +```json +{ "add": true } +``` + +- `add: true` increments favorite count. +- `add: false` decrements favorite count but not below zero. + +## Wallet auth + +Wallet auth is for normal users, not admin users. + +### `POST /api/auth/wallet/nonce` + +Request: + +```json +{ "address": "0x0000000000000000000000000000000000000000" } +``` + +Response: + +```json +{ + "nonce": "hex-code", + "message": "ARK Database — wallet sign-in\n\nWallet: 0x...\nOne-time code: ..." +} +``` + +The frontend asks the wallet to sign `message`. + +### `POST /api/auth/wallet/verify` + +Request: + +```json +{ + "address": "0x0000000000000000000000000000000000000000", + "message": "same message from nonce endpoint", + "signature": "0x..." +} +``` + +Response: + +```json +{ + "token": "jwt", + "wallet": "0xChecksumAddress" +} +``` + +### `GET /api/auth/wallet/me` + +Requires header: + +```http +Authorization: Bearer +``` + +Response: + +```json +{ "wallet": "0xChecksumAddress", "role": "user" } +``` + +## Admin auth + +### `POST /api/admin/login` + +Request: + +```json +{ "email": "admin@ark.local", "password": "admin123" } +``` + +Response: + +```json +{ "token": "admin-jwt" } +``` + +Use this token for admin endpoints: + +```http +Authorization: Bearer +``` + +## Admin endpoints + +All endpoints below require admin JWT. + +### `GET /api/admin/dashboard` + +Response includes counts and hot resources: + +```json +{ + "totalResources": 10, + "published": 8, + "todayNew": 1, + "totalViews": 100, + "totalDownloads": 20, + "totalFavorites": 5, + "totalShares": 3, + "hotResources": [] +} +``` + +### `GET /api/admin/search-logs?limit=200` + +Max limit is 500. + +Response: + +```json +{ "items": [{ "id": 1, "query": "ark", "createdAt": "2026-01-01T00:00:00Z" }] } +``` + +### `GET /api/admin/resources?page=1&limit=20` + +Lists all resources, including drafts/private resources. + +### `GET /api/admin/resources/{id}` + +Gets one resource by UUID. + +### `POST /api/admin/resources` + +Creates a resource. + +Example request body: + +```json +{ + "title": "Example", + "description": "Short text", + "type": "ppt", + "language": "zh-TW", + "categoryId": 1, + "coverImage": "/uploads/example.svg", + "fileUrl": "/uploads/example.pdf", + "previewUrl": "", + "externalUrl": "", + "bodyText": "", + "badgeLabel": "新人必看", + "isPublic": true, + "isDownloadable": true, + "isRecommended": false, + "sortOrder": 0, + "status": "draft", + "tags": ["教程"] +} +``` + +### `PUT /api/admin/resources/{id}` + +Updates a resource. Body shape is the same as create. + +### `DELETE /api/admin/resources/{id}` + +Deletes a resource. + +Response: + +```json +{ "ok": true } +``` + +### `POST /api/admin/upload` + +Multipart upload with field name `file`. + +Response for local storage: + +```json +{ "url": "/uploads/generated-name.ext", "filename": "generated-name.ext", "storage": "local" } +``` + +Response for S3: + +```json +{ "url": "https://...", "filename": "generated-name.ext", "storage": "s3" } +``` + +### `GET /api/admin/categories` + +Same handler as public categories; returns visible categories. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..780c9d9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,180 @@ +# Backend Architecture + +This document explains the backend in beginner-friendly language. + +## Big picture + +```text +Browser / Frontend + | + | HTTP JSON requests + v +cmd/server/main.go + | + +-- config.Load() reads environment variables + +-- db.Connect() creates PostgreSQL pool + +-- chi router maps URLs to handler functions + +-- handlers read/write DB and return JSON + | + v +PostgreSQL + local uploads or S3 +``` + +The backend is one Go HTTP server. There is no framework magic: `main.go` wires everything manually. + +## Request flow example + +Example: frontend calls `GET /api/resources?limit=20`. + +1. `main.go` has this route: + ```go + r.Get("/resources", handlers.ListResources) + ``` +2. The `/api` router has middleware: + ```go + r.Use(func(next http.Handler) http.Handler { + return handlers.WithPool(next, pool) + }) + ``` + This places the PostgreSQL pool into the request context. +3. `handlers.ListResources` calls `poolFrom(r)` to get the DB pool. +4. It reads query string filters (`q`, `type`, `language`, `category`, `sort`, `tag`). +5. It builds SQL, queries PostgreSQL, scans rows into `ResourceDTO`. +6. It returns JSON using `writeJSON`. + +## Why `internal/`? + +In Go, a folder named `internal` can only be imported by code inside the same module tree. This prevents other modules from depending on private implementation packages. + +This project uses: + +```text +internal/config private config package +internal/db private DB package +internal/auth private JWT package +internal/handlers private HTTP handler package +internal/seed private seed package +``` + +## Main packages + +### `cmd/server` + +`cmd/server/main.go` is the executable entry point. + +Responsibilities: + +- Load environment config. +- Connect to PostgreSQL. +- Ensure wallet nonce table when enabled. +- Create upload directory and placeholder file. +- Optionally seed the first admin. +- Configure optional S3 upload client. +- Register all HTTP middleware and routes. +- Start `http.ListenAndServe`. + +### `internal/config` + +`config.Load()` reads env vars and fills defaults. It returns a `Config` struct. + +Important pattern: + +```go +addr := os.Getenv("HTTP_ADDR") +if addr == "" { + addr = ":8080" +} +``` + +This means env vars are optional in local development. + +### `internal/db` + +`db.Connect(ctx, databaseURL)` parses `DATABASE_URL`, creates a pgx connection pool, then pings the DB. + +The app shares this pool for all requests. + +### `internal/auth` + +Two JWT token types exist: + +- Admin token: issuer `ark-admin`, includes `admin_id` and email. +- Wallet user token: issuer `ark-user`, includes wallet address and role `user`. + +Do not mix these. Admin middleware only accepts admin tokens. + +### `internal/handlers` + +Handlers are grouped by topic: + +- `public.go`: public category/resource list/detail, search logs, counters. +- `admin.go`: admin login/dashboard/resource CRUD/tag replacement. +- `wallet_auth.go`: wallet nonce and signature verification. +- `upload.go`: admin file upload. +- `middleware.go`: admin auth middleware. +- `context.go`: stores DB pool in request context. +- `util.go`: shared JSON helpers. + +## Database model summary + +Main tables from `migrations/001_init.sql`: + +- `admins`: admin email + bcrypt password hash. +- `categories`: resource categories with multilingual names. +- `resources`: library items; can be ppt/image/video/link/etc. +- `tags`: reusable tag names. +- `resource_tags`: many-to-many join table. +- `search_logs`: saved public search terms. + +Wallet auth table from migration/startup DDL: + +- `wallet_auth_nonces`: one temporary nonce per wallet address. + +## Route groups + +### Public routes + +These do not require auth: + +- health: `/healthz`, `/health` +- categories/resources under `/api` +- wallet auth nonce/verify/me (me requires wallet JWT) +- resource view/download/share/favorite counters + +### Admin routes + +`POST /api/admin/login` is public because it returns the admin token. + +Everything in this group requires admin JWT: + +- dashboard +- search logs +- resource list/get/create/update/delete +- upload +- admin categories list + +## Upload behavior + +Admin upload endpoint: `POST /api/admin/upload` with multipart field `file`. + +If S3 is configured: + +```text +S3_BUCKET non-empty + AWS config loads => upload to S3 +``` + +Otherwise: + +```text +save file to UPLOAD_DIR and return /uploads/ +``` + +The max upload size is 512 MiB (`uploadMaxBytes`). + +## Things to be careful with + +- `migrations/001_init.sql` is not safe to run repeatedly on the same DB. +- `JWT_SECRET` default is for dev only. +- `RUN_WALLET_AUTH_SCHEMA=true` runs DDL at startup. Use false if the runtime DB user should not create tables. +- The app does not auto-load `.env`. +- Public resources are filtered to published + public; admin list sees all resources. diff --git a/docs/GO_FOR_BEGINNERS.md b/docs/GO_FOR_BEGINNERS.md new file mode 100644 index 0000000..05bdb66 --- /dev/null +++ b/docs/GO_FOR_BEGINNERS.md @@ -0,0 +1,281 @@ +# Go Beginner Guide for This Backend + +This is not a full Go course. It explains only the Go ideas you need to read this project. + +## 1. How a Go project starts + +This repo has a `go.mod` file: + +```go +module github.com/arkie/ark-database +``` + +That module name is used in imports: + +```go +import "github.com/arkie/ark-database/internal/config" +``` + +The executable starts at: + +```text +cmd/server/main.go +``` + +In Go, a runnable program has: + +```go +package main + +func main() { + // program starts here +} +``` + +## 2. Packages + +Every `.go` file begins with a package name: + +```go +package handlers +``` + +Files in the same folder usually share the same package and can call each other directly. + +Example: files in `internal/handlers/` all say `package handlers`, so `public.go` can call `writeJSON` from `util.go`. + +## 3. Imports + +Go imports are explicit: + +```go +import ( + "net/http" + "github.com/go-chi/chi/v5" +) +``` + +If an import is unused, Go compilation fails. Run `gofmt -w .` after edits. + +## 4. Structs + +A struct is an object/data shape. + +Example from `internal/config/config.go`: + +```go +type Config struct { + Addr string + DatabaseURL string + JWTSecret string +} +``` + +JSON field names are controlled by tags: + +```go +type ResourceDTO struct { + CategoryID int `json:"categoryId"` +} +``` + +The Go field is `CategoryID`, but JSON output is `categoryId`. + +## 5. Functions with errors + +Go commonly returns `(value, error)`: + +```go +pool, err := db.Connect(ctx, cfg.DatabaseURL) +if err != nil { + log.Fatal(err) +} +``` + +Read this as: + +1. Try connecting to DB. +2. If `err` is not nil, stop. +3. Otherwise use `pool`. + +## 6. Short variable declaration `:=` + +This creates a new variable: + +```go +cfg := config.Load() +``` + +This assigns to an existing variable: + +```go +cfg = config.Load() +``` + +Most code here uses `:=` inside functions. + +## 7. HTTP handlers + +A Go HTTP handler usually looks like: + +```go +func ListCategories(w http.ResponseWriter, r *http.Request) { + // w writes response + // r reads request +} +``` + +- `w` = response writer. +- `r` = request. + +Route registration in `main.go`: + +```go +r.Get("/categories", handlers.ListCategories) +``` + +Because this is inside `r.Route("/api", ...)`, the full URL is: + +```text +GET /api/categories +``` + +## 8. Middleware + +Middleware wraps a request before it reaches the final handler. + +Example in `main.go`: + +```go +r.Use(middleware.Logger) +``` + +This logs requests. + +Admin auth is also middleware: + +```go +r.Use(handlers.AdminAuth(cfg.JWTSecret)) +``` + +If token is bad, it returns `401 unauthorized` before reaching the admin handler. + +## 9. Context + +Context carries request-scoped values and cancellation. + +This project stores the DB pool in request context: + +```go +r.Use(func(next http.Handler) http.Handler { + return handlers.WithPool(next, pool) +}) +``` + +Handlers retrieve it: + +```go +pool := poolFrom(r) +``` + +So most handlers do not receive the DB pool as a direct function parameter. + +## 10. Database queries + +This project uses pgx. + +One row: + +```go +err := pool.QueryRow(ctx, `SELECT id FROM admins WHERE email = $1`, email).Scan(&id) +``` + +Many rows: + +```go +rows, err := pool.Query(ctx, `SELECT id, title FROM resources`) +defer rows.Close() +for rows.Next() { + rows.Scan(&id, &title) +} +``` + +PostgreSQL parameters use `$1`, `$2`, etc. This protects from SQL injection when used correctly. + +## 11. Pointers and nil + +You will see `*string` and `*time.Time`: + +```go +var pubAt *time.Time +``` + +This allows SQL `NULL` to become Go `nil`. + +Code checks before using it: + +```go +if pubAt != nil { + s := pubAt.UTC().Format(time.RFC3339) + dto.PublishedAt = &s +} +``` + +## 12. `defer` + +`defer` runs later when the current function returns. + +Examples: + +```go +defer pool.Close() +defer rows.Close() +defer r.Body.Close() +``` + +Use it for cleanup. + +## 13. JSON helpers in this project + +Write JSON response: + +```go +writeJSON(w, map[string]any{"ok": true}) +``` + +Read JSON request body: + +```go +var req loginReq +if err := jsonDecode(r, &req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return +} +``` + +## 14. Common edit workflow + +After changing `.go` files: + +```bash +gofmt -w . +go test ./... +``` + +`go test ./...` also compiles all packages, even if there are no test files. + +## 15. Recommended reading order + +1. `README.md` +2. `cmd/server/main.go` +3. `internal/config/config.go` +4. `internal/handlers/public.go` +5. `internal/handlers/admin.go` +6. `internal/handlers/wallet_auth.go` +7. `migrations/001_init.sql` + +When you see a function you do not understand, search for its definition: + +```bash +rg "func FunctionName" . +```