fix mobile screen issues
This commit is contained in:
@@ -3,16 +3,24 @@
|
||||
*
|
||||
* 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 local store in `store.js` and the local AI engine
|
||||
* 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. `store.js` and `seed.js` are no longer the
|
||||
* source of data — the seeded dataset now lives in PostgreSQL, loaded by the
|
||||
* backend's `make seed`. `seed.js` is still imported for one thing: the shape
|
||||
* of the demo user's default preferences, which the synchronous accessor below
|
||||
* needs before the first response arrives.
|
||||
*/
|
||||
|
||||
import { createEntity, initStore, resetStore } from './store';
|
||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||
import { invokeLLM, uploadFile } from './aiEngine';
|
||||
import { DEMO_USER, seedData } from './seed';
|
||||
|
||||
initStore(seedData);
|
||||
import { DEMO_USER } from './seed';
|
||||
|
||||
const ENTITY_NAMES = [
|
||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||
@@ -33,9 +41,28 @@ const entities = Object.fromEntries(
|
||||
|
||||
/* ── 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 loadUser() {
|
||||
function readCachedUser() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
|
||||
@@ -44,98 +71,207 @@ function loadUser() {
|
||||
}
|
||||
}
|
||||
|
||||
let currentUser = loadUser();
|
||||
|
||||
/**
|
||||
* Writes the session user, and says whether it actually landed.
|
||||
* Mirrors the current user for the next page load's synchronous read.
|
||||
*
|
||||
* The old version was `try { setItem } catch {}` — a swallowed
|
||||
* `QuotaExceededError` or a private-browsing refusal, and the caller was handed
|
||||
* a user object indistinguishable from a successful write. For preferences that
|
||||
* is invisible; for `customSkills`, which is where every account-authored skill
|
||||
* definition lives, it is the whole "I saved it and it was gone" report: the
|
||||
* toast said added, the list showed it, the reload did not.
|
||||
*
|
||||
* The read-back matters as much as the catch. A write can be accepted and then
|
||||
* evicted, and a serialisation can land truncated; comparing what came back
|
||||
* with what went in is the only way to know the record is really there.
|
||||
* 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 persistUser() {
|
||||
const payload = JSON.stringify(currentUser);
|
||||
function cacheUser() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, payload);
|
||||
} catch (error) {
|
||||
return { persisted: false, error };
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
|
||||
} catch {
|
||||
// Private browsing or quota — the server still has the record.
|
||||
}
|
||||
try {
|
||||
if (localStorage.getItem(SESSION_KEY) !== payload) {
|
||||
return { persisted: false, error: new Error('The session record did not survive the write.') };
|
||||
}
|
||||
} catch (error) {
|
||||
return { persisted: false, error };
|
||||
}
|
||||
return { persisted: true, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 demo is always signed in as the seeded employer/admin user. */
|
||||
/**
|
||||
* 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() {
|
||||
return { ...currentUser };
|
||||
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) {
|
||||
currentUser = { ...currentUser, ...patch };
|
||||
persistUser();
|
||||
return { ...currentUser };
|
||||
const user = await request('PATCH', '/me', { body: patch });
|
||||
currentUser = user;
|
||||
cacheUser();
|
||||
return { ...user };
|
||||
},
|
||||
|
||||
/**
|
||||
* Preferences, read synchronously.
|
||||
*
|
||||
* `me()` is async because the real client fetches, but the panel provider has
|
||||
* to decide whether Owliver starts open during its first render — one tick
|
||||
* later is a visible flash of the wrong workspace. The session user is already
|
||||
* hydrated from localStorage at module load, so this is a plain read of the
|
||||
* same record `me()` returns, not a second copy of the state.
|
||||
* A plain read of the same record `me()` returns, defaulted with the shape
|
||||
* from `seed.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 with the rest of the user.
|
||||
* Merges into the stored preferences and persists them server-side.
|
||||
*
|
||||
* Returns the write's outcome alongside the record, rather than the record
|
||||
* alone. Preferences are where account-authored skills live, so "did this
|
||||
* survive the reload" is a question the caller has to be able to answer —
|
||||
* see `persistUser`.
|
||||
* `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) {
|
||||
currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } };
|
||||
const write = persistUser();
|
||||
return { user: { ...currentUser }, ...write };
|
||||
},
|
||||
|
||||
isAuthenticated() {
|
||||
return true;
|
||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||
currentUser = { ...currentUser, preferences };
|
||||
cacheUser();
|
||||
return { user: { ...currentUser }, persisted: true, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* There is no identity provider to sign out of, so this clears the local
|
||||
* session and returns to the requested page.
|
||||
* 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.
|
||||
*/
|
||||
logout(redirectTo = '/') {
|
||||
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 {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
await request('POST', '/auth/logout');
|
||||
} catch {
|
||||
// Ignore.
|
||||
// Already signed out, or the API is unreachable. Neither is a reason to
|
||||
// keep the user looking at a signed-in page.
|
||||
}
|
||||
currentUser = { ...DEMO_USER };
|
||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/';
|
||||
authenticated = false;
|
||||
forgetUser();
|
||||
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/admin/login';
|
||||
},
|
||||
|
||||
redirectToLogin() {
|
||||
window.location.href = '/';
|
||||
window.location.href = '/admin/login';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -158,8 +294,27 @@ const analytics = {
|
||||
|
||||
export const base44 = { entities, auth, integrations, analytics };
|
||||
|
||||
/** Restores the shipped demo data, discarding local edits. */
|
||||
/** 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() {
|
||||
resetStore(seedData);
|
||||
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();
|
||||
}
|
||||
|
||||
308
src/api/httpClient.js
Normal file
308
src/api/httpClient.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* HTTP transport for the entity API.
|
||||
*
|
||||
* This is the module that replaces `store.js`. It exposes the same
|
||||
* `createEntity(name)` factory with the same six methods and the same
|
||||
* signatures, so `base44Client.js` swaps one import and nothing above it
|
||||
* changes — not a hook, not a page, not a component.
|
||||
*
|
||||
* Everything here is a faithful translation of what `store.js` did locally into
|
||||
* what `docs/api-contract.md` specifies over the wire. Where the two could
|
||||
* differ, the local behaviour wins, because the callers were written against
|
||||
* it:
|
||||
*
|
||||
* - `list`/`filter` return a bare array; `get`/`create`/`update` return a bare
|
||||
* object; `delete` returns `{ id }`. The API's `{ data, meta }` envelope is
|
||||
* unwrapped here and never seen above.
|
||||
* - A failure throws an `Error` whose `message` is the server's message,
|
||||
* because `store.js` threw and several callers depend on the throw
|
||||
* (`useQuery`'s `isError`, and half a dozen `.catch(() => …)` fallbacks).
|
||||
* - The default `sort` and `limit` on every method are the ones `store.js`
|
||||
* declared, so a call site that passes neither still gets what it always
|
||||
* got.
|
||||
*
|
||||
* The one thing that is genuinely new is the failure mode. A local store could
|
||||
* not be unreachable; an API can, and "Failed to fetch" names neither the
|
||||
* problem nor the fix. `request` turns that into a message that says which URL
|
||||
* did not answer.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where the API lives.
|
||||
*
|
||||
* A same-origin path, not a host. The browser asks its own origin for
|
||||
* `/api/v1/...`; in development the Vite proxy forwards that to the Go API (see
|
||||
* `vite.config.js`), and in production the same path is served by the same
|
||||
* origin as the app (see `nginx.conf`).
|
||||
*
|
||||
* This is a requirement of the session cookie rather than a preference. The
|
||||
* cookie is HttpOnly with SameSite=Lax, and a Lax cookie is not sent on a
|
||||
* cross-site request — so a page on `localhost:5173` fetching
|
||||
* `http://127.0.0.1:8080` would authenticate once at login and then be a
|
||||
* stranger on every request after it.
|
||||
*
|
||||
* VITE_API_BASE_URL can still point somewhere else, and `credentials` below is
|
||||
* set so that it works, but the cross-origin path needs CORS credentials
|
||||
* configured on the server and is not the supported arrangement.
|
||||
*/
|
||||
const DEFAULT_BASE_URL = '/api/v1';
|
||||
|
||||
export const API_BASE_URL = String(
|
||||
import.meta.env?.VITE_API_BASE_URL || DEFAULT_BASE_URL
|
||||
).replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* Shout if the API has been pointed at another origin.
|
||||
*
|
||||
* This exists because the failure it catches is silent and misleading. Set
|
||||
* `VITE_API_BASE_URL` to `http://127.0.0.1:8080/api/v1` and the browser sends a
|
||||
* preflight, the server answers it `204`, and then the real request is never
|
||||
* dispatched at all — because `credentials: 'include'` obliges the browser to
|
||||
* require `Access-Control-Allow-Credentials: true` on that preflight, and the
|
||||
* API does not send it. The server log shows an OPTIONS and nothing else; the
|
||||
* page shows a request that never completes. Nothing names the cause.
|
||||
*
|
||||
* Even if CORS were opened up, the session cookie is `SameSite=Lax` and would
|
||||
* not be sent on a cross-site request, so login would appear to succeed once
|
||||
* and then every subsequent request would arrive as a stranger.
|
||||
*
|
||||
* Development only: `import.meta.env.DEV` is statically replaced at build time,
|
||||
* so this whole block is dropped from the production bundle.
|
||||
*/
|
||||
if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
|
||||
const sameOrigin =
|
||||
typeof window !== 'undefined' && API_BASE_URL.startsWith(window.location.origin);
|
||||
if (!sameOrigin) {
|
||||
console.error(
|
||||
`[krow] VITE_API_BASE_URL is "${API_BASE_URL}", which is a different origin ` +
|
||||
`from ${typeof window !== 'undefined' ? window.location.origin : 'this page'}. ` +
|
||||
'The session cookie will not work: the login POST is blocked at the CORS ' +
|
||||
'preflight, and a SameSite=Lax cookie would not be sent cross-site anyway. ' +
|
||||
'Set VITE_API_BASE_URL=/api/v1 in .env and restart the dev server so requests ' +
|
||||
'go through the Vite proxy.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity name → the contract's resource path (§1: kebab-case plural, mass nouns
|
||||
* singular).
|
||||
*
|
||||
* Declared rather than derived. A rule that turns `AIInterview` into
|
||||
* `ai-interviews` and `Staff` into `staff` and `UserActivity` into
|
||||
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
||||
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
||||
*/
|
||||
const RESOURCE_PATHS = {
|
||||
JobPosting: 'job-postings',
|
||||
JobApplication: 'job-applications',
|
||||
AIInterview: 'ai-interviews',
|
||||
Staff: 'staff',
|
||||
WorkerProfile: 'worker-profiles',
|
||||
Course: 'courses',
|
||||
Badge: 'badges',
|
||||
LearningPath: 'learning-paths',
|
||||
Certification: 'certifications',
|
||||
RoleCategory: 'role-categories',
|
||||
UserActivity: 'user-activity',
|
||||
Evidence: 'evidence',
|
||||
User: 'users',
|
||||
Assignment: 'assignments',
|
||||
ShiftRecord: 'shift-records',
|
||||
};
|
||||
|
||||
/* ── Request ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds a query string with the contract's filter encoding (§6).
|
||||
*
|
||||
* An array value becomes a repeated parameter — `?status=applied&status=hired`
|
||||
* — which the server reads as `col = ANY(...)`, matching `store.js`'s
|
||||
* `want.includes(got)`. `undefined` is omitted entirely: `store.js` compared
|
||||
* `got === undefined` against real values and matched nothing, and sending the
|
||||
* string "undefined" would be a filter on a value no column holds.
|
||||
*/
|
||||
function queryString(params) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item !== undefined) search.append(key, String(item));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
search.append(key, String(value));
|
||||
}
|
||||
const encoded = search.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a non-2xx becomes.
|
||||
*
|
||||
* `message` is the server's message verbatim, because §5.1 makes it
|
||||
* load-bearing: `store.js` threw `"<Entity> <id> not found"` and the API
|
||||
* reproduces that string exactly. The code, HTTP status and per-field details
|
||||
* ride along as properties — new information a local store never had, and
|
||||
* additive, so nothing that only reads `.message` notices.
|
||||
*/
|
||||
function apiError(status, payload) {
|
||||
const body = payload?.error;
|
||||
const error = new Error(body?.message || `Request failed with status ${status}`);
|
||||
error.name = 'KrowApiError';
|
||||
error.status = status;
|
||||
error.code = body?.code || 'internal';
|
||||
error.details = body?.details || {};
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request, unwrapped.
|
||||
*
|
||||
* Returns `payload.data`, so every caller above works in bare records exactly
|
||||
* as it did against the local store. `meta` is deliberately dropped: nothing
|
||||
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
||||
* return, which is the one thing Phase 2D must not do.
|
||||
*/
|
||||
async function request(method, path, { query, body } = {}) {
|
||||
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
// The session cookie is HttpOnly: this code cannot read it, attach it by
|
||||
// hand, or store it. `credentials` is the only lever there is, and
|
||||
// without it `fetch` omits cookies on cross-origin requests entirely.
|
||||
// Same-origin — the supported arrangement — would send them anyway;
|
||||
// saying so explicitly means the one line that makes authentication work
|
||||
// is visible rather than implied.
|
||||
credentials: 'include',
|
||||
headers: body === undefined
|
||||
? { Accept: 'application/json' }
|
||||
: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
// A transport failure, not an API response: no status, no envelope. The
|
||||
// browser's own message for this is "Failed to fetch", which says nothing
|
||||
// about which server or why, and it is nearly always the same cause — the
|
||||
// API is not running.
|
||||
const error = new Error(
|
||||
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
||||
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
||||
`(${method} ${path})`
|
||||
);
|
||||
error.name = 'KrowApiError';
|
||||
error.status = 0;
|
||||
error.code = 'unreachable';
|
||||
error.details = {};
|
||||
error.cause = cause;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) throw apiError(response.status, payload);
|
||||
|
||||
if (payload === null) {
|
||||
const error = new Error(`${method} ${path} returned no JSON body`);
|
||||
error.name = 'KrowApiError';
|
||||
error.status = response.status;
|
||||
error.code = 'internal';
|
||||
error.details = {};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/** The request helper, for the `auth` surface in `base44Client.js`. */
|
||||
export { request };
|
||||
|
||||
/**
|
||||
* True when an error is the API saying "you are not signed in".
|
||||
*
|
||||
* A 401 is an ordinary, expected answer here — it is what every request gets
|
||||
* before the first login and after a session expires — so callers need to tell
|
||||
* it apart from a real failure rather than treating both as "something broke".
|
||||
*/
|
||||
export function isUnauthenticated(error) {
|
||||
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
||||
}
|
||||
|
||||
/* ── Entity API ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Builds the client surface for one entity.
|
||||
*
|
||||
* Signature-compatible with `store.js`'s `createEntity`, defaults included. The
|
||||
* defaults matter more than they look: `store.js` declared
|
||||
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
||||
* them rather than passing their own.
|
||||
*/
|
||||
export function createEntity(name) {
|
||||
const path = RESOURCE_PATHS[name];
|
||||
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
||||
const base = `/${path}`;
|
||||
|
||||
return {
|
||||
entityName: name,
|
||||
|
||||
/**
|
||||
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
||||
* omitting it: the contract reads an explicit empty value as "no ordering",
|
||||
* which is what `applySort` did with a falsy sort, while omitting it would
|
||||
* apply the endpoint's default.
|
||||
*/
|
||||
async list(sort = '-created_date', limit = 100) {
|
||||
return request('GET', base, { query: { sort, limit } });
|
||||
},
|
||||
|
||||
async filter(query = {}, sort = '-created_date', limit = 100) {
|
||||
// Spread first so a field genuinely named `sort`, `limit` or `offset`
|
||||
// could never shadow the reserved parameters (§1 records that no column
|
||||
// collides with them today; this keeps that true if one ever does).
|
||||
return request('GET', base, { query: { ...query, sort, limit } });
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
return request('POST', base, { body: data });
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sequential creates, exactly as `store.js` did it.
|
||||
*
|
||||
* Not a batch endpoint and not `Promise.all`: the contract has no bulk
|
||||
* write (§12.1), and doing them one at a time keeps the failure behaviour
|
||||
* identical — the first rejection stops the run and the records before it
|
||||
* are already written.
|
||||
*/
|
||||
async bulkCreate(records = []) {
|
||||
const created = [];
|
||||
for (const record of records) created.push(await this.create(record));
|
||||
return created;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user