Files
krow_talent_app/src/lib/krowHooks.js
2026-08-20 18:18:10 +05:30

708 lines
26 KiB
JavaScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
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'] });
},
});
}
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),
});
}
export function useCreateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
logActivity('create_position');
},
});
}
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] });
},
});
}
export function useCreateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
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'] });
},
});
}
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'] });
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'] });
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;
},
});
}
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 updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
await base44.entities.Staff.create({
name: application.applicant_name,
email: application.email,
phone: application.phone,
role: job?.title || job?.role_category || 'Staff',
profile_tier: tier,
hire_date: new Date().toISOString().split('T')[0],
application_id: application.id,
job_posting_id: application.job_posting_id,
ai_score: application.ai_score,
status: 'onboarding',
});
return updated;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['staff'] });
logActivity('hire_candidate');
},
});
}
export function useMatchTalentForJob() {
return useMutation({
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
});
}
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'] });
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.
*
* Writes three things per person, because an assignment that updated only one
* of them would leave the system disagreeing with itself: the assignment
* record, the application's status where the person applied, and an activity
* event carrying every id involved.
*/
export function useAssignWorkers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
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 created = [];
for (const worker of workers) {
const record = await base44.entities.Assignment.create({
job_posting_id: position.id,
worker_email: worker.email,
worker_name: worker.name,
starts_at: startsAt,
ends_at: endsAt,
status: 'active',
source: 'owliver',
match_score: worker.score ?? null,
});
created.push(record);
/**
* The application is what puts a person *in the pipeline for this role*,
* and it is what every downstream step keys on — the candidate profile
* is addressed by it, and the AI interview takes one as its subject.
*
* So assigning somebody from the existing workforce creates one where
* none exists, exactly as the Position page's own "admit talent" path
* does. Without it an assigned worker would be unreachable: no record to
* open, and no way to interview them.
*/
let application = applications.find(
(a) => a.job_posting_id === position.id
&& String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase()
);
if (!application) {
application = await base44.entities.JobApplication.create({
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,
});
} else {
await base44.entities.JobApplication.update(application.id, { status: 'assigned' });
}
record.application_id = application.id;
await logActivity('assign_employee', {
details: `${worker.name} assigned to ${position.company ? `${position.company} — ` : ''}${position.title}`,
position_id: position.id,
application_id: application?.id || null,
worker_email: worker.email,
});
}
return created;
},
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: ['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: ['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'] }),
});
}