chore(ts-migration): migrate hooks and auth context to TypeScript

Phase 7. Four files, and the production bundle is byte-identical.

This is the phase where the generated entity types finally pay off. React Query
v5 infers a `mutationFn`'s parameter as `void` when nothing constrains it, so
every destructuring in `krowHooks` needed its shape written down - and those
shapes are real contracts, not guesses. Thirteen mutations now name what they
take, reusing `JobApplication`, `JobPosting`, `WorkerProfile` and `Course` from
`@/types/entities`: `useHireCandidate` takes `{ application, job }`,
`useCompleteCourse` takes `{ profile, course, quizScore }`. Five `{ id, data }`
mutations share one `IdPatch`, where `data` stays `any` on purpose - a PATCH
body is whichever fields the caller is changing, and naming a subset would
describe one call site rather than the endpoint.

Two local types absorb places where an object gains fields after it is built,
which TypeScript does not allow on a literal. `AssignmentEntry` declares
`application_id` and `application` as the alternatives they are - an existing
application named by id, or one described for the server to file in the same
transaction. `LearningProfile` narrows four `jsonb` columns the learning
mutations append to and spread; what the elements hold is still unstated,
because it still is.

`AuthContextValue` writes down the twelve keys every consumer reads. Two are
permanently inert and say so. `user` is `any` rather than `User`, and that is a
narrow, documented exception: `auth.me()` resolves either to the server record
or to the localStorage mirror over `DEMO_USER`, and `admin/Profile` reads
`user.avatar_url`, which is neither a column on `users` nor in the `/me`
projection - so typing it `User` would be accurate about the server and would
turn an always-undefined read into a compile error in a file this phase does not
touch.

`authError` is typed `{ type?: string } | null` rather than `null`, and
TypeScript is the reason. Typed as the provider actually behaves - always
`null` - it made `ProtectedRoute`'s `authError.type === 'user_not_registered'`
a property access on `never`: correct, and a report that the branch cannot be
reached in this build. The branch and `UserNotRegisteredError` are real, so what
a consumer may be handed is what is written down. That this build never produces
one is current behaviour, not the contract.

`use-size` gets a `Size` interface and a typed ref - a small, entirely clear
contract.

Thirteen JSDoc `@param {any}` comments became real annotations. That was not
cosmetic: left in place they changed esbuild's parenthesisation under the `.ts`
loader and put two redundant bytes into the production bundle. Converting them
brought the bundle back to byte-identical, which is how the difference was found
at all.

Verified: tsc 35 -> 35, set-difference showing zero introduced and zero removed;
zero errors in any Phase 7 file; production bundle byte-identical to d440036;
npm test 1684/1691 with the same seven failures; lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 00:26:24 +05:30
parent d440036211
commit 543da9d4e7
4 changed files with 157 additions and 44 deletions

View File

