/** * 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(); }