From d1425f974c324238598668f6e87ba5bd765fa180 Mon Sep 17 00:00:00 2001 From: Aravind Date: Tue, 15 Sep 2026 11:49:56 +0530 Subject: [PATCH] chore(ts-migration): add generated entity types Phase 3.5. Entity record shapes, generated from the backend's resource registry rather than transcribed from it. Type-only: every touched file emits byte-identical JavaScript, and the two type modules emit nothing at all. `scripts/gen-entity-types.mjs` reads `krow-backend/go-api/internal/domain/resources_gen.go` - itself generated out of information_schema, so it cannot drift from the migrations - and writes `src/types/entities.generated.ts`: 15 resources, 298 columns. It checks for drift by default and rewrites with --write, the same arrangement seed-fixture.mjs uses, and skips cleanly when the backend is not checked out beside this repo. Field types come from `Column.SelectExpr()` in `domain/resource.go`, which is what the read projection actually emits, not from the Postgres type. The two differ: uuid and citext are cast to text, numeric to float8, dates and timestamps to formatted strings, and - the case that justifies generating rather than typing by hand - `user_activity.id` is an identity bigint cast to text, so it arrives as a STRING. Written by hand it would have been called a number, and nothing would have contradicted that until a comparison quietly stopped matching. The five Phase 3 leaf utilities swap their `any` placeholders for these records, as `Partial<...>`: each takes `= {}` or guards every read because it renders before the query resolves, and requiring the whole record would force those defaults out - a behaviour change in a scoring path. jsonb is where the generator stops. Thirteen columns across eight entities are typed `unknown`, correctly: what sits inside a jsonb column is not in information_schema and nothing on the backend declares it. Where a module reads through one it is narrowed to `unknown[]`, `any[]` or `any` - what kind of value it is, and nothing about its contents. An earlier draft declared the fields these modules read off them; it was removed. That would have been inventing a schema the database does not hold, with the compiler then defending the guess. Not wired into skill-check.mjs: that file is being changed concurrently by unrelated feature work. Adding `"types:check": "node scripts/gen-entity-types.mjs"` beside the existing seed:check is the natural next step and is deliberately left for when that file is quiet. Verified in isolation from the parallel feature work (commit 1775395 plus these eight files only): tsc 64 errors with an unchanged histogram, skill-check 1641/1642 with only the known stale-fixture failure, Owliver baseline 59/59, lint 0 errors, production build succeeds with the API origin inlined. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8 --- scripts/gen-entity-types.mjs | 209 +++++++++++++ src/lib/employeeRoleModel.ts | 17 +- src/lib/krowScore.ts | 37 ++- src/lib/provingGround.ts | 29 +- src/lib/talentHome.ts | 29 +- src/lib/talentInsights.ts | 20 +- src/types/entities.generated.ts | 513 ++++++++++++++++++++++++++++++++ src/types/entities.ts | 60 +++- 8 files changed, 855 insertions(+), 59 deletions(-) create mode 100644 scripts/gen-entity-types.mjs create mode 100644 src/types/entities.generated.ts diff --git a/scripts/gen-entity-types.mjs b/scripts/gen-entity-types.mjs new file mode 100644 index 0000000..513fb2d --- /dev/null +++ b/scripts/gen-entity-types.mjs @@ -0,0 +1,209 @@ +/** + * Generates `src/types/entities.generated.ts` from the backend's resource + * registry. + * + * node scripts/gen-entity-types.mjs # check: exits non-zero if stale + * node scripts/gen-entity-types.mjs --write # regenerate + * + * The same arrangement as `seed-fixture.mjs`, and for the same reason: two + * descriptions of one shape, maintained by hand, only ever fail quietly. Here + * the two are the columns Postgres actually has and the fields this app + * believes it will be sent. + * + * ## Why generate rather than transcribe + * + * `krow-backend/go-api/internal/domain/resources_gen.go` is itself generated, + * out of `information_schema`, and says so: "Column names, types, enum values + * and nullability are read out of information_schema so they cannot drift from + * the migrations." It is the closest thing to the database that can be read + * without connecting to one — 15 resources, 298 columns, every one carrying its + * kind, its nullability and its permitted values. + * + * Two hundred and ninety-eight fields retyped by hand would be two hundred and + * ninety-eight chances to be confidently wrong, and a type that is wrong is + * worse than no type: it is a claim the compiler will defend. + * + * ## Where the TypeScript types come from + * + * Not from the Postgres type — from `Column.SelectExpr()` in + * `domain/resource.go`, which is what the read projection actually emits. The + * two differ, and the differences are the whole point of reading the code + * rather than the schema: + * + * uuid ::text -> string (never a byte array) + * numeric ::float8 -> number (never pgtype.Numeric) + * date to_char(…,'YYYY-MM-DD') -> string (a date, not a timestamp) + * timestamptz to_char(…ISO with ms…) -> string + * citext ::text -> string + * bigint ::text -> string ← user_activity.id only + * int (uncast) -> number + * + * That last one is the case worth the whole exercise: `user_activity.id` is an + * identity bigint and arrives as a STRING, because every id the frontend + * handles is an opaque string. A hand-written interface would have called it a + * number, and nothing would have contradicted that until a comparison silently + * stopped matching. + * + * Nullability is the column's: `NotNull: true` becomes a required field, its + * absence becomes `| null`. Every column is emitted because the projection + * emits every column — `Repo.selectList()` maps over `res.Columns` with no + * filter, and §4.1 of the API contract states the record is complete. + * + * ## What this does NOT generate + * + * Three of the eighteen entities the client knows are absent from the registry + * because they are not served by the generic entity machinery: AgentDefinition + * and SkillDefinition have dedicated handlers in `httpserver/definitions.go`, + * and the user is `httpserver/me.go`. Their shapes are hand-written in + * `src/types/user.ts` and `src/types/entities.ts`. This file does not invent + * them. + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** The registry, read from the sibling checkout. */ +export const REGISTRY_PATH = join( + process.cwd(), '..', 'krow-backend', 'go-api', 'internal', 'domain', 'resources_gen.go'); + +export const OUTPUT_PATH = join(process.cwd(), 'src', 'types', 'entities.generated.ts'); + +/** + * Kind -> TypeScript, following `Column.SelectExpr()` rather than the column's + * Postgres type. `pgType` is needed because two kinds cast conditionally. + */ +function tsTypeFor(kind, pgType, enumValues) { + switch (kind) { + case 'KindUUID': return 'string'; // ::text + case 'KindString': return 'string'; // citext also ::text + case 'KindBool': return 'boolean'; + case 'KindFloat': return 'number'; // ::float8 + case 'KindInt': return pgType === 'bigint' ? 'string' : 'number'; + case 'KindDate': return 'string'; // 'YYYY-MM-DD' + case 'KindTimestamp': return 'string'; // ISO-8601 with ms + case 'KindTextArray': return 'string[]'; + case 'KindEnum': return enumValues.map((v) => `'${v}'`).join(' | '); + case 'KindJSON': return 'unknown'; // jsonb: shape is the column's own + default: throw new Error(`unmapped Kind: ${kind}`); + } +} + +/** Parses the Go registry into `[{ name, path, table, columns }]`. */ +export function parseRegistry(source) { + const resources = []; + /* Each resource opens with a lone `{` one tab in, then a `Name:` line two + tabs in. Splitting on that opener gives one block per resource; the header + match rejects anything that is not one. */ + const blocks = source.split(/\n\t\{\n/).slice(1); + for (const block of blocks) { + const head = block.match(/^\t\tName: "(\w+)", Path: "([\w-]+)", Table: "(\w+)",/); + if (!head) continue; + const [, name, path, table] = head; + const columns = []; + const colRe = /\{Name: "(\w+)", Kind: (Kind\w+), PGType: "([^"]+)"([^}]*)\}/g; + let m; + while ((m = colRe.exec(block)) !== null) { + const [, colName, kind, pgType, rest] = m; + /* `rest` is already cut at the first `}`, which for an enum column is + the one closing `[]string{...}` — so the values are in it but the + brace is not. Requiring a closing brace here matched nothing and + silently produced empty unions. */ + const enumMatch = rest.match(/Enum: \[\]string\{([^}]*)/); + const enumValues = enumMatch + ? [...enumMatch[1].matchAll(/"([^"]*)"/g)].map((e) => e[1]) + : []; + columns.push({ + name: colName, + kind, + pgType, + notNull: /NotNull: true/.test(rest), + readOnly: /ReadOnly: true/.test(rest), + required: /Required: true/.test(rest), + enumValues, + }); + } + if (columns.length) resources.push({ name, path, table, columns }); + } + return resources; +} + +const FLAGS = (c) => { + const notes = []; + if (c.readOnly) notes.push('server-owned'); + if (c.required) notes.push('required on create'); + return notes.length ? ` /** ${notes.join('; ')}. */\n` : ''; +}; + +/** One resource -> one exported interface. */ +function renderInterface(res) { + const fields = res.columns.map((c) => { + const ts = tsTypeFor(c.kind, c.pgType, c.enumValues); + /* A nullable column returns JSON null, so the field is present and null + rather than absent. `?:` would describe a key that can be missing, which + is not what the projection does. */ + const type = c.notNull ? ts : `${ts} | null`; + return `${FLAGS(c)} ${c.name}: ${type};`; + }).join('\n'); + return `/** \`${res.path}\` — the \`${res.table}\` table, every column the projection returns. */\nexport interface ${res.name} {\n${fields}\n}`; +} + +export function render(resources) { + const banner = `/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Regenerate with: node scripts/gen-entity-types.mjs --write + * Source of truth: krow-backend/go-api/internal/domain/resources_gen.go + * (itself generated from information_schema) + * + * Field types follow \`Column.SelectExpr()\` in \`domain/resource.go\` — what the + * read projection emits — not the raw Postgres type. See the generator's header + * for the mapping and for why the two differ. + * + * ${resources.length} resources, ${resources.reduce((n, r) => n + r.columns.length, 0)} columns. + */ +`; + const interfaces = resources.map(renderInterface).join('\n\n'); + const names = resources.map((r) => ` | '${r.name}'`).join('\n'); + const mapEntries = resources.map((r) => ` ${r.name}: ${r.name};`).join('\n'); + const tail = `/** The entity names the generic registry serves. Three more exist — see \`entities.ts\`. */ +export type GeneratedEntityName = +${names}; + +/** Entity name -> its record type, for looking a record up by name. */ +export interface GeneratedEntityRecords { +${mapEntries} +} +`; + return `${banner}\n${interfaces}\n\n${tail}`; +} + +/* ── CLI ──────────────────────────────────────────────────────────────────── */ +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + if (!existsSync(REGISTRY_PATH)) { + console.log('krow-backend is not checked out beside this repo; entity types not checked here.'); + console.log('The types in src/types/entities.generated.ts are committed, so this is not fatal.'); + process.exit(0); + } + + const resources = parseRegistry(readFileSync(REGISTRY_PATH, 'utf8')); + if (!resources.length) { + console.error('parsed no resources out of resources_gen.go — has its shape changed?'); + process.exit(1); + } + const generated = render(resources); + + if (process.argv.includes('--write')) { + writeFileSync(OUTPUT_PATH, generated); + const cols = resources.reduce((n, r) => n + r.columns.length, 0); + console.log(`entities.generated.ts written — ${resources.length} resources, ${cols} columns.`); + } else if (!existsSync(OUTPUT_PATH)) { + console.error('entities.generated.ts is missing. Run: node scripts/gen-entity-types.mjs --write'); + process.exit(1); + } else if (readFileSync(OUTPUT_PATH, 'utf8') !== generated) { + console.error('entities.generated.ts is stale — it no longer matches the backend registry.'); + console.error('Run: node scripts/gen-entity-types.mjs --write'); + process.exit(1); + } else { + console.log('entities.generated.ts is in step with the backend registry.'); + } +} diff --git a/src/lib/employeeRoleModel.ts b/src/lib/employeeRoleModel.ts index 4cffc05..c87fcd6 100644 --- a/src/lib/employeeRoleModel.ts +++ b/src/lib/employeeRoleModel.ts @@ -8,17 +8,16 @@ * worker WANTS rather than what a posting OFFERS. */ +import type { EmployeeRole } from '@/types/entities'; + /** - * `draft` is a EmployeeRole record, typed `any` deliberately. - * - * The shared types in `src/types/` describe the transport and the signed-in - * user; they do not describe entity records yet. That shape is derivable — the - * backend generates a column registry from `information_schema` — but it - * belongs with the phase that converts the transport layer. A partial interface - * written here would be a guess that every later reader treats as settled. - * `any` states what is actually known today and is one annotation to replace. + * A draft is a partial record: the conversation fills it in a field at a time, + * and `defaultEmployeeRole()` supplies the rest. `EmployeeRole` has no `jsonb` + * columns, so every field here is checked against its real column type — + * `certifications` and `availability` are `text[]`, which is why they arrive as + * `string[]` rather than as something the registry could not describe. */ -type EmployeeRoleDraft = any; +type EmployeeRoleDraft = Partial; /** English levels, in the order the schema declares them. */ export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive']; diff --git a/src/lib/krowScore.ts b/src/lib/krowScore.ts index 9c9320d..b9f8a1b 100644 --- a/src/lib/krowScore.ts +++ b/src/lib/krowScore.ts @@ -1,23 +1,28 @@ +import type { Course, JobPosting, LearningPath, WorkerProfile } from '@/types/entities'; + /** - * The records this engine reads — WorkerProfile, JobPosting, Course and - * LearningPath — are typed `any` deliberately. + * The records this engine reads, from the generated registry. * - * `src/types/` describes the transport and the signed-in user; it does not - * describe entity records yet. Those shapes are derivable from the backend's - * generated column registry, but they belong with the phase that converts the - * transport layer. Written here they would be a guess, and this is the file - * where a wrong guess is most expensive: these weights decide what a worker's - * score is and which jobs they are shown. + * `Partial` throughout: every function here guards each read (`|| 0`, + * `?.length`) because these run against records that may still be loading, and + * the seeded profiles do not all carry every column. * - * `computeProfileCompletion` additionally indexes the profile by a field name - * held in a list (`profile[f]`), which no partial interface would permit - * without either widening it back to `any` or rewriting the loop — and - * rewriting it would be a behaviour change in a scoring path. + * The `jsonb` columns are narrowed to `any[]`. The registry types them + * `unknown`, which is the honest answer — what sits inside a jsonb column is + * not in `information_schema` and nothing on the backend declares it — but this + * module reads fields off the elements. `any[]` records exactly that state: it + * is an array, and its elements are unchecked. Writing a shape for the elements + * would be inventing one, and it would be the compiler defending a guess. + * Fields backed by real columns stay fully checked either way. */ -type ProfileRecord = any; -type JobPostingRecord = any; -type CourseRecord = any; -type LearningPathRecord = any; +type ProfileRecord = Partial & { + completed_courses?: any[]; + earned_badges?: any[]; + experience?: any[]; +}; +type JobPostingRecord = Partial; +type CourseRecord = Partial; +type LearningPathRecord = Partial & { steps?: any[] }; /** The score, its headline inputs, and the per-dimension breakdown a reader sees. */ export interface KrowScoreResult { diff --git a/src/lib/provingGround.ts b/src/lib/provingGround.ts index 62b5095..1f78c6b 100644 --- a/src/lib/provingGround.ts +++ b/src/lib/provingGround.ts @@ -1,13 +1,30 @@ import { base44 } from '@/api/base44Client'; +import type { Course, WorkerProfile } from '@/types/entities'; + /** - * Course, WorkerProfile and the conversation turns are typed `any` - * deliberately — `src/types/` does not describe entity records yet, and a - * partial interface written here would be a guess later readers treat as - * settled. See the same note in `krowScore.ts`. + * Course and WorkerProfile, from the generated registry. + * + * `Partial`, because `isUnlocked` takes `profile = {}` and every read is + * guarded — it runs on a course card before the profile has loaded. + * + * Three `jsonb` columns are narrowed, and the narrowing says only what kind of + * value it is. `challenge` and `unlock_requirements` are jsonb OBJECTS, so they + * become `any`; `earned_badges` is a jsonb ARRAY, so it becomes `any[]`. The + * registry types all three `unknown`, which is correct — the shape inside a + * jsonb column is not in `information_schema`. Declaring the fields this file + * reads off them (`min_shifts`, `rubric`, `prompt`, …) would be inventing a + * schema the database does not hold. Every field backed by a real column — + * `proof_skill`, `title`, `description`, `shifts_completed`, + * `reliability_score` — stays fully checked. */ -type CourseRecord = any; -type ProfileRecord = any; +type CourseRecord = Partial & { + challenge?: any; + unlock_requirements?: any; +}; +type ProfileRecord = Partial & { + earned_badges?: any[]; +}; /** One turn of the roleplay transcript, as the challenge runner stores it. */ interface ChallengeTurn { diff --git a/src/lib/talentHome.ts b/src/lib/talentHome.ts index 6980ce0..ddbf224 100644 --- a/src/lib/talentHome.ts +++ b/src/lib/talentHome.ts @@ -1,18 +1,29 @@ // Derives the Talent "credit score" dimensions and motivational metrics // from a WorkerProfile. Keeps the UI honest by mapping to real fields only. +import type { WorkerProfile } from '@/types/entities'; + /** - * `profile` is a WorkerProfile record, typed `any` deliberately. + * `Partial`, not the whole record, and the default is why. * - * The shared types do not describe entity records yet — that shape is derivable - * from the backend's generated column registry and belongs with the phase that - * converts the transport layer, not with this one. Writing a partial - * WorkerProfile here would be a guess wearing an interface, and every reader - * after it would treat the guess as settled. `any` says what is actually known - * today, which is nothing, and it is one annotation to replace when the real - * record type exists. + * Every function here takes `profile = {}` and defends against absent fields + * with `|| 0`. That default is not decoration — these render on pages that + * mount before the profile query resolves. `WorkerProfile` outright would make + * `{}` unassignable and force the defaults to be removed, which is a behaviour + * change in a scoring path. `Partial` keeps the field NAMES and TYPES checked, + * which is the part worth having, while still describing what the callers pass. */ -type WorkerProfileRecord = any; +type WorkerProfileRecord = Partial & { + /** + * `completed_courses` is a `jsonb` column, so the registry types it + * `unknown` — correctly, because the shape inside a jsonb column is not in + * `information_schema` and nothing on the backend declares it. This module + * only ever asks it how long it is. Intersecting narrows that one field to an + * array without asserting anything about what the array holds, and leaves + * every other field checked against the real column type. + */ + completed_courses?: unknown[]; +}; /** * One row of the reputation breakdown. diff --git a/src/lib/talentInsights.ts b/src/lib/talentInsights.ts index 54a81a8..ddd4d29 100644 --- a/src/lib/talentInsights.ts +++ b/src/lib/talentInsights.ts @@ -2,17 +2,21 @@ // client endorsements. Used by both the talent card and the talent detail // modal so the two stay in sync. +import type { WorkerProfile } from '@/types/entities'; + /** - * `profile` is a WorkerProfile record, typed `any` deliberately. + * `Partial`, because the callers render before the profile has loaded and every + * read here is already guarded (`profile?.experience || []`). * - * The shared types in `src/types/` describe the transport and the signed-in - * user; they do not describe entity records yet. That shape is derivable — the - * backend generates a column registry from `information_schema` — but it - * belongs with the phase that converts the transport layer. A partial interface - * written here would be a guess that every later reader treats as settled. - * `any` states what is actually known today and is one annotation to replace. + * `experience` is a `jsonb` column. The registry types it `unknown`, correctly: + * what sits inside a jsonb column is not in `information_schema`. This module + * reads `.role` and `.company` off its entries, so it is narrowed to `any[]` — + * an array whose elements are unchecked. Declaring a shape for those elements + * would be inventing one, and the compiler would then defend the guess. */ -type WorkerProfileRecord = any; +type WorkerProfileRecord = Partial & { + experience?: any[]; +}; /** One derived endorsement row, as the talent card and detail modal render it. */ export interface ClientEndorsement { diff --git a/src/types/entities.generated.ts b/src/types/entities.generated.ts new file mode 100644 index 0000000..1b05c9a --- /dev/null +++ b/src/types/entities.generated.ts @@ -0,0 +1,513 @@ +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Regenerate with: node scripts/gen-entity-types.mjs --write + * Source of truth: krow-backend/go-api/internal/domain/resources_gen.go + * (itself generated from information_schema) + * + * Field types follow `Column.SelectExpr()` in `domain/resource.go` — what the + * read projection emits — not the raw Postgres type. See the generator's header + * for the mapping and for why the two differ. + * + * 15 resources, 298 columns. + */ + +/** `job-postings` — the `job_postings` table, every column the projection returns. */ +export interface JobPosting { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** server-owned. */ + created_by: string | null; + company: string; + /** required on create. */ + title: string; + role_category: string; + description: string; + responsibilities: string[]; + qualifications: string[]; + nice_to_haves: string[]; + custom_requirements: string; + physical_requirements: string; + leadership_expectations: string; + attendance_expectations: string; + min_experience_years: number; + english_required: 'basic' | 'conversational' | 'fluent' | 'native'; + certifications_required: string[]; + skill_requirements: unknown; + pay_range_min: number; + pay_range_max: number; + location: string; + status: 'draft' | 'active' | 'paused' | 'closed'; + ai_generated: boolean; + headcount: number; + start_date: string | null; + duration_months: number | null; + priority: 'urgent' | 'high' | 'normal'; + vetting_criteria: unknown; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `job-applications` — the `job_applications` table, every column the projection returns. */ +export interface JobApplication { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** required on create. */ + job_posting_id: string; + worker_profile_id: string | null; + job_title: string; + /** required on create. */ + applicant_name: string; + /** required on create. */ + email: string; + phone: string; + years_experience: number; + english_level: 'basic' | 'conversational' | 'fluent' | 'native'; + certifications: string[]; + availability: string[]; + skills: string[]; + companies_worked: string[]; + client_rating: number; + professional_summary: string; + cover_letter: string; + selfie_url: string; + status: 'applied' | 'ai_screened' | 'shortlisted' | 'interview' | 'hired' | 'rejected' | 'assigned'; + ai_score: number; + ai_match_label: string; + ai_summary: string; + ai_strengths: string[]; + ai_gaps: string[]; + ai_recommendation: string; + score_breakdown: unknown; + screened_at: string | null; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; + interview_id: string | null; +} + +/** `ai-interviews` — the `ai_interviews` table, every column the projection returns. */ +export interface AIInterview { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** required on create. */ + application_id: string; + /** required on create. */ + job_posting_id: string; + job_title: string; + candidate_name: string; + messages: unknown; + overall_interview_score: number; + verdict: 'hire' | 'maybe' | 'no'; + hire_recommendation: string; + integrity_score: number; + ai_flags: string[]; + category_scores: unknown; + strengths: string[]; + concerns: string[]; + best_fit_roles: string[]; + summary: string; + reasoning: string; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `staff` — the `staff` table, every column the projection returns. */ +export interface Staff { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + application_id: string | null; + job_posting_id: string | null; + worker_profile_id: string | null; + /** required on create. */ + name: string; + /** required on create. */ + email: string; + phone: string; + role: string; + profile_tier: 'Beginner' | 'Cross-Trained' | 'Skilled'; + /** required on create. */ + hire_date: string; + ai_score: number; + status: 'onboarding' | 'active' | 'inactive'; + client_rating: number; + endorsement_text: string; + endorsed_skills: string[]; + review_date: string | null; + reviewer_name: string; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `worker-profiles` — the `worker_profiles` table, every column the projection returns. */ +export interface WorkerProfile { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** server-owned. */ + user_id: string | null; + /** required on create. */ + full_name: string; + /** required on create. */ + email: string; + phone: string; + address: string; + selfie_url: string; + languages: string[]; + availability: string[]; + transportation: string; + certifications: string[]; + experience: unknown; + experience_years: number; + current_position: string; + desired_position: string; + career_goals: string; + skills: string[]; + industries: string[]; + personality: string; + strengths: string[]; + weaknesses: string[]; + communication_style: string; + salary_expectations: string; + leadership_potential: number; + ai_interview_score: number; + krow_score: number; + reliability_score: number; + profile_completion: number; + xp: number; + completed_courses: unknown; + earned_badges: unknown; + capabilities: unknown; + shifts_completed: number; + attendance_score: number; + performance_score: number; + client_rating: number; + supervisor_rating: number; + status: string; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; + score_breakdown: unknown; +} + +/** `courses` — the `courses` table, every column the projection returns. */ +export interface Course { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string | null; + /** required on create. */ + title: string; + description: string; + category: string; + difficulty: string; + xp: number; + estimated_minutes: number; + badge_reward: string | null; + proof_skill: string; + skill_id: string | null; + target_level: 'beginner' | 'intermediate' | 'advanced' | 'expert' | null; + required_level: 'beginner' | 'intermediate' | 'advanced' | 'expert' | null; + completion_criteria: string[]; + verification_criteria: string[]; + challenge: unknown; + unlock_requirements: unknown; + quiz: unknown; + pass_score: number; + status: 'active' | 'inactive'; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; + training_outline: string[]; +} + +/** `learning-paths` — the `learning_paths` table, every column the projection returns. */ +export interface LearningPath { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string | null; + /** required on create. */ + name: string; + target_role: string; + description: string; + difficulty: string; + steps: unknown; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `role-categories` — the `role_categories` table, every column the projection returns. */ +export interface RoleCategory { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** required on create. */ + name: string; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `certifications` — the `certifications` table, every column the projection returns. */ +export interface Certification { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** required on create. */ + name: string; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `user-activity` — the `user_activity` table, every column the projection returns. */ +export interface UserActivity { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** required on create. */ + event_type: string; + /** server-owned. */ + user_id: string | null; + /** server-owned. */ + user_email: string; + /** server-owned. */ + user_name: string; + /** server-owned. */ + account_type: string; + details: string; + position_id: string | null; + application_id: string | null; + candidate_id: string | null; + interview_id: string | null; + worker_email: string | null; + metadata: unknown | null; + /** server-owned. */ + created_date: string; +} + +/** `evidence` — the `evidence` table, every column the projection returns. */ +export interface Evidence { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + course_id: string | null; + worker_profile_id: string | null; + course_title: string; + skill: string; + /** required on create. */ + worker_email: string; + worker_name: string; + /** required on create. */ + type: 'roleplay' | 'video' | 'photo_identify'; + media_url: string; + transcript: string; + ai_verdict: 'verified' | 'needs_work' | 'failed'; + ai_score: number; + ai_rubric: unknown; + ai_feedback: string; + supervisor_verified: boolean; + supervisor_name: string; + verified_date: string | null; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `assignments` — the `assignments` table, every column the projection returns. */ +export interface Assignment { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** required on create. */ + job_posting_id: string; + application_id: string | null; + worker_profile_id: string | null; + /** required on create. */ + worker_email: string; + worker_name: string; + /** required on create. */ + starts_at: string; + ends_at: string | null; + status: 'active' | 'completed' | 'cancelled'; + source: string; + match_score: number | null; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `shift-records` — the `shift_records` table, every column the projection returns. */ +export interface ShiftRecord { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + staff_id: string | null; + assignment_id: string | null; + job_posting_id: string | null; + worker_name: string; + worker_email: string; + role: string; + role_category: string; + shift_date: string; + scheduled_start: string; + scheduled_end: string; + scheduled_hours: number; + actual_start: string | null; + actual_end: string | null; + actual_hours: number; + overtime_hours: number; + minutes_late: number; + status: 'present' | 'late' | 'absent' | 'no_show'; + notes: string; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `employee-roles` — the `employee_roles` table, every column the projection returns. */ +export interface EmployeeRole { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + worker_profile_id: string | null; + /** required on create. */ + worker_email: string; + worker_name: string; + /** required on create. */ + role_category: string; + experience_years: number; + english_level: 'basic' | 'conversational' | 'fluent' | 'native'; + certifications: string[]; + desired_pay_min: number; + desired_pay_max: number; + availability: string[]; + notes: string; + status: 'seeking' | 'placed' | 'inactive'; + /** server-owned. */ + created_by: string | null; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** `badges` — the `badges` table, every column the projection returns. */ +export interface Badge { + /** server-owned. */ + id: string; + /** server-owned. */ + legacy_id: string | null; + /** server-owned. */ + org_id: string; + /** required on create. */ + name: string; + description: string; + image_url: string; + level: 'bronze' | 'silver' | 'gold' | 'platinum'; + requirements: string; + expiration_months: number | null; + verification_status: 'pending' | 'verified' | 'expired'; + /** server-owned. */ + created_date: string; + /** server-owned. */ + updated_date: string; +} + +/** The entity names the generic registry serves. Three more exist — see `entities.ts`. */ +export type GeneratedEntityName = + | 'JobPosting' + | 'JobApplication' + | 'AIInterview' + | 'Staff' + | 'WorkerProfile' + | 'Course' + | 'LearningPath' + | 'RoleCategory' + | 'Certification' + | 'UserActivity' + | 'Evidence' + | 'Assignment' + | 'ShiftRecord' + | 'EmployeeRole' + | 'Badge'; + +/** Entity name -> its record type, for looking a record up by name. */ +export interface GeneratedEntityRecords { + JobPosting: JobPosting; + JobApplication: JobApplication; + AIInterview: AIInterview; + Staff: Staff; + WorkerProfile: WorkerProfile; + Course: Course; + LearningPath: LearningPath; + RoleCategory: RoleCategory; + Certification: Certification; + UserActivity: UserActivity; + Evidence: Evidence; + Assignment: Assignment; + ShiftRecord: ShiftRecord; + EmployeeRole: EmployeeRole; + Badge: Badge; +} diff --git a/src/types/entities.ts b/src/types/entities.ts index d31f5ed..d8d6667 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -26,6 +26,24 @@ * `httpserver/definitions.go`, and the user by `httpserver/me.go`. */ +/** + * The record shapes, generated from the backend's resource registry. + * + * Re-exported here so that `@/types/entities` stays the one place to import an + * entity type from, whether or not that particular shape happens to be + * generated. See `entities.generated.ts` for how they are derived and + * `scripts/gen-entity-types.mjs` for the mapping from Postgres column to + * TypeScript field. + */ +export type { + AIInterview, Assignment, Badge, Certification, Course, EmployeeRole, Evidence, + JobApplication, JobPosting, LearningPath, RoleCategory, ShiftRecord, Staff, + UserActivity, WorkerProfile, +} from './entities.generated'; + +import type { GeneratedEntityRecords } from './entities.generated'; +import type { User } from './user'; + /** * Every entity name the client knows, spelled as the frontend spells it. * @@ -104,17 +122,10 @@ export type EntityResourcePath = * rely on them instead of passing their own, so a conversion that dropped them * would change what those callers fetch. * - * `T` is the record type, left open here. Phase 2 deliberately does not define - * the eighteen record shapes: they are derivable — `resources_gen.go` carries - * every column with its type, nullability and enum values — but nothing - * consumes them until the transport layer is converted, and transcribing some - * five hundred field declarations by hand ahead of a consumer is how a type - * becomes confidently wrong. They belong with Phase 4, generated from that - * registry rather than retyped from it. - * - * Until then `EntityClient` describes the shape of the surface without - * asserting anything about the records that travel through it, which is exactly - * as much as is known today. + * `T` is the record type and defaults to `unknown`, so the surface can be + * described without asserting anything about what travels through it. Where the + * entity is known, prefer `EntityClientFor<'JobPosting'>` below, which fills `T` + * in from `EntityRecords`. */ export interface EntityClient { entityName: EntityName; @@ -133,3 +144,30 @@ export interface EntityClient { */ bulkCreate(records?: Partial[]): Promise; } + +/** + * Entity name -> the record that entity's endpoints return. + * + * Fifteen come from the generated registry. The other three are the entities + * the generic machinery does not serve, and they are recorded honestly rather + * than guessed: + * + * User typed, from `src/types/user.ts` — the `/me` projection is + * read out of `httpserver/me.go`. Note that no + * `/api/v1/users` route exists, so the entity client for + * this name would 404; nothing calls it. + * AgentDefinition `unknown`. Served by `httpserver/definitions.go`, whose + * SkillDefinition columns are not in the registry this file is generated + * from. They belong to the agent layer, which this phase of + * the migration does not describe. `unknown` forces a + * deliberate narrowing at the point of use instead of + * quietly permitting anything, which `any` would. + */ +export interface EntityRecords extends GeneratedEntityRecords { + User: User; + AgentDefinition: unknown; + SkillDefinition: unknown; +} + +/** The client surface for one named entity, with its record type filled in. */ +export type EntityClientFor = EntityClient;