419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
/**
|
|
* Application data client.
|
|
*
|
|
* The reference app talks to a Base44 backend through this module. The demo
|
|
* keeps the module path, the export name, and the full method contract, and
|
|
* swaps the transport for the Go API in `httpClient.js` and the local AI engine
|
|
* in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or
|
|
* cares, which is exactly the point: the seam stays where it was.
|
|
*
|
|
* Phase 2D moved the transport from a localStorage-backed store to HTTP:
|
|
*
|
|
* React → base44Client.js → HTTP → Go API → PostgreSQL
|
|
*
|
|
* The entity surface is unchanged. The seeded dataset lives in PostgreSQL,
|
|
* loaded by the backend's `make seed`, and nothing in the running app carries a
|
|
* copy of it: `store.js` — the localStorage database this replaced — is gone,
|
|
* and `seed.js` is now a test fixture that no production module imports.
|
|
*
|
|
* The one thing still needed before the first response arrives is the *shape*
|
|
* of the user's default preferences, because the accessor that reads them is
|
|
* synchronous. That is `api/demoUser.js`: a default shape, not a record.
|
|
*/
|
|
|
|
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
|
import { invokeLLM, uploadFile } from './aiEngine';
|
|
import { DEMO_USER } from './demoUser';
|
|
|
|
const ENTITY_NAMES = [
|
|
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
|
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
|
|
'UserActivity', 'Evidence', 'User',
|
|
/* Who is on which position, and for how long. The record that turns "hired"
|
|
into workforce allocation: without it a position knows its demand and its
|
|
applicants but not who is actually covering it. */
|
|
'Assignment',
|
|
/* Shifts worked, missed and overrun. The operational record behind
|
|
attendance and overtime analysis — see `api/attendanceSeed.js`. */
|
|
'ShiftRecord',
|
|
/* The agent and skill registry the runtime resolves from.
|
|
Authored definitions used to be written into the account's preferences,
|
|
which the backend stores faithfully and the runtime never reads — so an
|
|
agent created in the editor showed as "published" and answered every
|
|
request with 404. These are the endpoints that make an authored agent a
|
|
real one. */
|
|
'AgentDefinition',
|
|
'SkillDefinition',
|
|
];
|
|
|
|
const entities = Object.fromEntries(
|
|
ENTITY_NAMES.map((name) => [name, createEntity(name)])
|
|
);
|
|
|
|
/* ── Auth ──────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* The last user the API returned.
|
|
*
|
|
* This is a **cache of `GET /me`**, not a store. The record itself lives in
|
|
* PostgreSQL; this exists for one reason, and it is not offline support.
|
|
*
|
|
* `auth.preferences()` is synchronous, and it has to stay synchronous:
|
|
* `AssistantPanelContext` decides whether Owliver starts open in a `useState`
|
|
* initialiser, during the first render, and `krowHooks.js:42` merges the same
|
|
* accessor under the async user so the first paint already has real values. One
|
|
* tick later is a visible flash of the wrong workspace — the panel opening on
|
|
* an account that turned it off, then closing.
|
|
*
|
|
* So the last known user is mirrored to localStorage and read back at module
|
|
* load, and `GET /me` refreshes it. On a return visit the synchronous read is
|
|
* already correct; on a first-ever visit it is the seeded defaults for exactly
|
|
* as long as the request takes, which is the same thing the old store did with
|
|
* an empty key.
|
|
*/
|
|
const SESSION_KEY = 'krow_demo_user';
|
|
|
|
function readCachedUser() {
|
|
try {
|
|
const raw = localStorage.getItem(SESSION_KEY);
|
|
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
|
|
} catch {
|
|
return { ...DEMO_USER };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mirrors the current user for the next page load's synchronous read.
|
|
*
|
|
* Failures are ignored, which is a real change from the old `persistUser` and a
|
|
* safe one. That function checked its write and reported failure because
|
|
* localStorage was the *only* copy — a swallowed `QuotaExceededError` was how
|
|
* account-authored skills silently disappeared. Now the only copy is in
|
|
* PostgreSQL, and a refused mirror costs one render of default preferences, not
|
|
* data.
|
|
*/
|
|
function cacheUser() {
|
|
try {
|
|
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
|
|
} catch {
|
|
// Private browsing or quota — the server still has the record.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drops the mirrored user.
|
|
*
|
|
* Signing out must not leave the next page load rendering the previous
|
|
* account's name and preferences out of localStorage while it waits for a
|
|
* `GET /me` that is going to 401.
|
|
*/
|
|
function forgetUser() {
|
|
try {
|
|
localStorage.removeItem(SESSION_KEY);
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
currentUser = { ...DEMO_USER };
|
|
}
|
|
|
|
let currentUser = readCachedUser();
|
|
|
|
/**
|
|
* Whether the last `GET /me` succeeded.
|
|
*
|
|
* A cache of the server's answer, not a decision. Nothing here grants access:
|
|
* the API refuses an unauthenticated request whatever this says, and a user who
|
|
* edits it in the console has changed a boolean in their own tab and nothing
|
|
* else. It exists because `isAuthenticated()` is synchronous.
|
|
*/
|
|
let authenticated = false;
|
|
|
|
/**
|
|
* The first `GET /me`, shared.
|
|
*
|
|
* Started at module load so the synchronous accessor is corrected as early as
|
|
* possible, and shared so the eleven `me()` call sites that fire during the
|
|
* first render make one request between them rather than eleven.
|
|
*
|
|
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
|
* app opens on the login page and this request is how it finds that out.
|
|
*/
|
|
let hydration = request('GET', '/me')
|
|
.then((user) => {
|
|
currentUser = user;
|
|
authenticated = true;
|
|
cacheUser();
|
|
return user;
|
|
})
|
|
.catch(() => {
|
|
authenticated = false;
|
|
return null;
|
|
});
|
|
|
|
const auth = {
|
|
/**
|
|
* The signed-in user, from the session cookie.
|
|
*
|
|
* Throws when there is no session — a `KrowApiError` with `status: 401` — and
|
|
* that throw is the app's authentication check. `AuthContext` catches it and
|
|
* renders the login page. Nothing here decides who the user is; the server
|
|
* reads its own session table and answers.
|
|
*
|
|
* Joins the in-flight hydration if there is one, so the first render's
|
|
* callers share a request; refetches afterwards so a change made in another
|
|
* tab, or a session that has since expired, is picked up.
|
|
*/
|
|
async me() {
|
|
if (hydration) {
|
|
const user = await hydration;
|
|
hydration = null;
|
|
if (user) {
|
|
authenticated = true;
|
|
return { ...user };
|
|
}
|
|
}
|
|
try {
|
|
const user = await request('GET', '/me');
|
|
currentUser = user;
|
|
authenticated = true;
|
|
cacheUser();
|
|
return { ...user };
|
|
} catch (error) {
|
|
if (isUnauthenticated(error)) {
|
|
authenticated = false;
|
|
forgetUser();
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Signs in and starts a session.
|
|
*
|
|
* The response body is the user. The session token is NOT in it — it arrives
|
|
* as an HttpOnly cookie the browser stores and this code cannot read, which
|
|
* is what stops a script on the page from stealing it. There is deliberately
|
|
* nothing here that writes a token anywhere.
|
|
*
|
|
* Every credential failure comes back as the same 401 with the same message,
|
|
* by design: telling the two apart would say whether an address is
|
|
* registered. The caller shows that message as-is.
|
|
*/
|
|
async login({ email, password, rememberMe = false }) {
|
|
const user = await request('POST', '/auth/login', {
|
|
body: { email, password, remember_me: Boolean(rememberMe) },
|
|
});
|
|
currentUser = user;
|
|
authenticated = true;
|
|
hydration = null;
|
|
cacheUser();
|
|
return { ...user };
|
|
},
|
|
|
|
async updateMe(patch) {
|
|
const user = await request('PATCH', '/me', { body: patch });
|
|
currentUser = user;
|
|
cacheUser();
|
|
return { ...user };
|
|
},
|
|
|
|
/**
|
|
* Preferences, read synchronously.
|
|
*
|
|
* A plain read of the same record `me()` returns, defaulted with the shape
|
|
* from `demoUser.js` so a key the server has never stored still resolves. See the
|
|
* note on `currentUser` for why this must not become async.
|
|
*/
|
|
preferences() {
|
|
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
|
|
},
|
|
|
|
/**
|
|
* Merges into the stored preferences and persists them server-side.
|
|
*
|
|
* `PATCH /me/preferences` shallow-merges and returns the whole merged object,
|
|
* which is where `customSkills` and `customAgents` — every account-authored
|
|
* definition — now live: `user_preferences.extra`, a real column in a real
|
|
* database rather than a browser key.
|
|
*
|
|
* The `{ user, persisted, error }` shape is kept because `saveFeedback.js`
|
|
* reads it. Over HTTP a write that did not land is a non-2xx and therefore a
|
|
* throw, so the success path is unconditionally `persisted: true` — the
|
|
* question the shape exists to answer is now answered by whether this
|
|
* function resolved at all.
|
|
*/
|
|
async updatePreferences(patch) {
|
|
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
|
currentUser = { ...currentUser, preferences };
|
|
cacheUser();
|
|
return { user: { ...currentUser }, persisted: true, error: null };
|
|
},
|
|
|
|
/**
|
|
* Whether the last `GET /me` succeeded.
|
|
*
|
|
* Synchronous, and therefore only ever a cache of what the server last said.
|
|
* It is a hint for rendering, never a gate: every protected endpoint is
|
|
* refused by the API on its own authority regardless of this value.
|
|
*/
|
|
isAuthenticated() {
|
|
return authenticated;
|
|
},
|
|
|
|
/**
|
|
* Signs out and returns to the requested page.
|
|
*
|
|
* The server revokes the session row and expires the cookie; this clears the
|
|
* cached copy of the user so a signed-out tab cannot render a stale name from
|
|
* localStorage. The redirect happens either way — a logout that could not
|
|
* reach the API must still leave the browser signed out locally, and the
|
|
* cookie it keeps will be refused by every request it is sent on.
|
|
*/
|
|
async logout(redirectTo = '/admin/login') {
|
|
try {
|
|
await request('POST', '/auth/logout');
|
|
} catch {
|
|
// Already signed out, or the API is unreachable. Neither is a reason to
|
|
// keep the user looking at a signed-in page.
|
|
}
|
|
authenticated = false;
|
|
forgetUser();
|
|
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/admin/login';
|
|
},
|
|
|
|
redirectToLogin() {
|
|
window.location.href = '/admin/login';
|
|
},
|
|
};
|
|
|
|
/* ── Workflows ──────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* The multi-record writes, as one request each.
|
|
*
|
|
* These are the only endpoints in this file that are not a CRUD projection of a
|
|
* table, and they exist because the flows below were previously performed as a
|
|
* sequence of independent requests with no transaction and no rollback — a hire
|
|
* whose PATCH landed and whose POST did not left a candidate marked `hired` with
|
|
* no employment record, and nothing in the UI could tell.
|
|
*
|
|
* They are shaped as verbs on the record they act on, so the entity surface
|
|
* above is untouched: `entities.JobApplication` still means the table, and
|
|
* hiring is a thing you do *to* an application rather than a fifteenth entity.
|
|
*
|
|
* `request` unwraps the envelope, so `hire` resolves to
|
|
* `{ application, staff }` and `assign` to `{ assignments, count }` — both
|
|
* records the server actually wrote, so no caller needs a follow-up read to
|
|
* render the outcome.
|
|
*/
|
|
const workflows = {
|
|
/**
|
|
* Move an application to `hired` and create the staff record, atomically.
|
|
*
|
|
* `body` carries only what a hiring form collects — `role`, `profile_tier`,
|
|
* `hire_date`, `status`, `phone`, `reviewer_name`. Everything else is carried
|
|
* across from the application by the server, because it is already the truth
|
|
* about this person and retyping it here is how the two records drift apart.
|
|
*/
|
|
async hire(applicationId, body = {}) {
|
|
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
|
body,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Place workers on a posting, atomically.
|
|
*
|
|
* All-or-nothing across the batch: assigning six people and having the fourth
|
|
* fail must not leave three placed, three not, and the caller unsure which.
|
|
* Each entry names its application by `application_id` if the caller already
|
|
* has one, or describes one under `application` for the server to find or file
|
|
* inside the same transaction; a worker taken straight from the talent pool
|
|
* with neither is placed without one rather than given an invented one.
|
|
*/
|
|
async assign(jobPostingId, workers = []) {
|
|
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
|
body: { workers },
|
|
});
|
|
},
|
|
};
|
|
|
|
/* ── Owliver ────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* What could usefully be asked on this page.
|
|
*
|
|
* The one read in this file that is not a table. `GET /owliver/suggestions`
|
|
* answers with at most three questions, and the server decides all three: it
|
|
* filters them against the caller's role through the same policy table every
|
|
* other endpoint consults, and — when nothing has been typed — ranks them
|
|
* against the organization's actual state in PostgreSQL. A workspace with
|
|
* unfinished drafts is asked about drafts; one with unscored candidates is
|
|
* asked about screening.
|
|
*
|
|
* Nothing is ranked, scored, filtered or reordered on this side. That is the
|
|
* point: a suggestion is a claim that the reader could usefully ask something,
|
|
* and the only thing that knows whether that is true is the thing holding the
|
|
* data and the permissions. The panel requests, renders, and runs whichever one
|
|
* is chosen through the capability it names.
|
|
*
|
|
* `query` is what is in the composer. Sent only when it is non-empty: an absent
|
|
* query asks the data what to suggest, and an empty string would be a different
|
|
* request from the one the caller means.
|
|
*/
|
|
const owliver = {
|
|
/** @param {any} request */
|
|
async suggestions({ page, query = '' } = {}) {
|
|
if (!page) return [];
|
|
const typed = String(query || '').trim();
|
|
const data = await request('GET', '/owliver/suggestions', {
|
|
query: { page, ...(typed ? { query: typed } : {}) },
|
|
});
|
|
return Array.isArray(data?.suggestions) ? data.suggestions : [];
|
|
},
|
|
};
|
|
|
|
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
|
|
|
const integrations = {
|
|
Core: {
|
|
InvokeLLM: invokeLLM,
|
|
UploadFile: uploadFile,
|
|
},
|
|
};
|
|
|
|
const analytics = {
|
|
track({ eventName, properties } = {}) {
|
|
if (import.meta.env.DEV) {
|
|
console.debug('[krow-demo] analytics', eventName, properties || {});
|
|
}
|
|
},
|
|
};
|
|
|
|
export const base44 = { entities, auth, integrations, analytics, workflows, owliver };
|
|
|
|
/** Where the entity data actually comes from, for diagnostics. */
|
|
export { API_BASE_URL };
|
|
|
|
/**
|
|
* Clears local session state and reloads.
|
|
*
|
|
* The demo dataset is no longer the browser's to restore: it lives in
|
|
* PostgreSQL, and reseeding it is `make seed` in the `krow-backend` repository,
|
|
* which upserts the shipped fixture in one transaction. All this can still do
|
|
* is drop the cached user and reload, so it says so rather than reporting a
|
|
* reset it did not perform.
|
|
*/
|
|
export function resetDemoData() {
|
|
try {
|
|
localStorage.removeItem(SESSION_KEY);
|
|
} catch {
|
|
// Ignore.
|
|
}
|
|
console.info(
|
|
'[krow-demo] Local session cache cleared. Entity data lives in PostgreSQL — ' +
|
|
'restore the shipped dataset with `make seed` in krow-backend.'
|
|
);
|
|
window.location.reload();
|
|
}
|