232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
# krow-backend
|
|
|
|
The backend for Krow — a Go API over PostgreSQL, with a Python Owliver service
|
|
to follow. The Krow frontend lives in a **separate repository** (`krow-demo`)
|
|
and is not vendored, copied or modified here.
|
|
|
|
**Status: Phase 3C — session authentication.** The API serves the endpoints in
|
|
`docs/api-contract.md` against PostgreSQL, loaded with the frontend's own demo
|
|
dataset. Every endpoint except `GET /health` and the two `/api/v1/auth/*` routes
|
|
requires a session: sign in with a password, hold an HttpOnly cookie, and the
|
|
server resolves it to a real user on every request. Authorization — which roles
|
|
may do what — is Phase 3D and is not implemented. No Owliver, no RAG, no Redis,
|
|
NATS or S3.
|
|
|
|
## Layout
|
|
|
|
```
|
|
krow-backend/
|
|
├── go-api/
|
|
│ ├── cmd/
|
|
│ │ ├── api/ the HTTP service entrypoint
|
|
│ │ └── seed/ loads the demo dataset
|
|
│ ├── internal/
|
|
│ │ ├── config/ environment loading + validation
|
|
│ │ ├── db/ pgx pool and the database health check
|
|
│ │ ├── domain/ resource descriptors (generated from the schema)
|
|
│ │ ├── repo/ pgx access layer — every statement built here
|
|
│ │ ├── service/ validation, org scoping, contract semantics
|
|
│ │ ├── orgctx/ the organization a request runs as
|
|
│ │ ├── httpserver/ router, handlers, /health, graceful shutdown
|
|
│ │ ├── seeder/ fixture loading + the attendanceSeed.js port
|
|
│ │ └── testutil/ disposable migrated + seeded test database
|
|
│ └── go.mod
|
|
├── migrations/ SQL migrations — the source of truth for the schema
|
|
├── seed/fixtures/ seed.json, generated from the frontend repository
|
|
├── docs/api-contract.md the frozen client contract
|
|
├── infrastructure/ deployment definitions (empty until later)
|
|
├── scripts/ verify_schema.sql, gen_resources.py
|
|
├── Makefile developer, migration and seed tasks
|
|
└── .env.example
|
|
```
|
|
|
|
## Architecture
|
|
|
|
```
|
|
HTTP handler → service → repository → pgx → PostgreSQL
|
|
```
|
|
|
|
No ORM. Every statement is built in `internal/repo` from a `*domain.Resource`:
|
|
column lists are explicit, every value is a bind parameter cast to its declared
|
|
type, and no identifier ever comes from user input — a filter or sort name is
|
|
resolved to a real column before any SQL is assembled.
|
|
|
|
The resource descriptors in `internal/domain/resources_gen.go` are **generated
|
|
from the live schema** (`make gen-resources`), so column names, types, enum
|
|
values and nullability cannot drift from the migrations. The hand-maintained
|
|
part is the per-resource metadata — path, default sort, default limit, supported
|
|
operations, required fields — which comes from `docs/api-contract.md`.
|
|
|
|
One shared query builder rather than fourteen repositories is deliberate: the
|
|
contract's awkward semantics (`NULLS LAST` in both directions, the `, id`
|
|
tiebreaker, per-endpoint limits, shallow PATCH, idempotent DELETE) are then
|
|
implemented once and apply identically everywhere.
|
|
|
|
## Requirements
|
|
|
|
| Tool | Version used | Install |
|
|
| --- | --- | --- |
|
|
| Go | 1.27 | `brew install go` |
|
|
| golang-migrate | 4.19.1 | `brew install golang-migrate` |
|
|
| PostgreSQL | 18.6 | `brew install postgresql@18` |
|
|
|
|
## Getting started
|
|
|
|
```bash
|
|
cp .env.example .env # then fill in DATABASE_USER / DATABASE_PASSWORD
|
|
make db-create # creates the database if it does not exist
|
|
make migrate-up # applies every migration in migrations/
|
|
make seed # loads the frontend's demo dataset
|
|
make run # http://127.0.0.1:8080/health
|
|
```
|
|
|
|
## The API
|
|
|
|
38 endpoints, specified in `docs/api-contract.md`. Every one exists because a
|
|
frontend call site exists — a table in the database is never a reason for an
|
|
endpoint. `DELETE /job-postings/{id}` and `POST /shift-records` return 405
|
|
because nothing in the frontend deletes a posting or creates a shift record.
|
|
|
|
```bash
|
|
curl localhost:8080/api/v1/job-postings
|
|
curl "localhost:8080/api/v1/job-applications?job_posting_id=<id>&limit=50"
|
|
curl "localhost:8080/api/v1/job-applications?status=hired&status=interview"
|
|
curl localhost:8080/api/v1/me
|
|
```
|
|
|
|
**Authentication is required.** `POST /api/v1/auth/login` verifies a password
|
|
with argon2id and issues a server-side session; the raw token goes out in an
|
|
HttpOnly, SameSite=Lax cookie and only its SHA-256 is stored. Middleware resolves
|
|
that cookie to a user on every request and puts the user's organization on the
|
|
context — the same seam `devOrgMiddleware` used to occupy, so nothing in the
|
|
service or repository layers changed.
|
|
|
|
Set a password before signing in: the seeded user's `password_hash` is NULL until
|
|
`go run ./cmd/setpassword -email demo@krow.app` is run.
|
|
|
|
curl -c jar -X POST localhost:8080/api/v1/auth/login \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"email":"demo@krow.app","password":"…","remember_me":false}'
|
|
curl -b jar localhost:8080/api/v1/me
|
|
curl -b jar -X POST localhost:8080/api/v1/auth/logout
|
|
|
|
Authorization is **not** implemented. `users.role` is carried on the identity and
|
|
consulted nowhere: any signed-in user reaches every endpoint. That is Phase 3D.
|
|
|
|
## Seeding
|
|
|
|
`make seed` loads `seed/fixtures/seed.json`, which is generated by executing the
|
|
frontend's `src/api/seed.js` through Vite — so ids, dates, numbers and enum
|
|
values arrive exactly as the demo has them, with no transcription step.
|
|
|
|
Shift records are the exception: they are **generated** by a Go port of
|
|
`attendanceSeed.js` rather than snapshotted, because their dates are anchored to
|
|
*now*. `dataResolver.inPeriod` windows every collection on `created_date`, so a
|
|
frozen snapshot would read as permanently empty a fortnight later.
|
|
|
|
**Idempotency: explicit upsert inside one transaction.** Every record's key is
|
|
derived deterministically from its source id (uuid v5), so re-running targets the
|
|
same rows and restores them to their seeded values. Nothing is deleted, so
|
|
records created through the API survive a re-seed.
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
make test # go test ./...
|
|
```
|
|
|
|
55 tests. The database-backed ones build a disposable database — dropped,
|
|
recreated, migrated and seeded per run, named `krow_backend_autotest_<pid>` so
|
|
concurrent test packages cannot collide. They skip rather than fail when
|
|
PostgreSQL is unreachable.
|
|
|
|
Seed assertions compare the database against the fixture field-by-field rather
|
|
than against numbers typed into a test. The two ordering guarantees
|
|
(`NULLS LAST` in both directions, and the `, id` tiebreaker) are covered by tests
|
|
verified to fail when the guarantee is removed.
|
|
|
|
`GET /health` is public and says only whether traffic should be sent here:
|
|
`{"status": "ok"}` with `200`, `{"status": "degraded"}` with `200` when the
|
|
database is up but unmigrated or left dirty, and `{"status": "unavailable"}`
|
|
with `503` when it is unreachable. Nothing about the server, the database or
|
|
the schema appears in the body — an unauthenticated caller gets the verdict,
|
|
not the reasoning.
|
|
|
|
The check itself is unchanged: `db.Check` still gathers the PostgreSQL version,
|
|
the database and schema names, the applied migration version, the table count
|
|
and the connection error, and the handler logs all of it. Read it from the
|
|
server log (`debug` when healthy, `warn` otherwise), or from psql.
|
|
|
|
## Migrations
|
|
|
|
Migration files are the source of truth for the schema. Nothing in `go-api/`
|
|
issues DDL, no ORM generates it, and no table is ever created by hand — if the
|
|
database and `migrations/` disagree, `migrations/` is right and the database is
|
|
wrong.
|
|
|
|
```bash
|
|
make migrate-up # apply everything pending
|
|
make migrate-status # current version
|
|
make migrate-down # roll back exactly one step
|
|
make migrate-new NAME=add_sessions # scaffold the next pair
|
|
make verify-schema # print tables, enums, FKs, indexes
|
|
```
|
|
|
|
**Ordering.** `-seq` numbering: `000001`, `000002`, … golang-migrate applies
|
|
them in ascending order and records the highest applied version in
|
|
`schema_migrations`. Two developers who both branch off `000001` and both write
|
|
`000002` get a collision at merge — rename the later one rather than resolving
|
|
it in the database.
|
|
|
|
**Never edit an applied migration.** Once a migration has run anywhere other
|
|
than your own machine, it is immutable. Change it and every database that
|
|
already applied it silently diverges from every one that has not. Write
|
|
`000002` instead.
|
|
|
|
**Guards against destructive change:**
|
|
|
|
- There is no `make migrate-drop`. golang-migrate's `drop` command deletes every
|
|
table in the database; it is deliberately not one keystroke from `down`.
|
|
- `make migrate-down` refuses to run unless `APP_ENV=development`.
|
|
- Down migrations name every object they drop. No `DROP SCHEMA`, no
|
|
`DROP DATABASE`, no `CASCADE` on a schema.
|
|
- `config.validate()` rejects `DATABASE_SCHEMA=pg_catalog`, `pg_toast`,
|
|
`information_schema` or anything starting with `pg_`, so the application can
|
|
never be pointed at a system schema.
|
|
- `DATABASE_SSLMODE=disable` is rejected when `APP_ENV=production`.
|
|
|
|
**Dirty state.** If a migration fails partway, golang-migrate marks the version
|
|
dirty and refuses to continue. Fix the SQL, restore from backup if the failure
|
|
left data behind, then `make migrate-force VERSION=<last-good>` and re-run.
|
|
`/health` answers `"degraded"` while the version is dirty, and the handler logs
|
|
`migration_dirty` alongside the applied version, so the detail is in the server
|
|
log rather than in the public response.
|
|
|
|
**Staging and production.** The same files, run by CI against the target
|
|
database as a discrete deploy step *before* the new binary rolls out — which
|
|
means every migration must be backwards-compatible with the currently-running
|
|
version. Expand, migrate, contract: add a nullable column, backfill, switch
|
|
reads, drop the old column in a later release. Never in one migration.
|
|
|
|
## Schema
|
|
|
|
`migrations/000001_initial_schema.up.sql` creates 17 tables in `public`. Fifteen
|
|
correspond to entities the frontend actually reads or writes through
|
|
`src/api/base44Client.js`; `organizations` and `user_preferences` are the two
|
|
additions, and both are documented in the migration itself.
|
|
|
|
Conventions:
|
|
|
|
- `uuid` primary keys, with a nullable unique `legacy_id text` so the existing
|
|
seeded record ids survive a data migration.
|
|
- `timestamptz` throughout.
|
|
- `created_date` / `updated_date` keep their frontend names deliberately. The
|
|
frontend windows every collection on `created_date` and its default sort
|
|
string is `-created_date`; renaming these to `created_at` would mean touching
|
|
the resolver, the store's sort parser and every hook.
|
|
- Native enums for closed vocabularies, `text` + `CHECK` where the set is still
|
|
moving.
|
|
- Email columns are `citext` and stay populated even where a uuid FK also
|
|
exists — the frontend joins workers by email today, and those joins must keep
|
|
working.
|