@@ -1,7 +1,13 @@
import * as React from "react"
export function useSize(ref) {
const [size, setSize] = React.useState(null)
/** The measured box. `null` until the first layout effect has run. */
export interface Size {
width: number
height: number
}
export function useSize(ref: React.RefObject<Element | null>): Size | null {
const [size, setSize] = React.useState<Size | null>(null)
// useLayoutEffect (not useEffect): the initial measurement must land before
// the browser paints, so consumers can render their real content on the

View File

@@ -1,4 +1,5 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import type { User } from '@/types/user';
import { base44 } from '@/api/base44Client';
/**
@@ -17,11 +18,56 @@ import { base44 } from '@/api/base44Client';
* client that reported the seeded user as permanently signed in.
*/
const AuthContext = createContext(/** @type {any} */ (null));
/**
* What `useAuth()` hands back.
*
* The twelve keys are a real contract — every consumer reads from this exact
* set — so they are written down rather than inferred. Two of them are
* permanently inert and say so: there are no remote app settings to fetch in
* this build, so `isLoadingPublicSettings` is always `false` and
* `appPublicSettings` always `null`. They stay because `App.tsx` and
* `ProtectedRoute` read them, and removing a key from the value is a
* behaviour change rather than a typing one.
*
* `user` is `any` rather than `User`, and that is a deliberate, narrow
* exception. `base44.auth.me()` resolves either to the server's record or to
* the localStorage mirror merged over `DEMO_USER`, and one consumer -
* `admin/Profile` - reads `user.avatar_url`, which is neither a column on
* `users` nor part of the `/me` projection. Typing this `User` would be
* accurate about the server and would turn that always-undefined read into a
* compile error in a file this phase does not touch. The field is worth
* raising separately; it is not this migration's to decide.
*/
export interface AuthContextValue {
user: any;
isAuthenticated: boolean;
isLoadingAuth: boolean;
isLoadingPublicSettings: false;
/**
* Why an object and not `null`, when this provider only ever sends `null`.
*
* `ProtectedRoute` branches on `authError.type === 'user_not_registered'` and
* renders `UserNotRegisteredError` for it. Typing this `null` made that
* branch's `.type` a property on `never` — TypeScript correctly reporting
* that the branch cannot be reached in this build. The branch and its
* component are real, so the shape a consumer may be handed is what is
* written down; that this build never produces one is the provider's current
* behaviour, not the contract.
*/
authError: { type?: string } | null;
appPublicSettings: null;
authChecked: boolean;
login: (credentials: { email: string; password: string; rememberMe?: boolean }) => Promise<User>;
logout: (redirectTo?: string) => Promise<void>;
navigateToLogin: () => void;
checkUserAuth: () => Promise<void>;
checkAppState: () => Promise<void>;
}
/** @param {any} props */
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<any>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoadingAuth, setIsLoadingAuth] = useState(true);
const [authChecked, setAuthChecked] = useState(false);
@@ -56,7 +102,11 @@ export const AuthProvider = ({ children }) => {
* say whether the address exists — so callers show it as-is rather than
* writing their own.
*/
const login = async ({ email, password, rememberMe = false }) => {
const login = async ({ email, password, rememberMe = false }: {
email: string;
password: string;
rememberMe?: boolean;
}) => {
const currentUser = await base44.auth.login({ email, password, rememberMe });
setUser(currentUser);
setIsAuthenticated(true);
@@ -64,7 +114,7 @@ export const AuthProvider = ({ children }) => {
return currentUser;
};
const logout = async (redirectTo = '/admin/login') => {
const logout = async (redirectTo: string = '/admin/login') => {
setUser(null);
setIsAuthenticated(false);
// Revokes the session server-side, expires the cookie, then redirects.

View File

@@ -1,5 +1,60 @@
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Course, JobApplication, JobPosting, WorkerProfile } from '@/types/entities';
/**
* One worker in an assignment batch, as the endpoint expects it.
*
* `application_id` and `application` are alternatives filled in AFTER the entry
* is built: an existing application is named by id, and a worker taken straight
* from the pool is described so the server can file one inside the same
* transaction. TypeScript fixes an object literal's type at creation, so both
* have to be declared here even though neither is set on the same pass.
*/
interface AssignmentEntry {
worker_email: any;
worker_name: any;
starts_at?: any;
ends_at?: any;
application_id?: string;
application?: Record<string, any>;
[key: string]: any;
}
/**
* A worker profile as the learning mutations read one.
*
* `completed_courses`, `earned_badges` and `capabilities` are `jsonb` columns,
* which the registry types `unknown` — correctly, since nothing on the backend
* declares what is inside them. These mutations append to them and spread them,
* so they are narrowed to arrays here. What the elements hold is still
* unstated, because it still is.
*/
type LearningProfile = Omit<
WorkerProfile, 'completed_courses' | 'earned_badges' | 'capabilities' | 'experience'
> & {
completed_courses?: any[];
earned_badges?: any[];
capabilities?: any[];
/* Narrowed for the same reason and additionally because the merged profile is
handed to `recalcProfilePatch`, which reads through it. */
experience?: any[];
};
/**
* The variables a mutation takes.
*
* React Query v5 infers a `mutationFn`'s parameter as `void` when nothing
* constrains it, so every destructuring below needs its shape written down.
* These are worth writing properly rather than stubbing: each one is a small,
* stable contract that the call sites already honour, and the entity records
* they carry are the generated types from `@/types/entities`.
*
* `data` stays `any` where it is a PATCH rather than a record — a caller sends
* whichever fields it is changing, which is what `PATCH` means, and naming a
* subset would describe one call site instead of the endpoint.
*/
interface IdPatch { id: string; data: any }
import { mergeLayouts, normalizeLayouts } from '@/lib/ui/patch';
import { API_BASE_URL, base44 } from '@/api/base44Client';
import { request } from '@/api/httpClient';
@@ -27,7 +82,7 @@ export function useCurrentUser() {
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch),
mutationFn: (patch: any) => base44.auth.updateMe(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
@@ -58,8 +113,8 @@ export function usePreferences() {
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
onSuccess: /** @param {any} result */ (result) => {
mutationFn: (patch: any) => base44.auth.updatePreferences(patch),
onSuccess: (result: any) => {
queryClient.setQueryData(['user'], result.user);
queryClient.invalidateQueries({ queryKey: ['user'] });
},
@@ -169,7 +224,7 @@ export function useStaff() {
export function useUpdateStaff() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data),
mutationFn: ({ id, data }: IdPatch) => base44.entities.Staff.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
},
@@ -193,7 +248,7 @@ export function useCertifications() {
export function useCreateCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data),
mutationFn: (data: any) => base44.entities.Certification.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
@@ -203,7 +258,7 @@ export function useCreateCertification() {
export function useDeleteCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id),
mutationFn: (id: string) => base44.entities.Certification.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
@@ -213,7 +268,7 @@ export function useDeleteCertification() {
export function useCreateRoleCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data),
mutationFn: (data: any) => base44.entities.RoleCategory.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
},
@@ -306,8 +361,9 @@ function reportMissingRoute() {
* composer is what the reader came for — and an error banner over a suggestion
* is a bigger interruption than the suggestion was worth.
*/
/** @param {any} request */
export async function fetchOwliverSuggestions({ page, query = '' } = {}) {
export async function fetchOwliverSuggestions(
{ page, query = '' }: { page?: string; query?: string } = {},
) {
if (!page || routeUnavailable) return [];
try {
return await base44.owliver.suggestions({ page, query });
@@ -334,8 +390,9 @@ export async function fetchOwliverSuggestions({ page, query = '' } = {}) {
* The untyped request is the one that reads the database, so it is invalidated
* whenever something the context counts has changed. See `owliverContextKey`.
*/
/** @param {any} request */
export function useOwliverSuggestions({ page, query = '', enabled = true } = {}) {
export function useOwliverSuggestions(
{ page, query = '', enabled = true }: { page?: string; query?: string; enabled?: boolean } = {},
) {
const typed = String(query || '').trim();
return useQuery({
queryKey: ['owliverSuggestions', page || null, typed],
@@ -355,7 +412,7 @@ export function useOwliverSuggestions({ page, query = '', enabled = true } = {})
export function useCreateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
mutationFn: (data: any) => base44.entities.JobPosting.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
/* The position count the server ranks suggestions against has changed, so
@@ -388,7 +445,7 @@ export function useCreateJobPosting() {
export function useCreateWorkerWithRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => request('POST', '/worker-profiles/with-role', { body: data }),
mutationFn: (data: any) => request('POST', '/worker-profiles/with-role', { body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
@@ -402,7 +459,7 @@ export function useCreateWorkerWithRole() {
export function useCreateEmployeeRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.EmployeeRole.create(data),
mutationFn: (data: any) => base44.entities.EmployeeRole.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
@@ -415,7 +472,7 @@ export function useCreateEmployeeRole() {
export function useUpdateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobPosting.update(id, data),
mutationFn: ({ id, data }: IdPatch) => base44.entities.JobPosting.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
@@ -427,7 +484,7 @@ export function useUpdateJobPosting() {
export function useCreateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
mutationFn: (data: any) => base44.entities.JobApplication.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
@@ -439,7 +496,7 @@ export function useCreateApplication() {
export function useUpdateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
mutationFn: ({ id, data }: IdPatch) => base44.entities.JobApplication.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
@@ -450,7 +507,7 @@ export function useUpdateApplication() {
export function useScreenCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
mutationFn: async ({ application, job }: { application: JobApplication; job: JobPosting }) => {
const result = await screenCandidate(application, job);
return base44.entities.JobApplication.update(application.id, {
status: 'ai_screened',
@@ -474,7 +531,7 @@ export function useScreenCandidate() {
export function useScreenAllCandidates() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ applications, job }) => {
mutationFn: async ({ applications, job }: { applications: JobApplication[]; job: JobPosting }) => {
const results = [];
for (const app of applications) {
if (app.status === 'applied' || app.status === 'ai_screened') {
@@ -505,7 +562,7 @@ export function useScreenAllCandidates() {
export function useGenerateJobDescription() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ data, draftId }) => {
mutationFn: async ({ data, draftId }: { data: any; draftId?: string }) => {
const result = await generateJobDescription(data);
if (draftId) {
await base44.entities.JobPosting.update(draftId, {
@@ -543,7 +600,7 @@ export function useGenerateJobDescription() {
export function useHireCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
mutationFn: async ({ application, job }: { application: JobApplication; job: JobPosting }) => {
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
const result = await base44.workflows.hire(application.id, {
role: job?.title || job?.role_category || 'Staff',
@@ -568,7 +625,7 @@ export function useHireCandidate() {
export function useMatchTalentForJob() {
return useMutation({
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
mutationFn: async ({ job, profiles }: { job: JobPosting; profiles: WorkerProfile[] }) => matchTalentForJob(job, profiles),
});
}
@@ -584,7 +641,7 @@ export function useMatchTalentForJob() {
export function useCreateInterview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data),
mutationFn: (data: any) => base44.entities.AIInterview.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
@@ -616,7 +673,7 @@ export function useCourse(id) {
export function useCreateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data),
mutationFn: (data: any) => base44.entities.Course.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
logActivity('create_skill_training');
@@ -628,7 +685,7 @@ export function useCreateCourse() {
export function useUpdateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Course.update(id, data),
mutationFn: ({ id, data }: IdPatch) => base44.entities.Course.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
@@ -677,7 +734,7 @@ export function useAssignments() {
export function useAssignWorkers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
mutationFn: async ({ position, workers = [], applications = [] }: { position: JobPosting; workers?: any[]; applications?: JobApplication[] }) => {
if (workers.length === 0) return [];
const startsAt = position.start_date
@@ -688,7 +745,7 @@ export function useAssignWorkers() {
: new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString();
const batch = workers.map((worker) => {
const entry = {
const entry: AssignmentEntry = {
worker_email: worker.email,
worker_name: worker.name,
starts_at: startsAt,
@@ -769,7 +826,7 @@ export function useAssignWorkers() {
export function useMarkInterviewReady() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position }) => {
mutationFn: async ({ application, position }: { application: JobApplication; position: JobPosting }) => {
if (!application?.id) throw new Error('No application to schedule against');
const updated = await base44.entities.JobApplication.update(application.id, {
@@ -819,7 +876,7 @@ export function useScheduleHumanInterview() {
const markInterviewReady = useMarkInterviewReady();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position, type, date, time, notes }) => {
mutationFn: async ({ application, position, type, date, time, notes }: { application: JobApplication; position: JobPosting; type?: string; date?: string; time?: string; notes?: string }) => {
if (!application?.id) throw new Error('No application to schedule against');
if (!date || !time) throw new Error('An interview needs a date and a time');
@@ -882,12 +939,12 @@ export function useCompleteHumanInterview() {
const createInterview = useCreateInterview();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position, assessment, notes }) => {
mutationFn: async ({ application, position, assessment, notes }: { application: JobApplication; position: JobPosting; assessment?: any; notes?: string }) => {
if (!application?.id) throw new Error('No application to record an interview against');
if (!application.job_posting_id) throw new Error('That application is not attached to a position');
/** @type {any} */
const record = {
const record: Record<string, any> = {
application_id: application.id,
job_posting_id: application.job_posting_id,
/* Denormalised the same way the AI path denormalises them, and factual
@@ -943,7 +1000,7 @@ export function useRecordInterviewNotHeld() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position, outcome, notes }) => {
mutationFn: async ({ application, position, outcome, notes }: { application: JobApplication; position: JobPosting; outcome?: string; notes?: string }) => {
if (!application?.id) throw new Error('No application to record against');
if (outcome !== CANCELLED && outcome !== NO_SHOW) {
throw new Error(`Not an outcome this records: ${outcome}`);
@@ -1023,7 +1080,7 @@ export function useWorkerProfiles() {
export function useUpdateWorkerProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
mutationFn: ({ id, data }: IdPatch) => base44.entities.WorkerProfile.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
@@ -1034,7 +1091,7 @@ export function useUpdateWorkerProfile() {
export function useCompleteCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ profile, course, quizScore }) => {
mutationFn: async ({ profile, course, quizScore }: { profile: LearningProfile; course: Course; quizScore?: number }) => {
const already = (profile.completed_courses || []).some(c => c.course_id === course.id);
const completed_courses = already ? profile.completed_courses : [
...profile.completed_courses,
@@ -1075,7 +1132,7 @@ export function useEvidenceList(workerEmail) {
export function useSubmitChallenge() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ profile, course, type, mediaUrl, transcript, identified }) => {
mutationFn: async ({ profile, course, type, mediaUrl, transcript, identified }: { profile: LearningProfile; course: Course; type?: string; mediaUrl?: string; transcript?: string; identified?: any[] }) => {
const result = await evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName: profile?.full_name });
const verdict = result.verdict || 'needs_work';
const score = Math.round(Number(result.score) || 0);
@@ -1133,7 +1190,7 @@ export function useSubmitChallenge() {
export function useVerifyEvidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ evidenceId }) =>
mutationFn: async ({ evidenceId }: { evidenceId: string }) =>
base44.entities.Evidence.update(evidenceId, {
supervisor_verified: true,
supervisor_name: 'Supervisor',