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;