965 lines
38 KiB
JavaScript
965 lines
38 KiB
JavaScript
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'] }),
|
|
});
|
|
} |