first commit

This commit is contained in:
2026-08-24 13:06:29 +05:30
commit 7d12ebef3d
86 changed files with 39996 additions and 0 deletions

View File

@@ -0,0 +1,748 @@
package domain_test
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/krow/krow-backend/go-api/internal/testutil"
)
// Phase 4C — the shape of the authored-definition tables.
//
// These test the MIGRATION, not any Go code: there is no agent or skill package
// yet, and there deliberately is not one until Phase 4D. What is under test is
// whether the database refuses the things it is supposed to refuse.
//
// An external test package (`domain_test`) rather than `package domain`,
// because testutil imports seeder which imports domain — reachable from an
// external test binary, an import cycle from an internal one.
const (
agents = "agent_definitions"
skills = "skill_definitions"
)
// fixture is a migrated sandbox with one organization and two users.
type fixture struct {
pool *pgxpool.Pool
ctx context.Context
orgID string
alice string
bob string
}
func newFixture(t *testing.T, label string) *fixture {
t.Helper()
ctx := context.Background()
pool := testutil.Sandbox(t, label)
testutil.ApplyAllMigrations(ctx, t, pool)
f := &fixture{pool: pool, ctx: ctx}
if err := pool.QueryRow(ctx,
`INSERT INTO organizations (name, slug) VALUES ('Defs Org','defs-org') RETURNING id::text`).
Scan(&f.orgID); err != nil {
t.Fatalf("create organization: %v", err)
}
f.alice = f.newUser(t, "alice@example.test")
f.bob = f.newUser(t, "bob@example.test")
return f
}
func (f *fixture) newUser(t *testing.T, email string) string {
t.Helper()
var id string
if err := f.pool.QueryRow(f.ctx,
`INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, $3) RETURNING id::text`,
f.orgID, email, email).Scan(&id); err != nil {
t.Fatalf("create user %s: %v", email, err)
}
return id
}
// row is one candidate definition. Any field may be made deliberately wrong.
type row struct {
table string
defID string
orgID string
visibility string
owner *string
createdBy *string
markdown string
status string
version *int
}
func (f *fixture) insert(r row) (string, error) {
cols := []string{"definition_id", "org_id", "visibility", "owner_user_id", "created_by", "markdown"}
vals := []string{"$1::text", "$2::uuid", "$3::text", "$4::uuid", "$5::uuid", "$6::text"}
args := []any{r.defID, r.orgID, r.visibility, r.owner, r.createdBy, r.markdown}
if r.status != "" {
cols, vals = append(cols, "status"), append(vals, "$7::text")
args = append(args, r.status)
}
if r.version != nil {
cols = append(cols, "version")
vals = append(vals, "$"+itoa(len(args)+1)+"::integer")
args = append(args, *r.version)
}
var id string
err := f.pool.QueryRow(f.ctx,
"INSERT INTO "+r.table+" ("+strings.Join(cols, ", ")+") VALUES ("+
strings.Join(vals, ", ")+") RETURNING id::text", args...).Scan(&id)
return id, err
}
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
// personal and organization build a valid row of each tier, so a test can
// change exactly one thing and see whether the database notices.
func (f *fixture) personal(table, defID, owner string) row {
return row{table: table, defID: defID, orgID: f.orgID, visibility: "personal",
owner: &owner, createdBy: &owner, markdown: "---\nid: " + defID + "\n---\n"}
}
func (f *fixture) organization(table, defID, author string) row {
return row{table: table, defID: defID, orgID: f.orgID, visibility: "organization",
owner: nil, createdBy: &author, markdown: "---\nid: " + defID + "\n---\n"}
}
func mustInsert(t *testing.T, f *fixture, r row) string {
t.Helper()
id, err := f.insert(r)
if err != nil {
t.Fatalf("a valid %s row was refused: %v", r.table, err)
}
return id
}
func refused(t *testing.T, f *fixture, r row, wantConstraint, why string) {
t.Helper()
_, err := f.insert(r)
if err == nil {
t.Fatalf("%s: the row was ACCEPTED — %s", r.table, why)
}
if wantConstraint != "" && !strings.Contains(err.Error(), wantConstraint) {
t.Errorf("%s: refused by %v, want the %s constraint", r.table, err, wantConstraint)
}
}
/* ── 1, 2. The ownership invariant ──────────────────────────────────────── */
func TestVisibilityRequiresMatchingOwnership(t *testing.T) {
f := newFixture(t, "defs_ownership")
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
// The two valid shapes.
mustInsert(t, f, f.personal(table, "valid-personal", f.alice))
mustInsert(t, f, f.organization(table, "valid-org", f.alice))
// 1. A personal definition with no owner belongs to nobody.
bad := f.personal(table, "no-owner", f.alice)
bad.owner = nil
refused(t, f, bad, "visibility_owner",
"a personal definition must have an owner")
// 2. An organization definition with an owner is two answers to
// "whose is this", which is one too many.
bad = f.organization(table, "with-owner", f.alice)
bad.owner = &f.alice
refused(t, f, bad, "visibility_owner",
"an organization definition must not have an owner")
// And an unrecognised tier is not a tier.
bad = f.personal(table, "bad-tier", f.alice)
bad.visibility = "public"
refused(t, f, bad, "visibility_check", "`public` is not a visibility")
})
}
}
/* ── 3. definition_id format ────────────────────────────────────────────── */
// The same rule the frontend validator enforces, restated in the database so a
// caller that bypasses the application cannot store an id the registry could
// never address.
func TestDefinitionIDFormat(t *testing.T) {
f := newFixture(t, "defs_idformat")
valid := []string{"a", "board", "krow-workforce-agent", "x1", "a-1-b", "0abc"}
invalid := map[string]string{
"leading dash": "-board",
"upper case": "Board",
"underscore": "my_skill",
"space": "my skill",
"trailing dot": "board.",
"empty": "",
"slash": "custom/board",
"unicode": "bòard",
"sql-ish": "a'; DROP TABLE users; --",
"newline": "board\nx",
}
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
for _, id := range valid {
if _, err := f.insert(f.personal(table, id, f.alice)); err != nil {
t.Errorf("valid id %q was refused: %v", id, err)
}
}
for name, id := range invalid {
bad := f.personal(table, id, f.bob)
refused(t, f, bad, "definition_id_format", "id "+name+" ("+id+") is not a valid id")
}
})
}
}
/* ── 4, 5, 6, 7. Status vocabularies and version ────────────────────────── */
func TestAgentStatusAndVersion(t *testing.T) {
f := newFixture(t, "defs_agentstatus")
// 4. The three agent statuses, and nothing else.
for _, status := range []string{"draft", "published", "archived"} {
r := f.personal(agents, "s-"+status, f.alice)
r.status = status
mustInsert(t, f, r)
}
for _, status := range []string{"active", "inactive", "live", "DRAFT", ""} {
r := f.personal(agents, "bad-status", f.bob)
r.status = status
if status == "" {
continue // an omitted status takes the default; tested below
}
refused(t, f, r, "status_check", "`"+status+"` is not an agent status")
}
// The default is draft: creating an agent must never publish it.
id := mustInsert(t, f, f.personal(agents, "defaulted", f.bob))
var status string
var version int
if err := f.pool.QueryRow(f.ctx,
`SELECT status, version FROM agent_definitions WHERE id = $1::uuid`, id).
Scan(&status, &version); err != nil {
t.Fatalf("read back: %v", err)
}
if status != "draft" {
t.Errorf("default status = %q, want draft", status)
}
if version != 1 {
t.Errorf("default version = %d, want 1", version)
}
// 6. A version is a whole number of 1 or more.
for _, v := range []int{0, -1, -100} {
r := f.personal(agents, "bad-version", f.bob)
r.version = &v
refused(t, f, r, "version_check", "version must be at least 1")
}
for _, v := range []int{1, 2, 9999} {
r := f.personal(agents, "v-ok", f.alice)
r.version = &v
r.defID = "v-ok-" + itoa(v%100)
if _, err := f.insert(r); err != nil {
t.Errorf("version %d was refused: %v", v, err)
}
}
}
func TestSkillStatusAndNoVersion(t *testing.T) {
f := newFixture(t, "defs_skillstatus")
// 5. The two skill statuses, and nothing else.
for _, status := range []string{"active", "inactive"} {
r := f.personal(skills, "s-"+status, f.alice)
r.status = status
mustInsert(t, f, r)
}
for _, status := range []string{"draft", "published", "archived", "ACTIVE"} {
r := f.personal(skills, "bad-status", f.bob)
r.status = status
refused(t, f, r, "status_check", "`"+status+"` is not a skill status")
}
// The default is active — a skill is on unless somebody turns it off.
id := mustInsert(t, f, f.personal(skills, "defaulted", f.bob))
var status string
if err := f.pool.QueryRow(f.ctx,
`SELECT status FROM skill_definitions WHERE id = $1::uuid`, id).Scan(&status); err != nil {
t.Fatalf("read back: %v", err)
}
if status != "active" {
t.Errorf("default status = %q, want active", status)
}
// 7. Skills have NO version. The frontend has no notion of one, so the
// column must not exist — inventing it "for symmetry" would create a field
// nothing can set and nothing can mean.
var exists int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*)::int FROM information_schema.columns
WHERE table_schema='public' AND table_name='skill_definitions' AND column_name='version'`).
Scan(&exists); err != nil {
t.Fatalf("look for a version column: %v", err)
}
if exists != 0 {
t.Error("skill_definitions has a version column; skills have no version concept")
}
}
/* ── 8. Markdown bound ──────────────────────────────────────────────────── */
func TestMarkdownSizeBound(t *testing.T) {
f := newFixture(t, "defs_markdown")
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
// The largest definition shipped with the product is 3,156 bytes,
// so anything realistic is far inside the bound.
ok := f.personal(table, "big-but-fine", f.alice)
ok.markdown = strings.Repeat("x", 65536)
mustInsert(t, f, ok)
over := f.personal(table, "too-big", f.bob)
over.markdown = strings.Repeat("x", 65537)
refused(t, f, over, "markdown_size", "a definition over the size bound")
empty := f.personal(table, "empty-md", f.bob)
empty.markdown = ""
refused(t, f, empty, "markdown_size", "an empty definition cannot parse")
})
}
}
// The Markdown is stored byte-for-byte. A definition has to survive a round
// trip to a .md file on disk, so anything that rewrote it here — trimming,
// newline normalisation, unicode folding — would break that.
func TestMarkdownIsStoredVerbatim(t *testing.T) {
f := newFixture(t, "defs_verbatim")
// A BOM, CRLF endings, trailing spaces and a tab — exactly the four things
// normalizeDefinition exists to tolerate. The database must not "help" by
// removing any of them: normalising is the parser's job, on read.
source := "\ufeff---\r\nid: verbatim\r\nname: Verbatim\r\n---\r\n\r\n# Verbatim \r\n\ttabbed\n"
r := f.personal(agents, "verbatim", f.alice)
r.markdown = source
id := mustInsert(t, f, r)
var stored string
if err := f.pool.QueryRow(f.ctx,
`SELECT markdown FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != source {
t.Errorf("the stored Markdown differs from what was written:\n in %q\n out %q", source, stored)
}
}
/* ── 9, 10, 11, 12. Foreign keys and deletion ───────────────────────────── */
func TestForeignKeysAndDeleteBehaviour(t *testing.T) {
f := newFixture(t, "defs_fk")
missing := "00000000-0000-0000-0000-000000000000"
for _, table := range []string{agents, skills} {
t.Run(table+"/rejects unknown references", func(t *testing.T) {
// 9. An organization that does not exist.
bad := f.personal(table, "bad-org", f.alice)
bad.orgID = missing
refused(t, f, bad, "org_id_fkey", "org_id must reference a real organization")
// 10. An owner that does not exist.
bad = f.personal(table, "bad-owner", f.alice)
bad.owner = &missing
refused(t, f, bad, "owner_user_id_fkey", "owner_user_id must reference a real user")
// 11. An author that does not exist.
bad = f.organization(table, "bad-author", f.alice)
bad.createdBy = &missing
refused(t, f, bad, "created_by_fkey", "created_by must reference a real user")
})
}
// 12. Deletion, three behaviours, each different and each deliberate.
t.Run("deleting the owner destroys their personal definitions", func(t *testing.T) {
carol := f.newUser(t, "carol@example.test")
mustInsert(t, f, f.personal(agents, "carols-agent", carol))
mustInsert(t, f, f.personal(skills, "carols-skill", carol))
if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, carol); err != nil {
t.Fatalf("delete the user: %v", err)
}
for _, table := range []string{agents, skills} {
var n int
if err := f.pool.QueryRow(f.ctx,
"SELECT count(*)::int FROM "+table+" WHERE definition_id LIKE 'carols-%'").Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Errorf("%s: %d personal definitions survive their deleted owner, want 0", table, n)
}
}
})
t.Run("deleting the author keeps the organization's definition", func(t *testing.T) {
dave := f.newUser(t, "dave@example.test")
id := mustInsert(t, f, f.organization(agents, "daves-shared-agent", dave))
if _, err := f.pool.Exec(f.ctx, `DELETE FROM users WHERE id = $1::uuid`, dave); err != nil {
t.Fatalf("delete the user: %v", err)
}
var author *string
if err := f.pool.QueryRow(f.ctx,
`SELECT created_by::text FROM agent_definitions WHERE id = $1::uuid`, id).Scan(&author); err != nil {
t.Fatalf("the shared definition did not survive its author: %v", err)
}
if author != nil {
t.Errorf("created_by = %v, want NULL after the author was deleted", *author)
}
})
t.Run("deleting the organization destroys both tiers", func(t *testing.T) {
g := newFixture(t, "defs_orgcascade")
mustInsert(t, g, g.personal(agents, "doomed-personal", g.alice))
mustInsert(t, g, g.organization(skills, "doomed-shared", g.alice))
if _, err := g.pool.Exec(g.ctx, `DELETE FROM organizations WHERE id = $1::uuid`, g.orgID); err != nil {
t.Fatalf("delete the organization: %v", err)
}
for _, table := range []string{agents, skills} {
var n int
if err := g.pool.QueryRow(g.ctx, "SELECT count(*)::int FROM "+table).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Errorf("%s: %d rows survive their deleted organization, want 0", table, n)
}
}
})
}
/* ── 13, 14. Uniqueness, per tier ───────────────────────────────────────── */
func TestUniquenessPerTier(t *testing.T) {
f := newFixture(t, "defs_unique")
for _, table := range []string{agents, skills} {
t.Run(table, func(t *testing.T) {
// 13. One personal definition per id per owner.
mustInsert(t, f, f.personal(table, "board", f.alice))
refused(t, f, f.personal(table, "board", f.alice), "personal_key",
"one user cannot hold two personal definitions of the same id")
// A different user may hold their own, which is the whole point of
// personal definitions.
mustInsert(t, f, f.personal(table, "board", f.bob))
// 14. One organization definition per id per organization.
mustInsert(t, f, f.organization(table, "board", f.alice))
refused(t, f, f.organization(table, "board", f.bob), "org_key",
"one organization cannot hold two shared definitions of the same id")
// Personal and organization definitions of the SAME id coexist:
// that is shadow-by-id, and it is the reason definition_id is not
// globally unique.
var personal, shared int
if err := f.pool.QueryRow(f.ctx,
"SELECT count(*) FILTER (WHERE visibility='personal'), "+
"count(*) FILTER (WHERE visibility='organization') "+
"FROM "+table+" WHERE definition_id = 'board'").Scan(&personal, &shared); err != nil {
t.Fatalf("count: %v", err)
}
if personal != 2 || shared != 1 {
t.Errorf("board: %d personal + %d shared, want 2 + 1", personal, shared)
}
})
}
// A second organization may hold its own definition of the same id.
t.Run("across organizations", func(t *testing.T) {
var otherOrg string
if err := f.pool.QueryRow(f.ctx,
`INSERT INTO organizations (name, slug) VALUES ('Other','other-defs') RETURNING id::text`).
Scan(&otherOrg); err != nil {
t.Fatalf("create the second organization: %v", err)
}
var erin string
if err := f.pool.QueryRow(f.ctx,
`INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid,'erin@example.test','Erin')
RETURNING id::text`, otherOrg).Scan(&erin); err != nil {
t.Fatalf("create a user in the second organization: %v", err)
}
r := f.organization(agents, "board", erin)
r.orgID = otherOrg
mustInsert(t, f, r)
})
}
/* ── Schema shape ───────────────────────────────────────────────────────── */
func TestDefinitionTablesShape(t *testing.T) {
f := newFixture(t, "defs_shape")
shared := map[string]string{
"id": "uuid",
"definition_id": "text",
"org_id": "uuid",
"visibility": "text",
"owner_user_id": "uuid",
"created_by": "text-or-uuid", // placeholder, replaced below
"markdown": "text",
"status": "text",
"name": "text",
"description": "text",
"pages": "ARRAY",
"created_date": "timestamp with time zone",
"updated_date": "timestamp with time zone",
}
shared["created_by"] = "uuid"
nullable := map[string]bool{"owner_user_id": true, "created_by": true}
for _, table := range []string{agents, skills} {
want := map[string]string{}
for k, v := range shared {
want[k] = v
}
if table == agents {
want["version"] = "integer"
}
t.Run(table, func(t *testing.T) {
rows, err := f.pool.Query(f.ctx,
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1`, table)
if err != nil {
t.Fatalf("read columns: %v", err)
}
got := map[string]string{}
for rows.Next() {
var name, kind, isNullable string
if err := rows.Scan(&name, &kind, &isNullable); err != nil {
t.Fatalf("scan: %v", err)
}
got[name] = kind
if (isNullable == "YES") != nullable[name] {
t.Errorf("%s.%s is_nullable=%s, want nullable=%v", table, name, isNullable, nullable[name])
}
}
rows.Close()
if err := rows.Err(); err != nil {
t.Fatalf("read columns: %v", err)
}
for name, kind := range want {
if got[name] == "" {
t.Errorf("%s.%s is missing", table, name)
} else if got[name] != kind {
t.Errorf("%s.%s is %s, want %s", table, name, got[name], kind)
}
}
for name := range got {
if _, expected := want[name]; !expected {
t.Errorf("%s has an unexpected column %q", table, name)
}
}
})
}
}
func TestDefinitionIndexes(t *testing.T) {
f := newFixture(t, "defs_indexes")
want := map[string][]string{
agents: {
"agent_definitions_pkey",
"agent_definitions_personal_key",
"agent_definitions_org_key",
"agent_definitions_org_visibility_idx",
"agent_definitions_owner_idx",
"agent_definitions_published_idx",
},
skills: {
"skill_definitions_pkey",
"skill_definitions_personal_key",
"skill_definitions_org_key",
"skill_definitions_org_visibility_idx",
"skill_definitions_owner_idx",
"skill_definitions_active_idx",
},
}
for table, names := range want {
rows, err := f.pool.Query(f.ctx,
`SELECT indexname, indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=$1`, table)
if err != nil {
t.Fatalf("list indexes: %v", err)
}
got := map[string]string{}
for rows.Next() {
var name, def string
if err := rows.Scan(&name, &def); err != nil {
t.Fatalf("scan: %v", err)
}
got[name] = def
}
rows.Close()
for _, name := range names {
if got[name] == "" {
t.Errorf("%s: index %s is missing", table, name)
}
}
// The two uniqueness indexes must be partial and unique, or they mean
// something other than what they are named.
for _, name := range []string{table + "_personal_key", table + "_org_key"} {
def := got[name]
if !strings.Contains(def, "UNIQUE") {
t.Errorf("%s is not UNIQUE: %s", name, def)
}
if !strings.Contains(def, "WHERE") {
t.Errorf("%s is not partial: %s", name, def)
}
}
}
// created_by is deliberately unindexed: attribution only, no listing is
// keyed by it, and its SET NULL scan happens only when a user is deleted.
for _, table := range []string{agents, skills} {
var n int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*)::int FROM pg_indexes
WHERE schemaname='public' AND tablename=$1 AND indexdef LIKE '%(created_by)%'`,
table).Scan(&n); err != nil {
t.Fatalf("look for a created_by index: %v", err)
}
if n != 0 {
t.Errorf("%s has an index on created_by; it was deliberately omitted", table)
}
}
}
/* ── Reversibility ──────────────────────────────────────────────────────── */
func TestMigration000005IsReversible(t *testing.T) {
ctx := context.Background()
pool := testutil.Sandbox(t, "defs_reversible")
testutil.ApplyAllMigrations(ctx, t, pool)
const up = "000005_agent_skill_definitions.up.sql"
const down = "000005_agent_skill_definitions.down.sql"
exists := func(name string) bool {
var reg *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, name).Scan(&reg); err != nil {
t.Fatalf("to_regclass(%s): %v", name, err)
}
return reg != nil
}
for _, table := range []string{agents, skills} {
if !exists(table) {
t.Fatalf("%s does not exist before the rollback", table)
}
}
if err := testutil.ApplyMigration(ctx, t, pool, down); err != nil {
t.Fatalf("apply %s: %v", down, err)
}
for _, table := range []string{agents, skills} {
if exists(table) {
t.Errorf("%s survived the rollback", table)
}
}
// The rollback must reach nothing that predates it.
for _, table := range []string{"users", "organizations", "sessions", "user_preferences", "job_postings"} {
if !exists(table) {
t.Fatalf("the rollback dropped %s, which 000005 did not create", table)
}
}
// And no enum type was created, so none can be left behind.
var leftover int
if err := pool.QueryRow(ctx,
`SELECT count(*)::int FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname='public' AND t.typtype='e'
AND t.typname IN ('definition_visibility','agent_status','skill_status')`).Scan(&leftover); err != nil {
t.Fatalf("look for leftover types: %v", err)
}
if leftover != 0 {
t.Errorf("%d enum types left behind by the rollback", leftover)
}
// Re-applying restores exactly what was removed.
if err := testutil.ApplyMigration(ctx, t, pool, up); err != nil {
t.Fatalf("re-apply %s: %v", up, err)
}
for _, table := range []string{agents, skills} {
if !exists(table) {
t.Errorf("%s did not come back", table)
}
}
}
// Every migration still has a matching down file, and 000005 is the newest.
func TestMigrationPairsIncluding000005(t *testing.T) {
ups := testutil.MigrationFiles(t, ".up.sql")
downs := testutil.MigrationFiles(t, ".down.sql")
if len(ups) != len(downs) {
t.Fatalf("%d up and %d down migrations", len(ups), len(downs))
}
for i, up := range ups {
want := strings.TrimSuffix(up, ".up.sql") + ".down.sql"
if downs[i] != want {
t.Errorf("%s has no matching down migration (found %s)", up, downs[i])
}
}
if len(ups) != 5 {
t.Errorf("%d migrations, want 5", len(ups))
}
if ups[4] != "000005_agent_skill_definitions.up.sql" {
t.Errorf("the last migration is %s", ups[4])
}
}
// 000005 creates exactly two tables and nothing else. The Phase 4B decision was
// explicit about which tables must NOT appear; this is that decision, asserted.
func TestMigrationAddsExactlyTwoTables(t *testing.T) {
f := newFixture(t, "defs_tablecount")
var n int
if err := f.pool.QueryRow(f.ctx,
`SELECT count(*)::int FROM information_schema.tables
WHERE table_schema='public' AND table_type='BASE TABLE'`).Scan(&n); err != nil {
t.Fatalf("count tables: %v", err)
}
// 17 from 000001 + sessions from 000004 + the two here. schema_migrations is
// golang-migrate's and is absent when the files are applied directly.
if n != 20 {
t.Errorf("%d base tables after every migration, want 20", n)
}
for _, forbidden := range []string{
"definition_versions", "definition_permissions", "agent_skills",
"agent_subagents", "agent_knowledge", "conversations",
"conversation_messages", "conversation_feedback",
} {
var reg *string
if err := f.pool.QueryRow(f.ctx,
`SELECT to_regclass('public.' || $1)::text`, forbidden).Scan(&reg); err != nil {
t.Fatalf("to_regclass: %v", err)
}
if reg != nil {
t.Errorf("table %s exists; Phase 4B deferred or rejected it", forbidden)
}
}
}

View File

@@ -0,0 +1,76 @@
package domain
import "fmt"
// Error is an API-level failure carrying the contract's error code.
// See api-contract.md §5.
type Error struct {
Code string
Message string
Details map[string]string
cause error
}
func (e *Error) Error() string { return e.Message }
func (e *Error) Unwrap() error { return e.cause }
// NotFound reproduces store.js's thrown message verbatim: "<Entity> <id> not
// found", using the frontend's entity name rather than the table name.
func NotFound(entity, id string) *Error {
return &Error{Code: "not_found", Message: fmt.Sprintf("%s %s not found", entity, id)}
}
func Invalid(msg string) *Error {
return &Error{Code: "invalid_query", Message: msg}
}
func Validation(msg string, details map[string]string) *Error {
if details == nil {
details = map[string]string{}
}
return &Error{Code: "validation_failed", Message: msg, Details: details}
}
func Conflict(msg string) *Error {
return &Error{Code: "conflict", Message: msg}
}
// Unauthenticated is every "you are not signed in" answer: no cookie, an
// unknown token, an expired session, a suspended user, a wrong password, an
// email that does not exist.
//
// One constructor for all of them, deliberately. The distinctions matter in the
// server log and must not reach the client: which of those it was tells an
// attacker whether an address is registered, whether an account is suspended,
// or whether a guessed token was ever real.
func Unauthenticated() *Error {
return &Error{Code: "unauthorized", Message: "authentication required"}
}
// Forbidden is the answer to an authenticated caller whose role does not permit
// the operation.
//
// Distinct from Unauthenticated: 401 means "I do not know who you are", 403
// means "I know exactly who you are and the answer is still no". Conflating
// them makes a client retry a login that will not help.
//
// The message names neither the role the caller has nor the roles that would
// have worked. That is not secrecy for its own sake — it is that an endpoint
// which answers "employers only" to a talent user is an endpoint that maps the
// organization's privilege structure for anyone who asks.
//
// Note what does NOT come through here: a row belonging to another
// organization, or to another person, is not forbidden — it is absent. Those
// answer 404 by way of a SQL predicate, so existence never leaks.
func Forbidden() *Error {
return &Error{Code: "forbidden", Message: "you do not have access to this operation"}
}
// RateLimited is the answer to too many failed sign-in attempts.
func RateLimited(msg string) *Error {
return &Error{Code: "rate_limited", Message: msg}
}
func Internal(err error) *Error {
return &Error{Code: "internal", Message: "internal error", cause: err}
}

View File

@@ -0,0 +1,330 @@
package domain
// Authorization policy: who may perform which operation on which resource, and
// which rows they may see when they get there.
//
// This file is hand-written and `resources_gen.go` is generated, which is the
// whole reason they are separate. Regenerating the descriptors from the live
// schema must never silently drop an access rule, and a column appearing in the
// database must never grant anybody anything by accident.
//
// Three properties hold here by construction:
//
// - DENY BY DEFAULT. A resource with no policy permits nothing, to anyone. A
// resource added to the schema tomorrow is unreachable until somebody
// writes down who may reach it. TestEveryResourceHasAPolicy makes the
// omission loud rather than silent.
// - ROLE IS users.role, ALWAYS. Never account_type — which the user can
// change on themselves through PATCH /me — and never anything read from a
// request body, a header or the browser.
// - OWNERSHIP IS A SQL PREDICATE, NOT A FILTER. TalentScope describes a WHERE
// clause the repository adds beside the organization scope. Rows a talent
// user may not see are never fetched, so they cannot leak through a count,
// a total or a bug in a later loop.
//
// Authorization is checked in the handler, before any query runs, and answers
// 403. Organization and ownership are predicates, so a row outside them is
// simply absent and answers 404 — the caller cannot tell "exists but not yours"
// from "does not exist", which is the point.
// Role is the authorization authority. It mirrors the users_role_check
// constraint in migration 000001 and there are deliberately no others.
type Role string
const (
RoleAdmin Role = "admin"
RoleEmployer Role = "employer"
RoleTalent Role = "talent"
)
// ParseRole converts a stored users.role into a Role, reporting whether it is
// one this API recognises. An unrecognised value authorizes nothing.
func ParseRole(s string) (Role, bool) {
switch Role(s) {
case RoleAdmin:
return RoleAdmin, true
case RoleEmployer:
return RoleEmployer, true
case RoleTalent:
return RoleTalent, true
}
return "", false
}
/* ── Row visibility ─────────────────────────────────────────────────────── */
// ScopeKind is how a resource decides which rows a talent user may see.
type ScopeKind int
const (
// ScopeNone: no extra predicate. Every row in the organization is visible.
ScopeNone ScopeKind = iota
// ScopeUserID: Column = the authenticated user's id.
ScopeUserID
// ScopeEmail: Column = the authenticated user's email.
//
// Used where the schema ties a row to a person by email string rather than
// by a foreign key — assignments, evidence, shift records, applications,
// activity. Those columns have no FK (see the Phase 3D audit, F-05), so the
// write path is what makes this trustworthy: a talent caller never supplies
// the value, it is derived from the session. See Derived.
ScopeEmail
// ScopeOwnApplications: the row references a job application belonging to
// the authenticated user. Ownership by reference rather than by column —
// an AI interview names an application, and the application names a person.
ScopeOwnApplications
// ScopeActivePostings: visibility rather than ownership. A talent user sees
// the postings they could apply to, not the organization's drafts, paused
// roles or closed history.
ScopeActivePostings
)
// Scope is the predicate applied to a talent caller's rows.
type Scope struct {
Kind ScopeKind
// Column is the column carrying the owner, for ScopeUserID and ScopeEmail.
// For ScopeOwnApplications it is the column referencing the application.
// For ScopeActivePostings it is the status column.
//
// Required by every kind except ScopeNone: a scope naming a column the
// resource does not have matches no rows at all, which is the safe
// direction to fail but is still a bug worth noticing.
Column string
}
/* ── Server-owned values ────────────────────────────────────────────────── */
// DeriveSource names which fact about the caller fills a column.
type DeriveSource int
const (
DeriveUserID DeriveSource = iota
DeriveEmail
DeriveFullName
DeriveAccountType
)
// Derived is a column the server fills in on insert from the session.
//
// Every column named here is also ReadOnly in the descriptors, so a value in a
// request body is dropped before it reaches SQL. This is the other half: the
// column still has to be filled, and the only acceptable source is the
// authenticated identity.
type Derived struct {
Column string
Source DeriveSource
// TalentOnly restricts the derivation to talent callers.
//
// It exists because two different questions wear the same shape. `created_by`
// and the user_activity columns record WHO ACTED, so they are the session
// user whoever that is. `worker_profiles.user_id`, `job_applications.email`
// and `evidence.worker_email` record WHO THE ROW IS ABOUT — and when an
// admin creates a candidate's profile or logs an application on their
// behalf, the subject is emphatically not the admin. Deriving those
// unconditionally would quietly file every candidate's record under the
// operator who typed it in.
TalentOnly bool
}
/* ── Policy ─────────────────────────────────────────────────────────────── */
// Policy is one resource's access rules.
//
// A nil Policy denies everything. An empty role list for an operation denies
// that operation to everyone, which is how an operation the resource does not
// support is expressed.
type Policy struct {
List []Role
Get []Role
Create []Role
Update []Role
Delete []Role
// TalentScope narrows which rows a talent caller may read or write. It is
// applied to talent and to nobody else: admin and employer see the whole
// organization, which is what an operator console is for.
TalentScope Scope
// Derived fills server-owned columns on insert.
Derived []Derived
}
// Allows reports whether a role may perform an operation.
//
// The zero answer is no: a nil policy, an unknown operation and an unlisted
// role all deny.
func (p *Policy) Allows(op Op, role Role) bool {
if p == nil {
return false
}
for _, r := range p.rolesFor(op) {
if r == role {
return true
}
}
return false
}
func (p *Policy) rolesFor(op Op) []Role {
switch op {
case OpList:
return p.List
case OpGet:
return p.Get
case OpCreate:
return p.Create
case OpUpdate:
return p.Update
case OpDelete:
return p.Delete
}
return nil
}
// ScopeFor returns the row predicate that applies to a role. Only talent is
// scoped; every other role sees the organization.
func (p *Policy) ScopeFor(role Role) Scope {
if p == nil || role != RoleTalent {
return Scope{}
}
return p.TalentScope
}
/* ── The table ──────────────────────────────────────────────────────────── */
var (
everyone = []Role{RoleAdmin, RoleEmployer, RoleTalent}
// operators are the roles that run the organization's hiring and workforce:
// they see and act on the whole tenant. Talent is not one of them.
operators = []Role{RoleAdmin, RoleEmployer}
adminOnly = []Role{RoleAdmin}
)
// policies is the authorization contract, keyed by URL path.
//
// Read this table as the answer to "who may call this, and which rows do they
// get". It is the only place those two questions are answered.
var policies = map[string]*Policy{
// Postings are the organization's shop window. Operators author them;
// talent sees the ones that are open, and nothing else — not the drafts,
// not the paused roles, not the closed history.
"job-postings": {
List: everyone, Get: everyone,
Create: operators, Update: operators,
TalentScope: Scope{Kind: ScopeActivePostings, Column: "status"},
Derived: []Derived{{Column: "created_by", Source: DeriveUserID}},
},
// An application is written by a person about themselves. Talent may file
// one and read their own; only operators may move it through the funnel or
// remove it. A talent caller never supplies the email — it is the session's,
// which is what makes the read predicate below mean anything.
"job-applications": {
List: everyone, Create: everyone,
Update: operators, Delete: operators,
TalentScope: Scope{Kind: ScopeEmail, Column: "email"},
Derived: []Derived{{Column: "email", Source: DeriveEmail, TalentOnly: true}},
},
// An interview belongs to an application, and the application belongs to a
// person. Talent reads their own and may sit one for an application of
// theirs; the insert guard in the repository enforces the second half.
"ai-interviews": {
List: everyone, Create: everyone,
TalentScope: Scope{Kind: ScopeOwnApplications, Column: "application_id"},
},
// The employment record of the organization's workforce. Operators only:
// it carries endorsements, review dates and reviewer names, which are the
// organization's assessment of a person rather than the person's own data.
"staff": {
List: operators, Create: operators, Update: operators,
},
// A worker's own profile: contact details, address, salary expectations,
// personality assessment. Talent may read and maintain theirs and no other.
// user_id is server-owned, so a talent caller cannot claim someone else's
// profile by naming them, and cannot hand theirs away.
"worker-profiles": {
List: everyone, Create: everyone, Update: everyone,
TalentScope: Scope{Kind: ScopeUserID, Column: "user_id"},
Derived: []Derived{{Column: "user_id", Source: DeriveUserID, TalentOnly: true}},
},
// Who is on which position. Operators allocate; talent reads their own
// roster and cannot create one — being assigned to work is not a thing you
// do to yourself.
"assignments": {
List: everyone, Create: operators,
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
},
// Attendance. Read-only for everyone over the API — the seeder owns these
// rows — and talent sees only their own shifts.
"shift-records": {
List: everyone,
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
},
// The training library. Everyone learns from it; only admin authors it.
// Employer is excluded from authoring deliberately: courses with a NULL
// org_id are the shared platform library, visible to every tenant, so a
// write here can reach beyond the writer's own organization.
"courses": {
List: everyone, Get: everyone,
Create: adminOnly, Update: adminOnly,
},
"learning-paths": {List: everyone},
// Organization taxonomy: the role names positions are filed under.
"role-categories": {
List: everyone, Create: operators,
},
// Organization taxonomy: the certifications positions may require.
// Deleting one changes what every existing posting means, so it is admin's.
"certifications": {
List: everyone, Create: operators, Delete: adminOnly,
},
// The audit log. Append-only by schema (no update, no delete). Anyone may
// write an entry about themselves — and only about themselves: all four
// identity columns are server-derived, so an entry cannot be attributed to
// someone else. Operators read the organization's log; talent reads theirs.
"user-activity": {
List: everyone, Create: everyone,
TalentScope: Scope{Kind: ScopeEmail, Column: "user_email"},
Derived: []Derived{
{Column: "user_id", Source: DeriveUserID},
{Column: "user_email", Source: DeriveEmail},
{Column: "user_name", Source: DeriveFullName},
{Column: "account_type", Source: DeriveAccountType},
},
},
// Proof of work: a worker submits it, the organization verifies it.
// Talent may submit their own and read it back; the verdict is an operator
// judgement, so talent cannot PATCH.
"evidence": {
List: everyone, Create: everyone, Update: operators,
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
Derived: []Derived{{Column: "worker_email", Source: DeriveEmail, TalentOnly: true}},
},
// Badges have no endpoints at all (Ops: 0 — the frontend's Badge.list call
// has 404ed since Phase 2C). The empty policy is written out rather than
// omitted so that the resource is deliberately closed rather than merely
// forgotten, and so TestEveryResourceHasAPolicy passes honestly.
"badges": {},
}
// init attaches the policies to the descriptors.
//
// A resource with no entry keeps a nil Policy and therefore permits nothing.
func init() {
for _, r := range AllResources {
r.Policy = policies[r.Path]
}
}

View File

@@ -0,0 +1,191 @@
package domain
import "testing"
// Invariants of the policy table itself. No database: these catch the mistakes
// that would otherwise only show up as a missing 403 in an integration test, or
// not at all.
// Every resource must say who may reach it. A resource added to the schema and
// left out of policies.go is unreachable — which is the safe direction, and
// still a mistake worth failing on rather than discovering in production.
func TestEveryResourceHasAPolicy(t *testing.T) {
for _, r := range AllResources {
if r.Policy == nil {
t.Errorf("resource %q (%s) has no policy: it permits nothing, which is safe but almost certainly unintended",
r.Name, r.Path)
}
}
}
// A nil policy denies everything. This is the property the test above relies on
// being true, so it is asserted rather than assumed.
func TestNilPolicyDeniesEverything(t *testing.T) {
var p *Policy
for _, op := range []Op{OpList, OpGet, OpCreate, OpUpdate, OpDelete} {
for _, role := range []Role{RoleAdmin, RoleEmployer, RoleTalent} {
if p.Allows(op, role) {
t.Errorf("a nil policy allowed op %d for %s", op, role)
}
}
}
if got := p.ScopeFor(RoleTalent); got.Kind != ScopeNone {
t.Error("a nil policy returned a scope")
}
}
// A policy must not grant an operation the resource does not expose. Such a
// grant is dead — no route is registered — but it reads as permission and would
// become real the moment the operation is added.
func TestPolicyGrantsNothingWithoutARoute(t *testing.T) {
ops := []struct {
op Op
name string
}{
{OpList, "List"}, {OpGet, "Get"}, {OpCreate, "Create"},
{OpUpdate, "Update"}, {OpDelete, "Delete"},
}
for _, r := range AllResources {
if r.Policy == nil {
continue
}
for _, o := range ops {
granted := len(r.Policy.rolesFor(o.op)) > 0
if granted && !r.Supports(o.op) {
t.Errorf("%s: policy grants %s but the resource has no such route", r.Path, o.name)
}
}
}
}
// An unrecognised role authorizes nothing, whatever the policy says.
func TestUnknownRoleIsDenied(t *testing.T) {
if _, ok := ParseRole("superuser"); ok {
t.Fatal("ParseRole accepted a role outside the users_role_check constraint")
}
if _, ok := ParseRole(""); ok {
t.Fatal("ParseRole accepted an empty role")
}
for _, r := range AllResources {
if r.Policy.Allows(OpList, Role("superuser")) {
t.Errorf("%s allows an unknown role", r.Path)
}
}
// The three real ones parse.
for _, want := range []Role{RoleAdmin, RoleEmployer, RoleTalent} {
if got, ok := ParseRole(string(want)); !ok || got != want {
t.Errorf("ParseRole(%q) = %q, %v", want, got, ok)
}
}
}
// Every column the server derives must also be ReadOnly, or a request body
// could still set it on a path the derivation does not cover.
func TestDerivedColumnsAreReadOnlyOrTalentScoped(t *testing.T) {
for _, r := range AllResources {
if r.Policy == nil {
continue
}
for _, d := range r.Policy.Derived {
col, ok := r.Column(d.Column)
if !ok {
t.Errorf("%s: derives %q, which is not a column", r.Path, d.Column)
continue
}
// A TalentOnly derivation intentionally leaves the column writable
// for operators — an admin filing a candidate's application must be
// able to say whose it is. The unconditional ones must be sealed.
if !d.TalentOnly && !col.ReadOnly {
t.Errorf("%s.%s is derived unconditionally but is not ReadOnly: a request body could still set it",
r.Path, d.Column)
}
}
}
}
// The six columns Phase 3D closed. Named explicitly, so that regenerating the
// descriptors without the SERVER_OWNED map in gen_resources.py fails loudly
// rather than silently reopening the holes.
func TestServerOwnedColumnsAreReadOnly(t *testing.T) {
sealed := map[string][]string{
"worker-profiles": {"user_id"},
"user-activity": {"user_id", "user_email", "user_name", "account_type"},
"job-postings": {"created_by"},
}
for path, cols := range sealed {
res, ok := ResourceByPath[path]
if !ok {
t.Fatalf("resource %s is missing", path)
}
for _, name := range cols {
col, ok := res.Column(name)
if !ok {
t.Errorf("%s has no column %s", path, name)
continue
}
if !col.ReadOnly {
t.Errorf("%s.%s is not ReadOnly — a client could supply it", path, name)
}
}
}
// And org_id everywhere, which predates Phase 3D and must stay that way.
for _, r := range AllResources {
if col, ok := r.Column("org_id"); ok && !col.ReadOnly {
t.Errorf("%s.org_id is not ReadOnly", r.Path)
}
}
}
// Talent is the only scoped role. If a scope ever applied to an operator the
// admin console would start losing rows, which is a failure mode worth pinning.
func TestOnlyTalentIsRowScoped(t *testing.T) {
for _, r := range AllResources {
for _, role := range []Role{RoleAdmin, RoleEmployer} {
if got := r.Policy.ScopeFor(role); got.Kind != ScopeNone {
t.Errorf("%s scopes rows for %s: operators see the whole organization", r.Path, role)
}
}
}
}
// Every talent scope must name a column the resource actually has.
func TestTalentScopesNameRealColumns(t *testing.T) {
for _, r := range AllResources {
scope := r.Policy.ScopeFor(RoleTalent)
if scope.Kind == ScopeNone {
continue
}
if scope.Column == "" {
t.Errorf("%s has a talent scope with no column", r.Path)
continue
}
if _, ok := r.Column(scope.Column); !ok {
t.Errorf("%s scopes on %q, which is not one of its columns", r.Path, scope.Column)
}
}
}
// Talent must not reach an operator resource by having a scope but no grant,
// or a grant but no scope where one is required. This pins the shape of the
// contract: wherever talent may list a resource that also holds other people's
// rows, a scope must narrow it.
func TestTalentGrantsHaveScopesWhereRowsAreShared(t *testing.T) {
// Resources whose rows are the organization's rather than any one person's:
// a talent grant here is deliberate and needs no ownership predicate.
shared := map[string]bool{
"courses": true, "learning-paths": true,
"role-categories": true, "certifications": true,
}
for _, r := range AllResources {
if !r.Policy.Allows(OpList, RoleTalent) {
continue
}
if shared[r.Path] {
continue
}
if r.Policy.ScopeFor(RoleTalent).Kind == ScopeNone {
t.Errorf("%s: talent may list it but no ownership scope narrows the rows", r.Path)
}
}
}

View File

@@ -0,0 +1,35 @@
package domain
// Record is one row as the API exposes it: the frontend's exact field names
// mapped to JSON-ready values.
//
// A map rather than a struct per resource. The frontend treats every record as
// an opaque bag of fields it round-trips unchanged — `store.js` stores whatever
// it was handed and returns a clone — and fourteen structs totalling ~350
// fields would add a transcription risk without adding a guarantee. Typing
// lives in the Column descriptors instead, where validation and SQL both read
// it from one place.
type Record map[string]any
// ListParams is a parsed, validated collection query.
type ListParams struct {
Sort string // column name, without the leading '-'
Desc bool
Limit int
Offset int
Filters []Filter
}
// Filter is one equality or membership test. See api-contract.md §6.
type Filter struct {
Column *Column
Values []string // len > 1 means IN
}
// Page is a collection result plus the metadata the envelope reports.
type Page struct {
Records []Record
Total int
Limit int
Offset int
}

View File

@@ -0,0 +1,164 @@
// Package domain describes the API's resources: their columns, types and the
// operations each one supports.
//
// The descriptors here are the single place the contract in
// `docs/api-contract.md` is encoded. The repository, service and HTTP layers
// are all driven from them, so a semantic is implemented once and applies
// identically to every resource — which is the point. Fourteen hand-written
// repositories would be fourteen chances to get NULLS LAST wrong.
package domain
import "fmt"
// Kind is a column's value type, as the API presents it.
type Kind int
const (
KindString Kind = iota
KindInt
KindFloat
KindBool
KindTimestamp
KindDate
KindTextArray
KindJSON
KindUUID
KindEnum
)
// Op is a supported operation, as a bit set.
type Op uint8
const (
OpList Op = 1 << iota
OpGet
OpCreate
OpUpdate
OpDelete
)
// Column is one database column and how the API treats it.
type Column struct {
Name string
Kind Kind
PGType string // the type every parameter is explicitly cast to
NotNull bool // database-level NOT NULL
ReadOnly bool // server-owned: ignored if present in a request body
Required bool // must be supplied, non-blank, on create
Enum []string // permitted values when Kind == KindEnum
}
// Resource is one API resource and its backing table.
type Resource struct {
Name string // frontend entity name, used verbatim in error messages
Path string // URL segment
Table string
Columns []Column
DefaultSort string
DefaultLimit int
Ops Op
// OrgNullable marks a table where a NULL org_id means "shared across every
// organization" — the platform course library. Reads match org OR NULL.
OrgNullable bool
// Policy is who may do what, and which rows they see. Attached from
// policy.go, which is hand-written; nil means the resource permits nothing.
Policy *Policy
byName map[string]*Column
}
// Supports reports whether the resource exposes an operation.
func (r *Resource) Supports(op Op) bool { return r.Ops&op != 0 }
// Column looks a column up by name.
func (r *Resource) Column(name string) (*Column, bool) {
if r.byName == nil {
r.byName = make(map[string]*Column, len(r.Columns))
for i := range r.Columns {
r.byName[r.Columns[i].Name] = &r.Columns[i]
}
}
c, ok := r.byName[name]
return c, ok
}
// Filterable reports whether a column may appear as a query filter.
//
// Arrays and JSON are excluded deliberately. `store.js` compares with `===`,
// so a filter against an array column matches nothing today; supporting
// containment here would be a silent behaviour change, not a fix.
// See api-contract.md §6.
func (r *Resource) Filterable(name string) bool {
c, ok := r.Column(name)
if !ok {
return false
}
switch c.Kind {
case KindTextArray, KindJSON:
return false
}
return true
}
// Sortable reports whether a column may be sorted on. Any real column may be.
func (r *Resource) Sortable(name string) bool {
_, ok := r.Column(name)
return ok
}
// SelectExpr is the SQL that reads one column back in its API representation.
//
// The casts are not cosmetic. pgx hands back a [16]byte for uuid and a
// pgtype.Numeric for numeric, neither of which JSON-encodes as the frontend
// expects, and both are cheaper to fix in the projection than in Go.
func (c Column) SelectExpr() string {
switch c.Kind {
case KindUUID:
return fmt.Sprintf("%s::text AS %s", c.Name, c.Name)
case KindFloat:
return fmt.Sprintf("%s::float8 AS %s", c.Name, c.Name)
case KindDate:
return fmt.Sprintf("to_char(%s, 'YYYY-MM-DD') AS %s", c.Name, c.Name)
case KindTimestamp:
// Reproduces the millisecond ISO-8601 form the seed data uses, so a
// record read back over HTTP is byte-identical to what the frontend
// has always seen from localStorage.
return fmt.Sprintf(
`to_char(%s AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS %s`, c.Name, c.Name)
case KindString:
if c.PGType == "citext" {
// pgx has no codec registered for citext, so without this the value
// comes back as an unmapped type rather than a string.
return fmt.Sprintf("%s::text AS %s", c.Name, c.Name)
}
return c.Name
case KindInt:
if c.PGType == "bigint" {
// user_activity.id is an identity bigint. Every id the frontend
// handles is an opaque string, so it stays one here too.
return fmt.Sprintf("%s::text AS %s", c.Name, c.Name)
}
return c.Name
default:
return c.Name
}
}
// ResourceByPath indexes AllResources by URL segment.
var ResourceByPath = func() map[string]*Resource {
m := make(map[string]*Resource, len(AllResources))
for _, r := range AllResources {
m[r.Path] = r
}
return m
}()
// ResourceByTable indexes AllResources by table name.
var ResourceByTable = func() map[string]*Resource {
m := make(map[string]*Resource, len(AllResources))
for _, r := range AllResources {
m[r.Table] = r
}
return m
}()

View File

@@ -0,0 +1,396 @@
// Code generated by scripts/gen_resources.py. DO NOT EDIT BY HAND.
// Regenerate with: make gen-resources
//
// Column names, types, enum values and nullability are read out of
// information_schema so they cannot drift from the migrations. The
// per-resource metadata (path, default sort, default limit, supported
// operations, required fields) comes from docs/api-contract.md.
package domain
// AllResources is every resource the API serves.
var AllResources = []*Resource{
{
Name: "JobPosting", Path: "job-postings", Table: "job_postings",
DefaultSort: "-created_date", DefaultLimit: 100,
Ops: OpList | OpGet | OpCreate | OpUpdate,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "created_by", Kind: KindUUID, PGType: "uuid", ReadOnly: true},
{Name: "company", Kind: KindString, PGType: "text", NotNull: true},
{Name: "title", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "role_category", Kind: KindString, PGType: "text", NotNull: true},
{Name: "description", Kind: KindString, PGType: "text", NotNull: true},
{Name: "responsibilities", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "qualifications", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "nice_to_haves", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "custom_requirements", Kind: KindString, PGType: "text", NotNull: true},
{Name: "physical_requirements", Kind: KindString, PGType: "text", NotNull: true},
{Name: "leadership_expectations", Kind: KindString, PGType: "text", NotNull: true},
{Name: "attendance_expectations", Kind: KindString, PGType: "text", NotNull: true},
{Name: "min_experience_years", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "english_required", Kind: KindEnum, PGType: "english_level", NotNull: true, Enum: []string{"basic", "conversational", "fluent", "native"}},
{Name: "certifications_required", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "skill_requirements", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "pay_range_min", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "pay_range_max", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "location", Kind: KindString, PGType: "text", NotNull: true},
{Name: "status", Kind: KindEnum, PGType: "posting_status", NotNull: true, Enum: []string{"draft", "active", "paused", "closed"}},
{Name: "ai_generated", Kind: KindBool, PGType: "boolean", NotNull: true},
{Name: "headcount", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "start_date", Kind: KindDate, PGType: "date"},
{Name: "duration_months", Kind: KindFloat, PGType: "numeric"},
{Name: "priority", Kind: KindEnum, PGType: "posting_priority", NotNull: true, Enum: []string{"urgent", "high", "normal"}},
{Name: "vetting_criteria", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "JobApplication", Path: "job-applications", Table: "job_applications",
DefaultSort: "-ai_score", DefaultLimit: 200,
Ops: OpList | OpCreate | OpUpdate | OpDelete,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "job_posting_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true},
{Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"},
{Name: "job_title", Kind: KindString, PGType: "text", NotNull: true},
{Name: "applicant_name", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "email", Kind: KindString, PGType: "citext", NotNull: true, Required: true},
{Name: "phone", Kind: KindString, PGType: "text", NotNull: true},
{Name: "years_experience", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "english_level", Kind: KindEnum, PGType: "english_level", NotNull: true, Enum: []string{"basic", "conversational", "fluent", "native"}},
{Name: "certifications", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "availability", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "skills", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "companies_worked", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "client_rating", Kind: KindFloat, PGType: "numeric", NotNull: true},
{Name: "professional_summary", Kind: KindString, PGType: "text", NotNull: true},
{Name: "cover_letter", Kind: KindString, PGType: "text", NotNull: true},
{Name: "selfie_url", Kind: KindString, PGType: "text", NotNull: true},
{Name: "status", Kind: KindEnum, PGType: "application_status", NotNull: true, Enum: []string{"applied", "ai_screened", "shortlisted", "interview", "hired", "rejected", "assigned"}},
{Name: "ai_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "ai_match_label", Kind: KindString, PGType: "text", NotNull: true},
{Name: "ai_summary", Kind: KindString, PGType: "text", NotNull: true},
{Name: "ai_strengths", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "ai_gaps", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "ai_recommendation", Kind: KindString, PGType: "text", NotNull: true},
{Name: "score_breakdown", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "screened_at", Kind: KindTimestamp, PGType: "timestamptz"},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "interview_id", Kind: KindUUID, PGType: "uuid"},
},
},
{
Name: "AIInterview", Path: "ai-interviews", Table: "ai_interviews",
DefaultSort: "-created_date", DefaultLimit: 100,
Ops: OpList | OpCreate,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "application_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true},
{Name: "job_posting_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true},
{Name: "job_title", Kind: KindString, PGType: "text", NotNull: true},
{Name: "candidate_name", Kind: KindString, PGType: "text", NotNull: true},
{Name: "messages", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "overall_interview_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "verdict", Kind: KindEnum, PGType: "interview_verdict", NotNull: true, Enum: []string{"hire", "maybe", "no"}},
{Name: "hire_recommendation", Kind: KindString, PGType: "text", NotNull: true},
{Name: "integrity_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "ai_flags", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "category_scores", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "strengths", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "concerns", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "best_fit_roles", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "summary", Kind: KindString, PGType: "text", NotNull: true},
{Name: "reasoning", Kind: KindString, PGType: "text", NotNull: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "Staff", Path: "staff", Table: "staff",
DefaultSort: "-created_date", DefaultLimit: 100,
Ops: OpList | OpCreate | OpUpdate,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "application_id", Kind: KindUUID, PGType: "uuid"},
{Name: "job_posting_id", Kind: KindUUID, PGType: "uuid"},
{Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"},
{Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "email", Kind: KindString, PGType: "citext", NotNull: true, Required: true},
{Name: "phone", Kind: KindString, PGType: "text", NotNull: true},
{Name: "role", Kind: KindString, PGType: "text", NotNull: true},
{Name: "profile_tier", Kind: KindEnum, PGType: "profile_tier", NotNull: true, Enum: []string{"Beginner", "Cross-Trained", "Skilled"}},
{Name: "hire_date", Kind: KindDate, PGType: "date", NotNull: true, Required: true},
{Name: "ai_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "status", Kind: KindEnum, PGType: "staff_status", NotNull: true, Enum: []string{"onboarding", "active", "inactive"}},
{Name: "client_rating", Kind: KindFloat, PGType: "numeric", NotNull: true},
{Name: "endorsement_text", Kind: KindString, PGType: "text", NotNull: true},
{Name: "endorsed_skills", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "review_date", Kind: KindDate, PGType: "date"},
{Name: "reviewer_name", Kind: KindString, PGType: "text", NotNull: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "WorkerProfile", Path: "worker-profiles", Table: "worker_profiles",
DefaultSort: "-krow_score", DefaultLimit: 500,
Ops: OpList | OpCreate | OpUpdate,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "user_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true},
{Name: "full_name", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "email", Kind: KindString, PGType: "citext", NotNull: true, Required: true},
{Name: "phone", Kind: KindString, PGType: "text", NotNull: true},
{Name: "address", Kind: KindString, PGType: "text", NotNull: true},
{Name: "selfie_url", Kind: KindString, PGType: "text", NotNull: true},
{Name: "languages", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "availability", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "transportation", Kind: KindString, PGType: "text", NotNull: true},
{Name: "certifications", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "experience", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "experience_years", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "current_position", Kind: KindString, PGType: "text", NotNull: true},
{Name: "desired_position", Kind: KindString, PGType: "text", NotNull: true},
{Name: "career_goals", Kind: KindString, PGType: "text", NotNull: true},
{Name: "skills", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "industries", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "personality", Kind: KindString, PGType: "text", NotNull: true},
{Name: "strengths", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "weaknesses", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "communication_style", Kind: KindString, PGType: "text", NotNull: true},
{Name: "salary_expectations", Kind: KindString, PGType: "text", NotNull: true},
{Name: "leadership_potential", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "ai_interview_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "krow_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "reliability_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "profile_completion", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "xp", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "completed_courses", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "earned_badges", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "capabilities", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "shifts_completed", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "attendance_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "performance_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "client_rating", Kind: KindFloat, PGType: "numeric", NotNull: true},
{Name: "supervisor_rating", Kind: KindFloat, PGType: "numeric", NotNull: true},
{Name: "status", Kind: KindString, PGType: "text", NotNull: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "score_breakdown", Kind: KindJSON, PGType: "jsonb", NotNull: true},
},
},
{
Name: "Course", Path: "courses", Table: "courses",
DefaultSort: "-created_date", DefaultLimit: 200,
Ops: OpList | OpGet | OpCreate | OpUpdate,
OrgNullable: true,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true},
{Name: "title", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "description", Kind: KindString, PGType: "text", NotNull: true},
{Name: "category", Kind: KindString, PGType: "text", NotNull: true},
{Name: "difficulty", Kind: KindString, PGType: "text", NotNull: true},
{Name: "xp", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "estimated_minutes", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "badge_reward", Kind: KindString, PGType: "text"},
{Name: "proof_skill", Kind: KindString, PGType: "text", NotNull: true},
{Name: "skill_id", Kind: KindString, PGType: "text"},
{Name: "target_level", Kind: KindEnum, PGType: "skill_level", Enum: []string{"beginner", "intermediate", "advanced", "expert"}},
{Name: "required_level", Kind: KindEnum, PGType: "skill_level", Enum: []string{"beginner", "intermediate", "advanced", "expert"}},
{Name: "completion_criteria", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "verification_criteria", Kind: KindTextArray, PGType: "text[]", NotNull: true},
{Name: "challenge", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "unlock_requirements", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "quiz", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "pass_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "status", Kind: KindEnum, PGType: "course_status", NotNull: true, Enum: []string{"active", "inactive"}},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "training_outline", Kind: KindTextArray, PGType: "text[]", NotNull: true},
},
},
{
Name: "LearningPath", Path: "learning-paths", Table: "learning_paths",
DefaultSort: "-created_date", DefaultLimit: 100,
Ops: OpList,
OrgNullable: true,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true},
{Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "target_role", Kind: KindString, PGType: "text", NotNull: true},
{Name: "description", Kind: KindString, PGType: "text", NotNull: true},
{Name: "difficulty", Kind: KindString, PGType: "text", NotNull: true},
{Name: "steps", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "RoleCategory", Path: "role-categories", Table: "role_categories",
DefaultSort: "-created_date", DefaultLimit: 100,
Ops: OpList | OpCreate,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "Certification", Path: "certifications", Table: "certifications",
DefaultSort: "-created_date", DefaultLimit: 200,
Ops: OpList | OpCreate | OpDelete,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "UserActivity", Path: "user-activity", Table: "user_activity",
DefaultSort: "-created_date", DefaultLimit: 500,
Ops: OpList | OpCreate,
Columns: []Column{
{Name: "id", Kind: KindInt, PGType: "bigint", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "event_type", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "user_id", Kind: KindUUID, PGType: "uuid", ReadOnly: true},
{Name: "user_email", Kind: KindString, PGType: "citext", NotNull: true, ReadOnly: true},
{Name: "user_name", Kind: KindString, PGType: "text", NotNull: true, ReadOnly: true},
{Name: "account_type", Kind: KindString, PGType: "text", NotNull: true, ReadOnly: true},
{Name: "details", Kind: KindString, PGType: "text", NotNull: true},
{Name: "position_id", Kind: KindUUID, PGType: "uuid"},
{Name: "application_id", Kind: KindUUID, PGType: "uuid"},
{Name: "candidate_id", Kind: KindUUID, PGType: "uuid"},
{Name: "interview_id", Kind: KindUUID, PGType: "uuid"},
{Name: "worker_email", Kind: KindString, PGType: "citext"},
{Name: "metadata", Kind: KindJSON, PGType: "jsonb"},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "Evidence", Path: "evidence", Table: "evidence",
DefaultSort: "-created_date", DefaultLimit: 200,
Ops: OpList | OpCreate | OpUpdate,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "course_id", Kind: KindUUID, PGType: "uuid"},
{Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"},
{Name: "course_title", Kind: KindString, PGType: "text", NotNull: true},
{Name: "skill", Kind: KindString, PGType: "text", NotNull: true},
{Name: "worker_email", Kind: KindString, PGType: "citext", NotNull: true, Required: true},
{Name: "worker_name", Kind: KindString, PGType: "text", NotNull: true},
{Name: "type", Kind: KindEnum, PGType: "challenge_type", NotNull: true, Required: true, Enum: []string{"roleplay", "video", "photo_identify"}},
{Name: "media_url", Kind: KindString, PGType: "text", NotNull: true},
{Name: "transcript", Kind: KindString, PGType: "text", NotNull: true},
{Name: "ai_verdict", Kind: KindEnum, PGType: "evidence_verdict", NotNull: true, Enum: []string{"verified", "needs_work", "failed"}},
{Name: "ai_score", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "ai_rubric", Kind: KindJSON, PGType: "jsonb", NotNull: true},
{Name: "ai_feedback", Kind: KindString, PGType: "text", NotNull: true},
{Name: "supervisor_verified", Kind: KindBool, PGType: "boolean", NotNull: true},
{Name: "supervisor_name", Kind: KindString, PGType: "text", NotNull: true},
{Name: "verified_date", Kind: KindTimestamp, PGType: "timestamptz"},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "Assignment", Path: "assignments", Table: "assignments",
DefaultSort: "-created_date", DefaultLimit: 500,
Ops: OpList | OpCreate,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "job_posting_id", Kind: KindUUID, PGType: "uuid", NotNull: true, Required: true},
{Name: "application_id", Kind: KindUUID, PGType: "uuid"},
{Name: "worker_profile_id", Kind: KindUUID, PGType: "uuid"},
{Name: "worker_email", Kind: KindString, PGType: "citext", NotNull: true, Required: true},
{Name: "worker_name", Kind: KindString, PGType: "text", NotNull: true},
{Name: "starts_at", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, Required: true},
{Name: "ends_at", Kind: KindTimestamp, PGType: "timestamptz"},
{Name: "status", Kind: KindEnum, PGType: "assignment_status", NotNull: true, Enum: []string{"active", "completed", "cancelled"}},
{Name: "source", Kind: KindString, PGType: "text", NotNull: true},
{Name: "match_score", Kind: KindInt, PGType: "int"},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
{
Name: "ShiftRecord", Path: "shift-records", Table: "shift_records",
DefaultSort: "-created_date", DefaultLimit: 500,
Ops: OpList,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "staff_id", Kind: KindUUID, PGType: "uuid"},
{Name: "assignment_id", Kind: KindUUID, PGType: "uuid"},
{Name: "job_posting_id", Kind: KindUUID, PGType: "uuid"},
{Name: "worker_name", Kind: KindString, PGType: "text", NotNull: true},
{Name: "worker_email", Kind: KindString, PGType: "citext", NotNull: true},
{Name: "role", Kind: KindString, PGType: "text", NotNull: true},
{Name: "role_category", Kind: KindString, PGType: "text", NotNull: true},
{Name: "shift_date", Kind: KindDate, PGType: "date", NotNull: true},
{Name: "scheduled_start", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true},
{Name: "scheduled_end", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true},
{Name: "scheduled_hours", Kind: KindFloat, PGType: "numeric", NotNull: true},
{Name: "actual_start", Kind: KindTimestamp, PGType: "timestamptz"},
{Name: "actual_end", Kind: KindTimestamp, PGType: "timestamptz"},
{Name: "actual_hours", Kind: KindFloat, PGType: "numeric", NotNull: true},
{Name: "overtime_hours", Kind: KindFloat, PGType: "numeric", NotNull: true},
{Name: "minutes_late", Kind: KindInt, PGType: "int", NotNull: true},
{Name: "status", Kind: KindEnum, PGType: "shift_status", NotNull: true, Enum: []string{"present", "late", "absent", "no_show"}},
{Name: "notes", Kind: KindString, PGType: "text", NotNull: true},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
// Badge serves NO endpoint: useBadges has zero consumers and every
// badge the UI renders comes from worker_profiles.earned_badges. The
// descriptor exists so the seeder can write the table. api-contract.md §2.
{
Name: "Badge", Path: "badges", Table: "badges",
DefaultSort: "-created_date", DefaultLimit: 200,
Ops: 0,
Columns: []Column{
{Name: "id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "legacy_id", Kind: KindString, PGType: "text", ReadOnly: true},
{Name: "org_id", Kind: KindUUID, PGType: "uuid", NotNull: true, ReadOnly: true},
{Name: "name", Kind: KindString, PGType: "text", NotNull: true, Required: true},
{Name: "description", Kind: KindString, PGType: "text", NotNull: true},
{Name: "image_url", Kind: KindString, PGType: "text", NotNull: true},
{Name: "level", Kind: KindEnum, PGType: "badge_level", NotNull: true, Enum: []string{"bronze", "silver", "gold", "platinum"}},
{Name: "requirements", Kind: KindString, PGType: "text", NotNull: true},
{Name: "expiration_months", Kind: KindInt, PGType: "int"},
{Name: "verification_status", Kind: KindEnum, PGType: "badge_verification", NotNull: true, Enum: []string{"pending", "verified", "expired"}},
{Name: "created_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
{Name: "updated_date", Kind: KindTimestamp, PGType: "timestamptz", NotNull: true, ReadOnly: true},
},
},
}