chore(ts-migration): establish TypeScript migration checkpoint
Phases 0-3 of the JS/JSX -> TS/TSX migration. No runtime behaviour changes:
every converted file emits byte-identical JavaScript, verified file by file.
Phase 0 - harness hardening, before any rename:
- scripts/ssr-resolve.mjs wraps `ssrLoadModule` so the ~170 literal module
paths in the check scripts resolve .js/.jsx/.ts/.tsx. Without it the first
rename would have silently destroyed the 1642-check suite that guards the
Owliver flow.
- eslint.config.js gains a TypeScript block. Its `files` globs listed only
{js,mjs,cjs,jsx}, so a renamed file would have dropped out of the run while
`eslint .` went on exiting 0 - the quietest failure mode available.
- MIGRATION_BASELINE.md records the measured starting point, including the
pre-existing seed-fixture failure and the already-broken standalone
owliver-baseline.mjs, so neither is later mistaken for migration damage.
Phase 1 - tsconfig.json succeeds jsconfig.json, carrying every option across at
its old value. `types` moves from [] to ["vite/client"], which fixes the eight
import.meta errors; @types/node is deliberately excluded so setTimeout stays a
number in browser code. allowJs and checkJs stay on, strict stays off.
Phase 2 - src/types/{api,entities,user}.ts. Transport envelope, error shape,
the entity-name union (the same 18 names are written down twice today, in
httpClient and base44Client, with nothing checking they agree), and the user
record. Every field transcribed from the API contract, the migrations and the
/me projection in me.go - not inferred. Entity record shapes are deliberately
absent: derivable, but nothing consumes them yet.
Phase 3 - nine leaf utilities renamed to .ts with annotations added only where
they could be established from existing usage. Entity records are typed `any`
with a comment naming what they are, rather than a guessed interface.
Verified: tsc 64 errors (65 before; one pre-existing TS2559 genuinely fixed,
none introduced), lint unchanged at 0 errors, skill-check 1641/1642 with only
the known failure, Owliver baseline section 59/59 green, production build
succeeds with the API origin inlined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
@@ -8,6 +8,18 @@
|
||||
* worker WANTS rather than what a posting OFFERS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `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.
|
||||
*/
|
||||
type EmployeeRoleDraft = any;
|
||||
|
||||
/** English levels, in the order the schema declares them. */
|
||||
export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive'];
|
||||
|
||||
@@ -35,7 +47,7 @@ export const defaultEmployeeRole = () => ({
|
||||
* A maximum of zero means "no ceiling stated" rather than "free", which is why
|
||||
* it is not rendered as a range ending at nothing.
|
||||
*/
|
||||
export function desiredPayLabel(record = {}) {
|
||||
export function desiredPayLabel(record: EmployeeRoleDraft = {}): string | null {
|
||||
const min = Number(record.desired_pay_min) || 0;
|
||||
const max = Number(record.desired_pay_max) || 0;
|
||||
if (!min && !max) return null;
|
||||
@@ -56,7 +68,7 @@ export function desiredPayLabel(record = {}) {
|
||||
* defaults one, or falls back to another record's; an absent email reaches the
|
||||
* server absent, and the server refuses it.
|
||||
*/
|
||||
export function toNewWorkerWithRolePayload(draft = {}) {
|
||||
export function toNewWorkerWithRolePayload(draft: EmployeeRoleDraft = {}) {
|
||||
const role = toEmployeeRolePayload(draft);
|
||||
/* The identity fields belong to the worker. The server copies them onto the
|
||||
role from the row it just created, so sending them twice would let the two
|
||||
@@ -82,7 +94,7 @@ export function toNewWorkerWithRolePayload(draft = {}) {
|
||||
* deliberately: both are the server's, derived from the session, and a value
|
||||
* sent for either is dropped before the insert.
|
||||
*/
|
||||
export function toEmployeeRolePayload(draft = {}) {
|
||||
export function toEmployeeRolePayload(draft: EmployeeRoleDraft = {}) {
|
||||
const record = { ...defaultEmployeeRole(), ...draft };
|
||||
return {
|
||||
...record,
|
||||
@@ -1,10 +1,47 @@
|
||||
/**
|
||||
* The records this engine reads — WorkerProfile, JobPosting, Course and
|
||||
* LearningPath — are typed `any` deliberately.
|
||||
*
|
||||
* `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.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
type ProfileRecord = any;
|
||||
type JobPostingRecord = any;
|
||||
type CourseRecord = any;
|
||||
type LearningPathRecord = any;
|
||||
|
||||
/** The score, its headline inputs, and the per-dimension breakdown a reader sees. */
|
||||
export interface KrowScoreResult {
|
||||
krow_score: number;
|
||||
reliability: number;
|
||||
breakdown: {
|
||||
/** `null`, not 0, where there is no record — an absent dimension renders "—". */
|
||||
attendance: number | null;
|
||||
performance: number;
|
||||
education: number;
|
||||
clientReviews: number;
|
||||
supervisorReviews: number;
|
||||
growth: number;
|
||||
experience: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Engine 6 — KROW Score Engine (formula-based, auto-recalculated)
|
||||
// Reliability = Attendance×.30 + Performance×.25 + Education×.10 + ClientReviews×.15 + SupervisorReviews×.10 + Growth×.05 + Experience×.05
|
||||
// KROW Score = Reliability×.40 + AI Interview×.25 + Education×.15 + Growth×.10 + Experience×.10
|
||||
|
||||
const clamp = (n) => Math.max(0, Math.min(100, n));
|
||||
const clamp = (n: number) => Math.max(0, Math.min(100, n));
|
||||
|
||||
export function computeKrowScore(profile) {
|
||||
export function computeKrowScore(profile: ProfileRecord): KrowScoreResult {
|
||||
/**
|
||||
* Attendance defaults to 100 for *display*: an unrated worker shows a full bar
|
||||
* rather than an accusatory 0. That default must not become a scoring input.
|
||||
@@ -64,7 +101,7 @@ export function computeKrowScore(profile) {
|
||||
|
||||
const CORE_FIELDS = ['full_name', 'email', 'phone', 'address', 'current_position', 'desired_position', 'career_goals', 'transportation', 'resume_url'];
|
||||
|
||||
export function computeProfileCompletion(profile) {
|
||||
export function computeProfileCompletion(profile: ProfileRecord): number {
|
||||
let filled = 0;
|
||||
for (const f of CORE_FIELDS) if (profile[f]) filled++;
|
||||
if (profile.languages?.length) filled++;
|
||||
@@ -83,7 +120,7 @@ export function computeProfileCompletion(profile) {
|
||||
}
|
||||
|
||||
/** Returns only the recomputed fields — merge into an update patch */
|
||||
export function recalcProfilePatch(profile) {
|
||||
export function recalcProfilePatch(profile: ProfileRecord) {
|
||||
const { krow_score, reliability, breakdown } = computeKrowScore(profile);
|
||||
return {
|
||||
krow_score,
|
||||
@@ -95,7 +132,7 @@ export function recalcProfilePatch(profile) {
|
||||
|
||||
// Engine 11 (light) — recommendation helpers
|
||||
|
||||
export function recommendNextCourse(profile, learningPaths = [], courses = []) {
|
||||
export function recommendNextCourse(profile: ProfileRecord, learningPaths: LearningPathRecord[] = [], courses: CourseRecord[] = []) {
|
||||
const p = profile || {};
|
||||
const completedIds = new Set((p.completed_courses || []).map(c => c.course_id));
|
||||
const desired = (p.desired_position || '').toLowerCase();
|
||||
@@ -116,14 +153,17 @@ export function recommendNextCourse(profile, learningPaths = [], courses = []) {
|
||||
return uncompleted[0] ? { course: uncompleted[0], path: null } : { course: null, path };
|
||||
}
|
||||
|
||||
export function computeJobMatch(profile, job) {
|
||||
export function computeJobMatch(profile: ProfileRecord, job: JobPostingRecord): number {
|
||||
const p = profile || {};
|
||||
let score = 0;
|
||||
const desired = (p.desired_position || '').toLowerCase();
|
||||
if (desired && (job.title?.toLowerCase().includes(desired) || job.role_category?.toLowerCase().includes(desired))) score += 35;
|
||||
|
||||
const profileSkills = new Set((p.skills || []).map(s => s.toLowerCase()));
|
||||
const profileCerts = new Set((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean));
|
||||
/* `new Set(x)` where x is `any` infers `Set<unknown>`, and the loops below
|
||||
call `.includes` on the members. Naming the element type is the whole
|
||||
fix; the values put into the set are unchanged. */
|
||||
const profileSkills = new Set<string>((p.skills || []).map(s => s.toLowerCase()));
|
||||
const profileCerts = new Set<string>((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean));
|
||||
const reqSkills = (job.qualifications || []).map(q => q.toLowerCase());
|
||||
const reqCerts = (job.certifications_required || []).map(c => c.toLowerCase());
|
||||
|
||||
@@ -144,7 +184,7 @@ export function computeJobMatch(profile, job) {
|
||||
return Math.min(100, Math.round(score));
|
||||
}
|
||||
|
||||
export function recommendJobs(profile, jobPostings = [], limit = 3) {
|
||||
export function recommendJobs(profile: ProfileRecord, jobPostings: JobPostingRecord[] = [], limit = 3) {
|
||||
return jobPostings
|
||||
.filter(j => j.status === 'active')
|
||||
.map(j => ({ job: j, match: computeJobMatch(profile, j) }))
|
||||
@@ -1,5 +1,57 @@
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
type CourseRecord = any;
|
||||
type ProfileRecord = any;
|
||||
|
||||
/** One turn of the roleplay transcript, as the challenge runner stores it. */
|
||||
interface ChallengeTurn {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body sent to `InvokeLLM`, built up across branches.
|
||||
*
|
||||
* Annotated rather than inferred because the code assigns `file_urls` and
|
||||
* `prompt` onto the object AFTER creating it. TypeScript permits that in a
|
||||
* `.js` file — it treats a JavaScript object literal as expandable — and
|
||||
* refuses it in a `.ts` file, where the literal's type is fixed at creation.
|
||||
* That difference is the only thing this interface exists to absorb: the
|
||||
* assignments, their order and their values are untouched, and the emitted
|
||||
* JavaScript is the same either way.
|
||||
*/
|
||||
interface ChallengeEvalCall {
|
||||
response_json_schema: unknown;
|
||||
model: string;
|
||||
file_urls?: string[];
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
/** What the evaluator is asked to return; the schema below is its contract. */
|
||||
export interface ChallengeEvaluation {
|
||||
verdict: 'verified' | 'needs_work' | 'failed';
|
||||
score: number;
|
||||
rubric: Record<string, number>;
|
||||
feedback: string;
|
||||
strengths: string[];
|
||||
concerns: string[];
|
||||
}
|
||||
|
||||
/** The inputs a challenge submission carries, by challenge type. */
|
||||
export interface ChallengeSubmission {
|
||||
type: string;
|
||||
mediaUrl?: string;
|
||||
transcript?: string;
|
||||
identified?: { label: string; x: number; y: number }[];
|
||||
workerName?: string;
|
||||
}
|
||||
|
||||
const CHALLENGE_EVAL_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -13,7 +65,7 @@ const CHALLENGE_EVAL_SCHEMA = {
|
||||
};
|
||||
|
||||
/** Real-work unlock gate — shifts, reliability, and badges must be earned first. */
|
||||
export function isUnlocked(course, profile = {}) {
|
||||
export function isUnlocked(course: CourseRecord, profile: ProfileRecord = {}) {
|
||||
const req = course?.unlock_requirements;
|
||||
if (!req) return { unlocked: true, reasons: [] };
|
||||
const reasons = [];
|
||||
@@ -27,7 +79,7 @@ export function isUnlocked(course, profile = {}) {
|
||||
}
|
||||
|
||||
/** One sharp follow-up question during a roleplay challenge. */
|
||||
export async function challengeFollowUp(course, history) {
|
||||
export async function challengeFollowUp(course: CourseRecord, history: ChallengeTurn[]) {
|
||||
const convo = history.map((m) => `${m.role === 'user' ? 'Worker' : 'KROW'}: ${m.content}`).join('\n');
|
||||
const prompt = `You are "KROW", running a short proving-ground challenge for the skill "${course?.proof_skill || course?.title}".
|
||||
Challenge scenario: ${course?.challenge?.prompt || course?.description}
|
||||
@@ -41,7 +93,7 @@ Ask your follow-up now. Respond with only the question.`;
|
||||
|
||||
/** Evaluate a worker's proof — transcript for roleplay, attached media for photo/video,
|
||||
* identified hazards for photo_identify (with the scene image attached). */
|
||||
export async function evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName }) {
|
||||
export async function evaluateChallenge(course: CourseRecord, { type, mediaUrl, transcript, identified, workerName }: ChallengeSubmission) {
|
||||
const ch = course?.challenge || {};
|
||||
const skill = course?.proof_skill || course?.title;
|
||||
const criteria = (ch.rubric || []).map((r) => r.criterion).join(', ') || 'overall_performance';
|
||||
@@ -50,7 +102,7 @@ export async function evaluateChallenge(course, { type, mediaUrl, transcript, id
|
||||
: 'Score "overall_performance" 0-100 in the rubric object.';
|
||||
|
||||
let mediaPart;
|
||||
const call = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' };
|
||||
const call: ChallengeEvalCall = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' };
|
||||
|
||||
if (type === 'photo_identify') {
|
||||
const list = (identified || []).map((h) => `- "${h.label}" at (${Math.round(h.x * 100)}%, ${Math.round(h.y * 100)}%)`).join('\n') || '(no hazards marked)';
|
||||
@@ -1,15 +1,56 @@
|
||||
// Derives the Talent "credit score" dimensions and motivational metrics
|
||||
// from a WorkerProfile. Keeps the UI honest by mapping to real fields only.
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
export function getTalentScore(profile = {}) {
|
||||
/**
|
||||
* `profile` is a WorkerProfile record, typed `any` deliberately.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
type WorkerProfileRecord = any;
|
||||
|
||||
/**
|
||||
* One row of the reputation breakdown.
|
||||
*
|
||||
* `big` marks the headline dimension — Talent Score — which renders larger and
|
||||
* is the one `getFactors` filters out. It is set on exactly one entry and
|
||||
* absent from the other six, so it is optional rather than `boolean`: writing
|
||||
* it as required would describe an object this module never builds.
|
||||
*/
|
||||
export interface ReputationDimension {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
big?: boolean;
|
||||
}
|
||||
|
||||
/** A dimension carrying the weight it contributes, as `getFactors` returns it. */
|
||||
export interface ReputationFactor extends ReputationDimension {
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/** The credit-rating tier for a score, with the colour it renders in. */
|
||||
export interface ScoreBand {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const clamp = (n: unknown, min = 0, max = 100) =>
|
||||
Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
export function getTalentScore(profile: WorkerProfileRecord = {}) {
|
||||
return clamp(profile.krow_score || 0);
|
||||
}
|
||||
|
||||
// Career Score — FICO-style 300–850 scale, the number shown on the KROW ID.
|
||||
export const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
export const toFICO = (s: number) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
|
||||
export function getStars(profile = {}) {
|
||||
export function getStars(profile: WorkerProfileRecord = {}) {
|
||||
const s = getTalentScore(profile);
|
||||
if (s >= 90) return 5;
|
||||
if (s >= 75) return 4;
|
||||
@@ -20,18 +61,18 @@ export function getStars(profile = {}) {
|
||||
}
|
||||
|
||||
// Market value uplift ($/hr) derived from Talent Score
|
||||
export function getMarketValue(profile = {}) {
|
||||
export function getMarketValue(profile: WorkerProfileRecord = {}) {
|
||||
const s = getTalentScore(profile);
|
||||
return Math.max(0, Math.round((s - 70) / 5));
|
||||
}
|
||||
|
||||
// Approximate growth this month (motivational, derived from XP)
|
||||
export function getGrowth(profile = {}) {
|
||||
export function getGrowth(profile: WorkerProfileRecord = {}) {
|
||||
const xp = Number(profile.xp) || 0;
|
||||
return clamp(Math.round(xp / 25), 0, 99);
|
||||
}
|
||||
|
||||
export function getReputation(profile = {}) {
|
||||
export function getReputation(profile: WorkerProfileRecord = {}): ReputationDimension[] {
|
||||
const s = profile || {};
|
||||
const courses = s.completed_courses || [];
|
||||
const learning = clamp(courses.length * 8 + (Number(s.xp) || 0) / 10);
|
||||
@@ -47,7 +88,7 @@ export function getReputation(profile = {}) {
|
||||
}
|
||||
|
||||
// Weight each reputation factor like a credit score (FICO-style, sums to 100)
|
||||
export const FACTOR_WEIGHTS = {
|
||||
export const FACTOR_WEIGHTS: Record<string, number> = {
|
||||
reliability: 25,
|
||||
attendance: 20,
|
||||
communication: 15,
|
||||
@@ -57,7 +98,7 @@ export const FACTOR_WEIGHTS = {
|
||||
};
|
||||
|
||||
// Score band — the "credit rating" tier for the talent
|
||||
export function getScoreBand(score = 0) {
|
||||
export function getScoreBand(score = 0): ScoreBand {
|
||||
if (score >= 90) return { label: 'Elite', color: '#0838E0' };
|
||||
if (score >= 75) return { label: 'Excellent', color: '#16A34A' };
|
||||
if (score >= 60) return { label: 'Solid', color: '#2563EB' };
|
||||
@@ -66,7 +107,7 @@ export function getScoreBand(score = 0) {
|
||||
return { label: 'No score yet', color: '#D1D5DB' };
|
||||
}
|
||||
|
||||
export function getFactors(profile = {}) {
|
||||
export function getFactors(profile: WorkerProfileRecord = {}): ReputationFactor[] {
|
||||
return getReputation(profile)
|
||||
.filter((d) => !d.big)
|
||||
.map((d) => ({ ...d, weight: FACTOR_WEIGHTS[d.key] || 0 }));
|
||||
@@ -2,8 +2,27 @@
|
||||
// client endorsements. Used by both the talent card and the talent detail
|
||||
// modal so the two stay in sync.
|
||||
|
||||
/**
|
||||
* `profile` is a WorkerProfile 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.
|
||||
*/
|
||||
type WorkerProfileRecord = any;
|
||||
|
||||
/** One derived endorsement row, as the talent card and detail modal render it. */
|
||||
export interface ClientEndorsement {
|
||||
company: string;
|
||||
role: string;
|
||||
stars: number;
|
||||
}
|
||||
|
||||
// ESAT — Employee Skills Assessment Transcript; derived from AI interview score
|
||||
export function esatTag(profile) {
|
||||
export function esatTag(profile: WorkerProfileRecord) {
|
||||
const s = Math.round(profile?.ai_interview_score || 0);
|
||||
if (s > 0) return { label: `${s}/100`, cls: 'bg-[#FFF7ED] text-[#B45309]' };
|
||||
return { label: 'Pending', cls: 'bg-[#F3F4F6] text-[#6B7280]' };
|
||||
@@ -11,13 +30,13 @@ export function esatTag(profile) {
|
||||
|
||||
// Recent job history (last 3 years). experience entries carry a duration
|
||||
// (`years`) rather than dates, so we surface the most recent up to 3.
|
||||
export function recentJobs(profile) {
|
||||
export function recentJobs(profile: WorkerProfileRecord) {
|
||||
return (profile?.experience || []).slice(0, 3).filter((e) => e && (e.role || e.company));
|
||||
}
|
||||
|
||||
// Up to 3 client endorsements, deterministically derived from companies the
|
||||
// worker has been placed at, rated off their aggregate client rating.
|
||||
export function clientEndorsements(profile) {
|
||||
export function clientEndorsements(profile: WorkerProfileRecord): ClientEndorsement[] {
|
||||
const companies = (profile?.experience || [])
|
||||
.map((e) => e?.company)
|
||||
.filter(Boolean);
|
||||
@@ -1,6 +1,10 @@
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
let _cachedUser = null;
|
||||
/* The signed-in user, or null when nobody is or the lookup failed. Fetched
|
||||
once per page load: this is best-effort logging, and a tracking call must
|
||||
never become a second round trip on a user action. */
|
||||
let _cachedUser: User | null = null;
|
||||
let _userFetched = false;
|
||||
|
||||
async function getCurrentUser() {
|
||||
@@ -14,8 +18,30 @@ async function getCurrentUser() {
|
||||
return _cachedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional context an event may carry.
|
||||
*
|
||||
* Every field is optional because every call site supplies a different subset —
|
||||
* the shape is "whatever this particular event knows". The id fields are the
|
||||
* records the event is about; they are written onto the record only when
|
||||
* supplied, so an event with no application does not get an
|
||||
* `application_id: null` that later reads as a missing link.
|
||||
*/
|
||||
export interface ActivityExtra {
|
||||
email?: string;
|
||||
name?: string;
|
||||
account_type?: string;
|
||||
details?: string;
|
||||
position_id?: string;
|
||||
application_id?: string;
|
||||
candidate_id?: string;
|
||||
interview_id?: string;
|
||||
worker_email?: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
/** Best-effort activity logging — never throws, never blocks the main flow. */
|
||||
export async function logActivity(eventType, extra = {}) {
|
||||
export async function logActivity(eventType: string, extra: ActivityExtra = {}) {
|
||||
try {
|
||||
const me = await getCurrentUser();
|
||||
const record = {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clsx } from "clsx"
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { extendTailwindMerge } from "tailwind-merge"
|
||||
|
||||
/**
|
||||
@@ -27,7 +27,18 @@ const twMerge = extendTailwindMerge({
|
||||
},
|
||||
})
|
||||
|
||||
export function cn(...inputs) {
|
||||
/**
|
||||
* `ClassValue` is clsx's own type, so this accepts exactly what clsx accepts
|
||||
* and nothing has to be kept in step by hand: strings, numbers, `null`,
|
||||
* `undefined`, `false`, arrays of any of those, and `{ 'class-name': boolean }`
|
||||
* objects, nested to any depth. That breadth is deliberate — the conditional
|
||||
* forms are most of why callers reach for `cn` at all.
|
||||
*
|
||||
* The body is untouched. `clsx(inputs)` is passed the rest array rather than
|
||||
* spread into it, which is not the usual shape but is what this has always
|
||||
* done, and clsx flattens an array argument to the same string either way.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
129
src/types/api.ts
Normal file
129
src/types/api.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The shapes the Krow API speaks in.
|
||||
*
|
||||
* Types only — this module emits nothing. Import it with `import type` so it is
|
||||
* erased at build time and never becomes a runtime dependency of the transport.
|
||||
*
|
||||
* Every declaration here is transcribed from the API's own contract
|
||||
* (`krow-backend/docs/api-contract.md`, §1, §4 and §5) and cross-read against
|
||||
* what `src/api/httpClient.js` actually does with the response today. Where the
|
||||
* two could differ, the running code wins, because the callers were written
|
||||
* against it.
|
||||
*
|
||||
* Nothing in `src` imports this yet. It exists so that the transport layer has
|
||||
* a vocabulary to be converted INTO, rather than one invented halfway through
|
||||
* converting it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collection metadata, from the `meta` half of a list response.
|
||||
*
|
||||
* Present on `list` and `filter` only; a single record carries no `meta`.
|
||||
*
|
||||
* Worth knowing before anyone reaches for it: `httpClient.request` returns
|
||||
* `payload.data` and **drops `meta` on the floor**. That is deliberate — §4.2
|
||||
* records that nothing reads it, and surfacing it would change what the six
|
||||
* entity methods return, which the transport swap was not allowed to do. So
|
||||
* this type describes a value that is real on the wire and currently
|
||||
* unreachable from application code. Typing it costs nothing and means the
|
||||
* shape is already written down on the day someone wants `total`.
|
||||
*
|
||||
* `truncated` is `true` when `total > offset + returned`, which is the silent
|
||||
* data-hiding bug §12.2 describes. It is not a bug this type fixes.
|
||||
*/
|
||||
export interface ApiMeta {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
returned: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What comes back on the wire, before `request` unwraps it.
|
||||
*
|
||||
* A successful response is always `{ data: … }`; a collection adds `meta`. The
|
||||
* envelope exists so `meta` has somewhere to live (§1.1) and is unwrapped in
|
||||
* exactly one place, so no hook, page or component has ever seen it.
|
||||
*/
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta?: ApiMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* The eight codes the API is allowed to return (§5).
|
||||
*
|
||||
* A closed union rather than `string`, because these are a fixed vocabulary the
|
||||
* server owns and the client branches on — `isUnauthenticated` tests for
|
||||
* `'unauthorized'` by name.
|
||||
*
|
||||
* `'unreachable'` is NOT in this union and is deliberately kept out: it is not
|
||||
* an API code. `httpClient` synthesises it when `fetch` itself rejects and
|
||||
* there is no response, no status and no envelope to read a code from. It
|
||||
* belongs to `KrowApiError` below, where the distinction between "the API said
|
||||
* no" and "there was no API" stays visible.
|
||||
*/
|
||||
export type ApiErrorCode =
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'rate_limited'
|
||||
| 'not_found'
|
||||
| 'validation_failed'
|
||||
| 'invalid_query'
|
||||
| 'conflict'
|
||||
| 'internal';
|
||||
|
||||
/**
|
||||
* The body of a non-2xx response.
|
||||
*
|
||||
* `details` maps field → reason on `validation_failed` and is `{}` otherwise.
|
||||
*/
|
||||
export interface ApiErrorBody {
|
||||
code: ApiErrorCode;
|
||||
message: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A non-2xx response, whole. */
|
||||
export interface ApiErrorResponse {
|
||||
error: ApiErrorBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a failed request becomes on the client.
|
||||
*
|
||||
* An **interface, not a class**, and that is the load-bearing decision in this
|
||||
* file. `httpClient` builds these by constructing a plain `Error` and assigning
|
||||
* four properties onto it:
|
||||
*
|
||||
* const error = new Error(body?.message || `Request failed with status ${status}`);
|
||||
* error.name = 'KrowApiError';
|
||||
* error.status = status;
|
||||
* error.code = body?.code || 'internal';
|
||||
* error.details = body?.details || {};
|
||||
*
|
||||
* Declaring a `class KrowApiError extends Error` would be the tidier-looking
|
||||
* conversion and would change behaviour: it alters the prototype chain, so
|
||||
* `instanceof` starts answering differently, and anything comparing
|
||||
* `error.name === 'KrowApiError'` is relying on a string that a class would set
|
||||
* by a different route. The migration's whole claim is that it changes nothing,
|
||||
* so the shape is described rather than rebuilt — Phase 4 casts the constructed
|
||||
* `Error` to this interface and the emitted JavaScript stays identical.
|
||||
*
|
||||
* `code` widens to include `'unreachable'` because `fetch` rejecting is not an
|
||||
* API answer: that path sets `status: 0`, `details: {}` and a `cause`, and its
|
||||
* message names the URL that did not respond rather than anything the server
|
||||
* said.
|
||||
*
|
||||
* `status` is `0` for that transport failure and the HTTP status otherwise.
|
||||
* Both `isUnauthenticated` and the 429 branch in `pages/admin/Login.jsx` read
|
||||
* it, which is why it is required rather than optional.
|
||||
*/
|
||||
export interface KrowApiError extends Error {
|
||||
name: 'KrowApiError';
|
||||
status: number;
|
||||
code: ApiErrorCode | 'unreachable';
|
||||
details: Record<string, unknown>;
|
||||
cause?: unknown;
|
||||
}
|
||||
135
src/types/entities.ts
Normal file
135
src/types/entities.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Which entities exist, and the surface each one presents.
|
||||
*
|
||||
* Types only — nothing here emits. Import with `import type`.
|
||||
*
|
||||
* This file exists because the list of entities is currently written down
|
||||
* **twice**: as the keys of `RESOURCE_PATHS` in `src/api/httpClient.js`, and as
|
||||
* the `ENTITY_NAMES` array in `src/api/base44Client.js`. They hold the same
|
||||
* eighteen names in a different order, and nothing checks that they agree. They
|
||||
* agree today; they are one careless edit from not doing so, and the symptom
|
||||
* would be `base44.entities.Whatever` being `undefined` at runtime with no
|
||||
* warning from anywhere.
|
||||
*
|
||||
* Note what this file does NOT do: it does not replace either list. Both stay
|
||||
* exactly where they are, because collapsing them into one is a runtime
|
||||
* refactor and this phase changes no runtime behaviour. What it adds is a
|
||||
* single type the two can be checked against once they are TypeScript, which
|
||||
* turns a silent divergence into a compile error.
|
||||
*
|
||||
* Verified against the backend on 2026-09-11: fifteen of these eighteen appear
|
||||
* in `krow-backend/go-api/internal/domain/resources_gen.go` — a registry
|
||||
* generated from `information_schema`, so it cannot drift from the migrations —
|
||||
* and every one of those fifteen paths matches character for character. The
|
||||
* three that are absent from it are served by dedicated handlers rather than
|
||||
* the generic entity machinery: `AgentDefinition` and `SkillDefinition` by
|
||||
* `httpserver/definitions.go`, and the user by `httpserver/me.go`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every entity name the client knows, spelled as the frontend spells it.
|
||||
*
|
||||
* PascalCase and used verbatim in error messages — §5.1 of the API contract
|
||||
* makes the entity name load-bearing in a `not_found` message, which reads
|
||||
* `"<EntityName> <id> not found"` using this spelling rather than the table
|
||||
* name.
|
||||
*
|
||||
* `'User'` is in this union because it is in both runtime lists, not because it
|
||||
* works. There is no `/api/v1/users` route on the backend, and nothing in `src`
|
||||
* calls `base44.entities.User` — a call would 404. It is recorded here as it
|
||||
* is, dead entry and all, because removing it from the union while both runtime
|
||||
* lists still carry it would make the type a description of something other
|
||||
* than this codebase. Whether the entry should exist at all is a product
|
||||
* question, not a migration one.
|
||||
*/
|
||||
export type EntityName =
|
||||
| 'JobPosting'
|
||||
| 'JobApplication'
|
||||
| 'AIInterview'
|
||||
| 'Staff'
|
||||
| 'WorkerProfile'
|
||||
| 'EmployeeRole'
|
||||
| 'Course'
|
||||
| 'Badge'
|
||||
| 'LearningPath'
|
||||
| 'Certification'
|
||||
| 'RoleCategory'
|
||||
| 'UserActivity'
|
||||
| 'Evidence'
|
||||
| 'User'
|
||||
| 'Assignment'
|
||||
| 'ShiftRecord'
|
||||
| 'AgentDefinition'
|
||||
| 'SkillDefinition';
|
||||
|
||||
/**
|
||||
* The URL segment each entity lives under.
|
||||
*
|
||||
* Kebab-case plural, with mass nouns left singular — `staff`, `evidence`,
|
||||
* `user-activity` (§1). Declared rather than derived, for the reason
|
||||
* `httpClient` gives at the site of the real map: a rule that turns
|
||||
* `AIInterview` into `ai-interviews` and `Staff` into `staff` and `UserActivity`
|
||||
* into `user-activity` is three special cases wearing a trench coat, and a
|
||||
* wrong guess is a 404 at run time instead of a mistake anyone can see.
|
||||
*/
|
||||
export type EntityResourcePath =
|
||||
| 'job-postings'
|
||||
| 'job-applications'
|
||||
| 'ai-interviews'
|
||||
| 'staff'
|
||||
| 'worker-profiles'
|
||||
| 'employee-roles'
|
||||
| 'courses'
|
||||
| 'badges'
|
||||
| 'learning-paths'
|
||||
| 'certifications'
|
||||
| 'role-categories'
|
||||
| 'user-activity'
|
||||
| 'evidence'
|
||||
| 'users'
|
||||
| 'assignments'
|
||||
| 'shift-records'
|
||||
| 'agent-definitions'
|
||||
| 'skill-definitions';
|
||||
|
||||
/**
|
||||
* What `createEntity(name)` hands back: one entity's client surface.
|
||||
*
|
||||
* Eight members, transcribed from `src/api/httpClient.js` — the six methods
|
||||
* `store.js` declared, plus `bulkCreate` and the `entityName` the object
|
||||
* carries around.
|
||||
*
|
||||
* The defaults are part of the signature rather than incidental. `store.js`
|
||||
* declared `list(sort = '-created_date', limit = 100)` and several call sites
|
||||
* 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<T>` 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.
|
||||
*/
|
||||
export interface EntityClient<T = unknown> {
|
||||
entityName: EntityName;
|
||||
list(sort?: string, limit?: number): Promise<T[]>;
|
||||
filter(query?: Record<string, unknown>, sort?: string, limit?: number): Promise<T[]>;
|
||||
get(id: string): Promise<T>;
|
||||
create(data: Partial<T>): Promise<T>;
|
||||
update(id: string, data: Partial<T>): Promise<T>;
|
||||
/** Resolves to `{ id }`, matching what `store.js` returned (§4.3). */
|
||||
delete(id: string): Promise<{ id: string }>;
|
||||
/**
|
||||
* Sequential creates, not a batch endpoint and not `Promise.all`: there is no
|
||||
* bulk write in the contract (§12.1), and doing them one at a time keeps the
|
||||
* failure behaviour identical — the first rejection stops the run and the
|
||||
* records written before it stay written.
|
||||
*/
|
||||
bulkCreate(records?: Partial<T>[]): Promise<T[]>;
|
||||
}
|
||||
26
src/types/global.d.ts
vendored
Normal file
26
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export {};
|
||||
|
||||
/**
|
||||
* Globals this app adds to the browser's own.
|
||||
*
|
||||
* There is exactly one, and it is a custom event rather than anything hung off
|
||||
* `window` — nothing in `src` ever assigns a property to `window`, and this
|
||||
* should stay true.
|
||||
*
|
||||
* `krow:open-owliver` is how a component that is nowhere near the assistant
|
||||
* asks for the assistant to open. The talent shell listens for it
|
||||
* (`src/layouts/Layout.jsx`) and two buttons plus the employee dashboard
|
||||
* dispatch it. Declaring it in `WindowEventMap` is what lets
|
||||
* `addEventListener` and `dispatchEvent` agree on the name: without it both
|
||||
* sides fall back to the untyped string overload, and a misspelling on either
|
||||
* side is a listener that never fires and nothing that says why.
|
||||
*
|
||||
* The payload is an unparameterised `CustomEvent` because the event carries no
|
||||
* detail — it is a signal, not a message. If it ever gains one, give it
|
||||
* `CustomEvent<{ … }>` here and both ends are checked against it at once.
|
||||
*/
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'krow:open-owliver': CustomEvent;
|
||||
}
|
||||
}
|
||||
132
src/types/user.ts
Normal file
132
src/types/user.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The signed-in user, and the preferences that travel with the account.
|
||||
*
|
||||
* Types only — nothing here emits. Import with `import type`.
|
||||
*
|
||||
* Transcribed from the projection the server actually selects, not from the
|
||||
* documentation of it. `krow-backend/go-api/internal/httpserver/me.go` builds
|
||||
* every user record from one constant:
|
||||
*
|
||||
* const userColumns = `id::text AS id, legacy_id, full_name, email::text AS email,
|
||||
* role, account_type, status,
|
||||
* to_char(created_date …) AS created_date,
|
||||
* to_char(updated_date …) AS updated_date`
|
||||
*
|
||||
* and then attaches `preferences`. That is ten fields. The contract document's
|
||||
* worked example in §9 shows seven — it omits `status` and `updated_date`, and
|
||||
* it is the older of the two. The code is what answers the request, so the code
|
||||
* is what is written down here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the account is allowed to do.
|
||||
*
|
||||
* A closed union because the database closes it: `users_role_check` is
|
||||
* `CHECK (role IN ('admin', 'employer', 'talent'))`, so no other value can be
|
||||
* stored and none can be returned.
|
||||
*
|
||||
* This is the authorization field. It is server-owned — `PATCH /me` ignores it,
|
||||
* deliberately, because when it was writable any signed-in user could promote
|
||||
* themselves to admin with a one-line request.
|
||||
*/
|
||||
export type UserRole = 'admin' | 'employer' | 'talent';
|
||||
|
||||
/**
|
||||
* Whether the account may sign in.
|
||||
*
|
||||
* Closed for the same reason: `users_status_check` is
|
||||
* `CHECK (status IN ('active', 'suspended'))`. Also server-owned.
|
||||
*/
|
||||
export type UserStatus = 'active' | 'suspended';
|
||||
|
||||
/**
|
||||
* Product preferences, stored on the account.
|
||||
*
|
||||
* Three named keys and an open tail, and the open tail is not a hedge — it is
|
||||
* the shape. The `user_preferences` table has exactly three boolean columns
|
||||
* plus an `extra jsonb` blob, and the server merges that blob into the same
|
||||
* flat object it returns. Everything account-authored lives in there:
|
||||
* `customSkills` and `customAgents`, written by `WorkspaceSkills.jsx`,
|
||||
* `SkillEditor.jsx`, `OwliverSkillEditor.jsx` and `useAgents.js`.
|
||||
*
|
||||
* The index signature is therefore a true statement about the runtime value,
|
||||
* not a shortcut. Naming `customSkills` and `customAgents` explicitly was
|
||||
* tempting and rejected: their contents are the authored-definition shapes,
|
||||
* which belong to the agent layer, and this phase does not describe the agent
|
||||
* layer.
|
||||
*
|
||||
* The three named keys are the one place field naming diverges from the
|
||||
* database — camelCase here, snake_case in the columns. The server does that
|
||||
* translation; nothing on this side should.
|
||||
*/
|
||||
export interface UserPreferences {
|
||||
owliverDefault: boolean;
|
||||
compactDensity: boolean;
|
||||
emailDigest: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user as `GET /me` returns it.
|
||||
*
|
||||
* Four fields are optional, and the reason is worth stating because it is not
|
||||
* laziness. `base44.auth.me()` does not always resolve to a server response: on
|
||||
* the path through `readCachedUser` it returns
|
||||
* `{ ...DEMO_USER, ...JSON.parse(raw) }`, and `DEMO_USER` — the default shape
|
||||
* in `src/api/demoUser.js` that has to resolve during the first render, before
|
||||
* the server has answered — carries only `id`, `full_name`, `email`, `role`,
|
||||
* `account_type`, `created_date` and `preferences`.
|
||||
*
|
||||
* So a value of this type may genuinely lack `legacy_id`, `status` and
|
||||
* `updated_date`. Marking them required would describe the server's answer
|
||||
* accurately and the runtime value inaccurately, and the second is the one that
|
||||
* reaches components.
|
||||
*
|
||||
* `legacy_id` is additionally nullable at the source — `legacy_id text UNIQUE`,
|
||||
* no NOT NULL — so it is `string | null` when present. It holds the original
|
||||
* seeded identifier (`user_demo`); `id` is the uuid and is the only addressable
|
||||
* one.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
/**
|
||||
* Which product surface the person is looking at, Employer or Talent.
|
||||
*
|
||||
* Plain `string`, not a union, and that is checked rather than assumed: the
|
||||
* column is `account_type text NOT NULL DEFAULT 'employer'` with **no CHECK
|
||||
* constraint**, and the frontend reads it as open text — `|| 'applicant'`,
|
||||
* `|| 'unknown'`, `|| '—'` all appear at call sites. There is no closed set
|
||||
* to write down, so none is invented.
|
||||
*
|
||||
* Explicitly not an authorization field. `role` authorizes; this is display.
|
||||
*/
|
||||
account_type: string;
|
||||
created_date: string;
|
||||
preferences: UserPreferences;
|
||||
legacy_id?: string | null;
|
||||
status?: UserStatus;
|
||||
updated_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `auth.updatePreferences()` resolves to.
|
||||
*
|
||||
* Not a bare user, and the reason is a bug rather than a preference: a
|
||||
* swallowed `QuotaExceededError` once silently lost account-authored skills, so
|
||||
* a caller storing something it needs back after a reload has to be able to
|
||||
* tell a write that landed from one the browser refused. The cache is updated
|
||||
* either way — the change is real for the session even when it could not be
|
||||
* stored.
|
||||
*
|
||||
* Over HTTP a failed write is already a non-2xx, so the client synthesises
|
||||
* `{ user, persisted: true, error: null }` on success and lets the throw path
|
||||
* handle failure. `src/lib/skills/saveFeedback.js` is the only consumer.
|
||||
*/
|
||||
export interface PreferencesUpdateResult {
|
||||
user: User;
|
||||
persisted: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
43
src/vite-env.d.ts
vendored
Normal file
43
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* Vite's build-time additions to `import.meta`, and the environment this app
|
||||
* actually reads from it.
|
||||
*
|
||||
* The reference above is what supplies `import.meta.env`, `import.meta.glob`
|
||||
* and the `?raw` module shape. Without it the two registries that discover
|
||||
* agents and skills from the filesystem do not typecheck at all, and one of
|
||||
* them carries a hand-written `@ts-ignore` saying so.
|
||||
*
|
||||
* The interface below names the two variables this codebase reads. It does not
|
||||
* restrict `import.meta.env` to them — this declaration MERGES with Vite's,
|
||||
* which carries an open `[key: string]: any`, so `import.meta.env.VITE_ANYTHING`
|
||||
* still compiles and still comes back as `any`. Typing cannot close that hole;
|
||||
* only reading this list can.
|
||||
*
|
||||
* Declaring them is therefore documentation as much as typing: these are
|
||||
* inlined at build time, so a variable missing when the bundle is built is
|
||||
* missing for the life of that bundle, and a typo is not a runtime error
|
||||
* anywhere — it is a silent `undefined` and a fallback taken forever.
|
||||
*
|
||||
* VITE_API_BASE_URL src/api/httpClient.js — where the entity API lives.
|
||||
* Defaults to the same-origin '/api/v1', which is the
|
||||
* supported arrangement: the session cookie is HttpOnly
|
||||
* and SameSite=Lax, so a cross-origin value breaks auth.
|
||||
* VITE_AGENT_API src/components/ai-assistant/provider.js — the agent
|
||||
* endpoint. Unset means the local engine answers.
|
||||
*
|
||||
* VITE_API_PROXY_TARGET is deliberately absent: it is read by `vite.config.js`
|
||||
* through `loadEnv` at dev-server startup and never reaches `import.meta.env`.
|
||||
*
|
||||
* Both are optional because both have fallbacks, and typing them as required
|
||||
* would claim a guarantee the build does not make.
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_AGENT_API?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user