first commit
This commit is contained in:
748
go-api/internal/domain/definitions_schema_test.go
Normal file
748
go-api/internal/domain/definitions_schema_test.go
Normal 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(®); 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(®); err != nil {
|
||||
t.Fatalf("to_regclass: %v", err)
|
||||
}
|
||||
if reg != nil {
|
||||
t.Errorf("table %s exists; Phase 4B deferred or rejected it", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
go-api/internal/domain/errors.go
Normal file
76
go-api/internal/domain/errors.go
Normal 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}
|
||||
}
|
||||
330
go-api/internal/domain/policy.go
Normal file
330
go-api/internal/domain/policy.go
Normal 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]
|
||||
}
|
||||
}
|
||||
191
go-api/internal/domain/policy_test.go
Normal file
191
go-api/internal/domain/policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
go-api/internal/domain/record.go
Normal file
35
go-api/internal/domain/record.go
Normal 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
|
||||
}
|
||||
164
go-api/internal/domain/resource.go
Normal file
164
go-api/internal/domain/resource.go
Normal 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
|
||||
}()
|
||||
396
go-api/internal/domain/resources_gen.go
Normal file
396
go-api/internal/domain/resources_gen.go
Normal 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},
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user