import React from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { mergeLayouts, normalizeLayouts } from '@/lib/ui/patch'; import { API_BASE_URL, base44 } from '@/api/base44Client'; import { request } from '@/api/httpClient'; import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi'; import { recalcProfilePatch } from './krowScore'; import { logActivity } from './userTracking'; import { evaluateChallenge } from './provingGround'; /** * The signed-in user, on the `['user']` key the assistant and the 404 page * already read. One cache entry means a profile edit reaches the navbar, the * Profile page and Owliver's greeting without any of them knowing about each * other. */ export function useCurrentUser() { return useQuery({ queryKey: ['user'], queryFn: () => base44.auth.me().catch(() => null), staleTime: Infinity, }); } /** Saves profile fields and refreshes every consumer of `['user']`. */ export function useUpdateProfile() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch), onSuccess: (user) => { queryClient.setQueryData(['user'], user); queryClient.invalidateQueries({ queryKey: ['user'] }); }, }); } /** * Product preferences, stored on the user record. * * Reads fall back to the synchronous accessor so the first render already has * the real values rather than a default that corrects itself a tick later. */ export function usePreferences() { const { data: user } = useCurrentUser(); return { ...base44.auth.preferences(), ...(user?.preferences || {}) }; } /** * Writes one or more preferences and refreshes every consumer of `['user']`. * * The mutation resolves to `{ user, persisted, error }`, not to the user — a * caller storing something it needs back after a reload, which is every caller * writing `customSkills`, 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 * this session even when it could not be stored. */ export function useUpdatePreferences() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch), onSuccess: /** @param {any} result */ (result) => { queryClient.setQueryData(['user'], result.user); queryClient.invalidateQueries({ queryKey: ['user'] }); }, }); } /** * A person's own UI layout changes, per page. * * Stored beside `customSkills` in the account's preferences, which is a store * that already exists, is already per-user, and already reaches Postgres * through `PATCH /api/v1/me/preferences`. No new endpoint, no new table, and * nothing to deploy — which is the whole point: a layout change is a runtime * change, and a runtime change that needed a release would not be one. * * What is written is the **operation list** the UI engine validated, never a * copy of the rendered tree and never Markdown. A stored tree would be a * photograph of the page on the day it was saved; an operation still means what * it said after a release moves the built-ins around it. * * The whole `uiLayouts` object is sent on every write, because the endpoint * shallow-merges its top-level keys: sending one page would replace the map and * silently drop every other page's layout. */ export function useUiLayouts() { const preferences = usePreferences(); const update = useUpdatePreferences(); const layouts = React.useMemo( () => normalizeLayouts(preferences.uiLayouts).layouts, [preferences.uiLayouts] ); /** * Store one page's patch. * * A patch with no operations is removed rather than stored empty — that is * the same state as never having customised the page, and keeping the key * would grow the blob with a record of every page somebody once opened. */ const save = React.useCallback(async (page, patch) => { const key = String(page || '').trim(); if (!key) return null; /* Only `uiLayouts` is sent. Every other preference — `customSkills` above all — is left for the endpoint's shallow merge to preserve, so a layout change can never disturb an authored skill. */ return update.mutateAsync({ uiLayouts: mergeLayouts(layouts, key, patch) }); }, [layouts, update]); return { layouts, save, saving: update.isPending }; } export function useJobPostings() { return useQuery({ queryKey: ['jobPostings'], queryFn: () => base44.entities.JobPosting.list('-created_date', 100), }); } export function useJobPosting(id) { return useQuery({ queryKey: ['jobPosting', id], queryFn: () => base44.entities.JobPosting.get(id), enabled: !!id, }); } export function useApplications(jobPostingId) { return useQuery({ queryKey: ['applications', jobPostingId || 'all'], queryFn: () => jobPostingId ? base44.entities.JobApplication.filter({ job_posting_id: jobPostingId }, '-ai_score', 200) : base44.entities.JobApplication.list('-ai_score', 200), }); } export function useInterviews() { return useQuery({ queryKey: ['interviews'], queryFn: () => base44.entities.AIInterview.list('-created_date', 100), }); } /** * Shifts worked, missed and overrun. * * A higher limit than the other collections because this is the one that grows * per person per day: three workers over eight weeks is already north of a * hundred records, and a truncated read would silently under-report attendance * rather than fail. */ export function useShiftRecords() { return useQuery({ queryKey: ['shiftRecords'], queryFn: () => base44.entities.ShiftRecord.list('-created_date', 500), }); } export function useStaff() { return useQuery({ queryKey: ['staff'], queryFn: () => base44.entities.Staff.list('-created_date', 100), }); } export function useUpdateStaff() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['staff'] }); }, }); } export function useRoleCategories() { return useQuery({ queryKey: ['roleCategories'], queryFn: () => base44.entities.RoleCategory.list('-created_date', 100), }); } export function useCertifications() { return useQuery({ queryKey: ['certifications'], queryFn: () => base44.entities.Certification.list('-created_date', 200), }); } export function useCreateCertification() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['certifications'] }); }, }); } export function useDeleteCertification() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['certifications'] }); }, }); } export function useCreateRoleCategory() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roleCategories'] }); }, }); } export function useUserActivity() { return useQuery({ queryKey: ['userActivity'], queryFn: () => base44.entities.UserActivity.list('-created_date', 500), }); } /** * What Owliver could usefully be asked on this page. * * The server answers, and the server is the only thing that decides: it filters * the readings against the caller's role and — with nothing typed — ranks them * against the organization's actual state in PostgreSQL. This hook requests and * caches; it does not score, filter or reorder. * * Keyed on the page and the query together, so every distinct composer state * has its own cache entry and typing back to something already asked is * answered without a request. `placeholderData` keeps the previous answer on * screen while the next one is in flight, which is what stops the chip row * emptying and refilling between keystrokes. * * A failure resolves to no suggestions rather than to an error state. The panel * still works with none — the composer is what the reader came for — and an * error banner over a suggestion is a bigger interruption than the suggestion * was worth. * * The untyped request is the one that reads the database, so it is invalidated * whenever something the context counts has changed. See `owliverContextKey`. */ export const owliverContextKey = ['owliverSuggestions']; /** * Whether this backend serves the endpoint at all. * * A deployment lag, not a bug, and it has happened: the route exists in the Go * source and is not yet on the host a given dev server proxies to, which * answers `404` for it and `200` for everything else. Every keystroke would * then be a request that cannot succeed, and no later keystroke can change the * answer — so the first `404` closes the circuit for the rest of the page load. * * Only `404` closes it. A `401` means the session is not signed in, a `500` * means the server had a bad moment, and an unreachable API means it is being * restarted: all three are worth trying again, and treating them as "this * backend does not have the route" would silence suggestions permanently over a * temporary fault. Module-level rather than component state because the fact is * about the backend, not about one panel. */ let routeUnavailable = false; /** * Say it once, out loud. * * The circuit breaker below stops the requests; it must not stop the reader * finding out why the chip row is empty. A suggestion that quietly never * arrives is indistinguishable from a page that has nothing to suggest, and * that ambiguity is what makes this fault expensive — it looks like a frontend * bug and it is a deployment lag. * * So the panel degrades to no suggestions, and the console says exactly which * request failed and what would fix it. Nothing is substituted for the missing * answer: there is no fallback list, and no locally-ranked catalogue standing * in for the server's. An empty row is the honest rendering of "the backend * could not tell us". */ function reportMissingRoute() { if (routeUnavailable) return; routeUnavailable = true; console.warn( `[krow] ${API_BASE_URL}/owliver/suggestions answered 404. The backend this ` + 'frontend is talking to does not register that route, so Owliver will show ' + 'no suggestion chips until it is deployed. This is a backend deployment ' + 'version mismatch, not a frontend fault — the route exists in the Go source ' + '(go-api/internal/httpserver/owliver.go). Nothing is being substituted for ' + 'the missing answer.' ); } /** * Ask the API what could usefully be asked here. * * The one place the request is made, so the circuit breaker above cannot be * bypassed by a caller that reaches for `base44.owliver` directly. Resolves to * an empty list on any failure: the panel works with no suggestions — the * 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 = '' } = {}) { if (!page || routeUnavailable) return []; try { return await base44.owliver.suggestions({ page, query }); } catch (error) { if (error?.status === 404) reportMissingRoute(); return []; } } /** * What Owliver could usefully be asked on this page. * * The server answers, and the server is the only thing that decides: it filters * the readings against the caller's role and — with nothing typed — ranks them * against the organization's actual state in PostgreSQL. This hook requests and * caches; it does not score, filter or reorder. * * Keyed on the page and the query together, so every distinct composer state * has its own cache entry and typing back to something already asked is * answered without a request. `placeholderData` keeps the previous answer on * screen while the next one is in flight, which is what stops the chip row * emptying and refilling between keystrokes. * * 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 } = {}) { const typed = String(query || '').trim(); return useQuery({ queryKey: ['owliverSuggestions', page || null, typed], queryFn: () => fetchOwliverSuggestions({ page, query: typed }), enabled: Boolean(page) && enabled, /* Long enough that a keystroke returned to is instant, short enough that a position created in another tab is reflected on the next open. */ staleTime: 30_000, placeholderData: (previous) => previous, /* `fetchOwliverSuggestions` never rejects, so a retry would only repeat a request that already resolved. Said explicitly so the default of one retry does not read as a safety net that is doing something. */ retry: false, }); } export function useCreateJobPosting() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['jobPostings'] }); /* The position count the server ranks suggestions against has changed, so what Owliver offers next has to be asked again rather than read from a cache written before the record existed. */ queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('create_position'); }, }); } /** * Record what a worker declares they do. * * The supply-side twin of `useCreateJobPosting`. It invalidates the worker * queries as well as its own, because a declared role changes what the Talent * Pool shows about that person — and the Owliver context, because the * organization now has one more worker offering that role and what is worth * asking has changed with it. */ /** * Record a NEW employee and their first declared role, in one transaction. * * One request, not two. Creating the worker and then the role as separate calls * leaves a worker nobody meant to create when the second fails — indistinguishable * from a real one and with nothing to say why it is there. The endpoint writes * both inside a transaction and rolls the worker back if the role cannot be * written, so a refused create leaves the database exactly as it was. */ export function useCreateWorkerWithRole() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => request('POST', '/worker-profiles/with-role', { body: data }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['employeeRoles'] }); queryClient.invalidateQueries({ queryKey: ['workerProfiles'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('create_employee_role'); }, }); } /** Record another role for somebody already on file. A different request. */ export function useCreateEmployeeRole() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.EmployeeRole.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['employeeRoles'] }); queryClient.invalidateQueries({ queryKey: ['workerProfiles'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('create_employee_role'); }, }); } export function useUpdateJobPosting() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobPosting.update(id, data), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['jobPostings'] }); queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); }, }); } export function useCreateApplication() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('apply_job'); }, }); } export function useUpdateApplication() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); }, }); } export function useScreenCandidate() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ application, job }) => { const result = await screenCandidate(application, job); return base44.entities.JobApplication.update(application.id, { status: 'ai_screened', ai_score: result.overall_score, ai_match_label: result.match_label, ai_summary: result.summary, ai_strengths: result.strengths, ai_gaps: result.gaps, ai_recommendation: result.recommendation, score_breakdown: result.score_breakdown, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('screen_candidate'); }, }); } export function useScreenAllCandidates() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ applications, job }) => { const results = []; for (const app of applications) { if (app.status === 'applied' || app.status === 'ai_screened') { const result = await screenCandidate(app, job); const updated = await base44.entities.JobApplication.update(app.id, { status: 'ai_screened', ai_score: result.overall_score, ai_match_label: result.match_label, ai_summary: result.summary, ai_strengths: result.strengths, ai_gaps: result.gaps, ai_recommendation: result.recommendation, score_breakdown: result.score_breakdown, }); results.push(updated); } } return results; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('screen_candidate'); }, }); } export function useGenerateJobDescription() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ data, draftId }) => { const result = await generateJobDescription(data); if (draftId) { await base44.entities.JobPosting.update(draftId, { description: result.description, responsibilities: result.responsibilities, qualifications: result.qualifications, nice_to_haves: result.nice_to_haves, ai_generated: true, }); queryClient.invalidateQueries({ queryKey: ['jobPostings'] }); queryClient.invalidateQueries({ queryKey: ['jobPosting', draftId] }); } return result; }, }); } /** * Hire a candidate: the application moves to `hired` and the staff record * appears, in one request and one transaction. * * This used to be a `PATCH` followed by a `POST`, and the failure it kept * inviting was the second one not landing: the candidate read as hired * everywhere while no employment record existed, and nothing in the UI could * tell. `POST /job-applications/{id}/hire` does both writes or neither. * * Only the fields a hiring decision actually chooses are sent. Name, email, * phone, score, posting and application id are carried across from the * application by the server — they are already the truth about this person, and * sending them from here is how the two records drift apart. * * Resolves to the updated **application**, which is what the two-call version * returned, so every call site is unchanged. */ export function useHireCandidate() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ application, job }) => { 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', profile_tier: tier, hire_date: new Date().toISOString().split('T')[0], status: 'onboarding', }); return result?.application; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); queryClient.invalidateQueries({ queryKey: ['staff'] }); /* The `hire_candidate` entry is written inside the same transaction as the hire, so there is nothing to log here — only something to refetch. A second `logActivity` call would file a duplicate audit row for one action. */ queryClient.invalidateQueries({ queryKey: ['userActivity'] }); }, }); } export function useMatchTalentForJob() { return useMutation({ mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles), }); } /** * Record a completed interview. * * One request writes both records: the server moves the application to * `interview`, sets `interview_id` and copies the score across in the same * transaction as the interview. Callers must not patch the application * themselves afterwards — that was the old two-call flow, and it is the reason * `['applications']` is invalidated here. */ export function useCreateInterview() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interviews'] }); queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('start_interview'); }, }); } /* ===== University + Worker Intelligence Profile ===== */ export function useCourses() { return useQuery({ queryKey: ['courses'], queryFn: () => base44.entities.Course.list('-created_date', 200) }); } export function useCourse(id) { return useQuery({ queryKey: ['course', id], queryFn: () => base44.entities.Course.get(id), enabled: !!id }); } /** * Author a skill in KROW Forge. * * A skill and the challenge that verifies it are one Course record — the entity * already carries the skill (title, category, difficulty, proof_skill), the * training (quiz, estimated_minutes), the proof (challenge.type) and the * evaluation Owliver runs (challenge.rubric). So Forge authoring is a create on * the entity the whole product already reads, not a parallel store. */ export function useCreateCourse() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['courses'] }); logActivity('create_skill_training'); }, }); } /** Status changes and edits to an existing skill. */ export function useUpdateCourse() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Course.update(id, data), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['courses'] }); queryClient.invalidateQueries({ queryKey: ['course', variables.id] }); }, }); } /* ── Workforce assignment ──────────────────────────────────────────────── */ /** Every assignment on file. The workforce side of the source of truth. */ export function useAssignments() { return useQuery({ queryKey: ['assignments'], queryFn: () => base44.entities.Assignment.list('-created_date', 500), }); } /** * Put people on a position. * * Deliberately a bulk mutation taking an already-decided list: the deciding * happens in `lib/workforce.js` and the confirming happens in the UI, so by the * time anything reaches here the admin has seen exactly who and how many. There * is no code path that assigns somebody without that preview having been shown. * * Still three writes per person — the assignment, the application's status, and * the activity event carrying every id involved — but they are now one request * and one transaction rather than 3n sequential round-trips that could fail in * the middle. Assigning six people and having the fourth fail used to leave * three placed, three not, and the caller unsure which; the batch is now * all-or-nothing. * * Per worker the request says one of two things about the application: * * - `application_id`, when this page already holds the application this * person filed for this posting. The server moves it to `assigned`. * - an `application` payload otherwise. The application is what puts a person * *in the pipeline for this role*, and every downstream step keys on it — * the candidate profile is addressed by it, and the AI interview takes one * as its subject — so a worker pulled from the existing workforce gets one * filed. The server finds it or files it inside the same transaction, which * is what stops a stale local list from producing a duplicate. * * Resolves to the created assignment records, as the loop did. */ export function useAssignWorkers() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => { if (workers.length === 0) return []; const startsAt = position.start_date ? new Date(position.start_date).toISOString() : new Date().toISOString(); const endsAt = position.duration_months == null ? null : new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString(); const batch = workers.map((worker) => { const entry = { worker_email: worker.email, worker_name: worker.name, starts_at: startsAt, ends_at: endsAt, source: 'owliver', match_score: worker.score ?? null, }; const application = applications.find( (a) => a.job_posting_id === position.id && String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase() ); if (application) { entry.application_id = application.id; } else { entry.application = { applicant_name: worker.name, email: worker.email || '', phone: worker.profile?.phone || '', years_experience: worker.profile?.experience_years || 0, skills: worker.profile?.skills || [], availability: worker.profile?.availability || [], certifications: worker.profile?.certifications || [], selfie_url: worker.profile?.selfie_url || '', professional_summary: worker.profile?.career_goals || '', cover_letter: '', job_posting_id: position.id, job_title: position.title, status: 'assigned', ai_score: worker.score ?? 0, }; } return entry; }); /* The `assign_employee` entries are written inside the same transaction, so nothing is logged from here — a `logActivity` call per worker would file a duplicate audit row for every placement. */ const result = await base44.workflows.assign(position.id, batch); return result?.assignments || []; }, onSuccess: () => { /* Everything that reads workforce state refreshes together, so the position card, the detail page and Owliver's next answer cannot be looking at three different moments. */ queryClient.invalidateQueries({ queryKey: ['assignments'] }); queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); queryClient.invalidateQueries({ queryKey: ['jobPostings'] }); queryClient.invalidateQueries({ queryKey: ['userActivity'] }); }, }); } /** * Put an assigned candidate in front of the AI interview. * * Deliberately *not* a call to `useCreateInterview`. `AIInterviewModal` — the AI * Interview Owliver — writes its own `AIInterview` record when the interview * actually finishes, carrying the transcript, the scores and the verdict. * Creating one here would produce a second, empty interview record for the same * person: a duplicate system, and a row claiming an interview happened when * nobody has spoken yet. * * What is missing before an interview can run is the application being *at* * interview stage. That is what this writes — the same status transition the * Position and Candidates pages already perform — which is what makes the * candidate appear as interview-ready everywhere and lets the existing modal * open on them. */ export function useMarkInterviewReady() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ application, position }) => { if (!application?.id) throw new Error('No application to schedule against'); const updated = await base44.entities.JobApplication.update(application.id, { status: 'interview', }); await logActivity('interview_ready', { details: `${application.applicant_name} moved to interview for ${position?.title || application.job_title}`, position_id: position?.id || application.job_posting_id, application_id: application.id, }); return updated; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: owliverContextKey }); queryClient.invalidateQueries({ queryKey: ['interviews'] }); queryClient.invalidateQueries({ queryKey: ['userActivity'] }); }, }); } export function useLearningPaths() { return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) }); } export function useBadges() { return useQuery({ queryKey: ['badges'], queryFn: () => base44.entities.Badge.list('-created_date', 200) }); } /** * The signed-in worker's profile, created on first access if absent. * * `enabled: false` skips it entirely — callers that only sometimes need the * profile must gate it, because the query has a side effect (it creates the * record) and should not run for employer or admin sessions. */ export function useWorkerProfile({ enabled = true } = {}) { return useQuery({ enabled, queryKey: ['workerProfile'], queryFn: async () => { const me = await base44.auth.me(); const list = await base44.entities.WorkerProfile.filter({ email: me.email }, '-created_date', 1); if (list.length > 0) return list[0]; return base44.entities.WorkerProfile.create({ full_name: me.full_name || (me.email ? me.email.split('@')[0] : 'Worker'), email: me.email, experience_years: 0, completed_courses: [], earned_badges: [], skills: [], languages: [], availability: [], experience: [], xp: 0, krow_score: 0, reliability_score: 0, profile_completion: 10, ai_interview_score: 0, attendance_score: 100, performance_score: 0, client_rating: 0, supervisor_rating: 0, }); }, }); } export function useWorkerProfiles() { return useQuery({ queryKey: ['workerProfiles'], queryFn: () => base44.entities.WorkerProfile.list('-krow_score', 500), }); } export function useUpdateWorkerProfile() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workerProfile'] }); queryClient.invalidateQueries({ queryKey: ['workerProfiles'] }); }, }); } export function useCompleteCourse() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ profile, course, quizScore }) => { const already = (profile.completed_courses || []).some(c => c.course_id === course.id); const completed_courses = already ? profile.completed_courses : [ ...profile.completed_courses, { course_id: course.id, title: course.title, score: quizScore, completed_date: new Date().toISOString() }, ]; const level = course.difficulty === 'beginner' ? 'bronze' : course.difficulty === 'intermediate' ? 'silver' : 'gold'; const earned_badges = (already || !course.badge_reward) ? profile.earned_badges : [ ...profile.earned_badges, { name: course.badge_reward, level, course_id: course.id, earned_date: new Date().toISOString() }, ]; const xp = (profile.xp || 0) + (already ? 0 : (course.xp || 0)); const merged = { ...profile, completed_courses, earned_badges, xp }; const patch = { completed_courses, earned_badges, xp, ...recalcProfilePatch(merged) }; return base44.entities.WorkerProfile.update(profile.id, patch); }, /* Completing training changes a verified skill level, and a level is read by Forge, the profile and every position match — so the workforce list is invalidated too, not just the one profile. */ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workerProfile'] }); queryClient.invalidateQueries({ queryKey: ['workerProfiles'] }); }, }); } /* ===== Proving Ground — evidence-based challenges ===== */ export function useEvidenceList(workerEmail) { return useQuery({ queryKey: ['evidence', workerEmail || 'all'], queryFn: () => workerEmail ? base44.entities.Evidence.filter({ worker_email: workerEmail }, '-created_date', 200) : base44.entities.Evidence.list('-created_date', 200), enabled: !!workerEmail, }); } export function useSubmitChallenge() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ profile, course, type, mediaUrl, transcript, identified }) => { 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); const passScore = Number(course.pass_score) || 70; const passed = verdict === 'verified' && score >= passScore; const evidence = await base44.entities.Evidence.create({ course_id: course.id, course_title: course.title, skill: course.proof_skill || course.title, worker_email: profile.email, worker_name: profile.full_name, type, media_url: mediaUrl || '', transcript: identified ? JSON.stringify(identified) : (transcript || ''), ai_verdict: verdict, ai_score: score, ai_rubric: result.rubric || {}, ai_feedback: result.feedback || '', }); if (!passed) return { evidence, passed, result }; const already = (profile.completed_courses || []).some((c) => c.course_id === course.id); const completed_courses = already ? profile.completed_courses : [ ...profile.completed_courses, { course_id: course.id, title: course.title, score, completed_date: new Date().toISOString() }, ]; const level = course.difficulty === 'beginner' ? 'bronze' : course.difficulty === 'intermediate' ? 'silver' : 'gold'; const earned_badges = (already || !course.badge_reward) ? profile.earned_badges : [ ...profile.earned_badges, { name: course.badge_reward, level, course_id: course.id, earned_date: new Date().toISOString() }, ]; const skill = course.proof_skill || course.title; const capabilities = (profile.capabilities || []).some((c) => c.skill === skill) ? profile.capabilities : [...(profile.capabilities || []), { skill, level: 'Verified', evidence_id: evidence.id, status: 'verified', verified_date: new Date().toISOString() }]; const xp = (profile.xp || 0) + (already ? 0 : (course.xp || 0)); const merged = { ...profile, completed_courses, earned_badges, capabilities, xp }; const patch = { completed_courses, earned_badges, capabilities, xp, ...recalcProfilePatch(merged) }; await base44.entities.WorkerProfile.update(profile.id, patch); return { evidence, passed, result }; }, /* The whole loop lands here: evidence written, profile updated, and every reader of a skill level — Forge, profiles, position matching — refreshed from the same mutation that Owliver's verdict triggered. */ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workerProfile'] }); queryClient.invalidateQueries({ queryKey: ['workerProfiles'] }); queryClient.invalidateQueries({ queryKey: ['evidence'] }); }, }); } export function useVerifyEvidence() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} vars */ async ({ evidenceId }) => base44.entities.Evidence.update(evidenceId, { supervisor_verified: true, supervisor_name: 'Supervisor', verified_date: new Date().toISOString(), }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['evidence'] }), }); }