diff --git a/.env.bak b/.env.bak new file mode 100644 index 0000000..e767daa --- /dev/null +++ b/.env.bak @@ -0,0 +1,41 @@ +# ============================================================================ +# Krow frontend — example environment +# +# Copy to .env and adjust. .env is gitignored. +# +# cp .env.example .env +# +# Vite only exposes variables prefixed with VITE_ to client code, and it reads +# these at build/dev-server start — changing one needs a restart, not a reload. +# ============================================================================ + +# Where the browser sends API requests. +# +# A SAME-ORIGIN PATH, not a host. The browser asks its own origin for +# /api/v1/..., and something on that origin forwards it to the Go API: +# in development the Vite proxy (see `server.proxy` in vite.config.js), in +# production the same web server that serves the built assets (see nginx.conf). +# +# browser → localhost:5173/api/v1 → Vite proxy → 127.0.0.1:8080/api/v1 +# +# THIS MUST STAY A PATH. Pointing it at http://127.0.0.1:8080/api/v1 makes every +# request cross-site, and the session cookie stops working in two separate ways: +# +# 1. The cookie is SameSite=Lax, and a Lax cookie is not sent on a cross-site +# subresource request. A browser treats localhost:5173 and 127.0.0.1:8080 +# as different sites, so the cookie would be set at login and then never +# sent again. +# 2. Because the transport sends `credentials: 'include'`, the browser +# requires `Access-Control-Allow-Credentials: true` on the preflight +# response. The API does not send it — deliberately, because the supported +# arrangement is same-origin — so Chrome discards the preflight and never +# dispatches the real request. The symptom is an OPTIONS that answers 204 +# followed by a POST that never reaches the server at all. +# +# Both failures are silent from the page's point of view, which is why this +# comment is longer than the value. +VITE_API_BASE_URL=/api/v1 + +# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by +# client code. +VITE_API_PROXY_TARGET=https://mcp.krowforce.com diff --git a/docs/api-audit.md b/docs/api-audit.md new file mode 100644 index 0000000..e47113b --- /dev/null +++ b/docs/api-audit.md @@ -0,0 +1,661 @@ +# Krow frontend — API audit + +Every API this repository needs, and where each one stands against the running +Go backend at `../krow-backend`. + +**The backend's `docs/api-contract.md` is normative.** This document does not +restate its semantics — it inventories what *this* repo calls, and marks each +against what the server actually registers. Nothing here proposes a route, +request field, response field or table that is not traced to a frontend call +site or an existing backend requirement. + +## Status labels + +| Label | Meaning | +| --- | --- | +| **Served** | The frontend calls it, and the backend registers it. Working. | +| **Adopt** | The backend registers it; the frontend does not use it yet, and should. No new work on the server. | +| **Local stub** | Implemented in-browser. No network, no server, no key. | +| **Browser-only** | State lives in `localStorage` / `sessionStorage` and nowhere else. | +| **External** | Third-party host, hotlinked, no auth, no contract. | +| **Dead code** | Frontend code exists with no consumer and no route. Not a backend gap. | + +--- + +## 1. Route counts, derived from source + +The figure "38 endpoints" is the backend contract's **§2 table** (34 Live + +4 "unreachable today"). It is not the count of what the server registers. + +Counted from source rather than from any README: + +| Group | Routes | Where counted | +| --- | --- | --- | +| Entity CRUD | **34** | `Ops` bits across 14 resources in `internal/domain/resources_gen.go` | +| `/me` | 4 | `internal/httpserver/me.go:83-86` | +| `/auth` | 2 | `internal/httpserver/auth.go:129-130` | +| Agent + skill definitions | 10 | `internal/httpserver/definitions.go:11-21` | +| Workflows | 2 | `internal/httpserver/workflows.go:87-88` | +| Owliver suggestions | 1 | `internal/httpserver/owliver.go` | +| Health | 1 | `internal/httpserver/server.go:146` | +| **Total registered** | **54** | | + +`Server.Endpoints()` returns 53 — the sum of the six `route*` helpers, +excluding `GET /health`, which is registered before the sum. + +The backend README used to say "51 registered routes", omitting the two +workflow routes; it now enumerates all 54 and is back in step with its router. + +--- + +## 2. Configuration and transport + +| Variable | Default | Read by | +| --- | --- | --- | +| `VITE_API_BASE_URL` | `/api/v1` | `src/api/httpClient.js:50` — client code | +| `VITE_API_PROXY_TARGET` | `https://mcp.krowforce.com` | `vite.config.js:9` — dev server only | +| `VITE_ASSISTANT_ENDPOINT` | *(unset)* | `src/components/ai-assistant/provider.js:205` | + +- **Development.** `vite.config.js` proxies `/api` and `/health` to + `VITE_API_PROXY_TARGET`, so the browser only ever talks to one origin. +- **Production.** `Dockerfile:33` bakes + `ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1` at build time — + `.dockerignore` excludes `.env*`, so no env file reaches the image, and + `nginx.conf` has **no** `location /api/`. A relative base URL in production + would answer a login POST with 405. +- **Session cookie.** HttpOnly, SameSite=Lax. `httpClient.js` sends + `credentials: 'include'` and warns in DEV if the base URL is cross-origin + (`httpClient.js:72-84`). +- **Envelope.** `{ data, meta }` in, bare records out — `request()` returns + `payload.data` and drops `meta` entirely (`httpClient.js:226`). +- **Errors.** `{ error: { code, message, details } }` becomes a `KrowApiError` + carrying `.status`, `.code`, `.details`. A transport failure becomes + `status: 0, code: 'unreachable'` with a message naming the URL. + +--- + +## 3. Entity CRUD — Served + +`RESOURCE_PATHS` (`src/api/httpClient.js:96`) against `Ops` +(`internal/domain/resources_gen.go`). **Every method the frontend actually +calls has a route. Zero new endpoints are needed here.** + +| Entity | Path | Frontend calls | Backend `Ops` | | +| --- | --- | --- | --- | --- | +| JobPosting | `job-postings` | list, get, create, update | List·Get·Create·Update | Served | +| JobApplication | `job-applications` | list, filter, create, update, delete | List·Create·Update·Delete | Served | +| AIInterview | `ai-interviews` | list, create | List·Create | Served | +| Staff | `staff` | list, create, update | List·Create·Update | Served | +| WorkerProfile | `worker-profiles` | list, filter, create, update | List·Create·Update | Served | +| Course | `courses` | list, get, create, update | List·Get·Create·Update | Served | +| LearningPath | `learning-paths` | list | List | Served | +| RoleCategory | `role-categories` | list, create | List·Create | Served | +| Certification | `certifications` | list, create, delete | List·Create·Delete | Served | +| UserActivity | `user-activity` | list, filter, create | List·Create | Served | +| Evidence | `evidence` | list, filter, create, update | List·Create·Update | Served | +| Assignment | `assignments` | list, create | List·Create | Served | +| ShiftRecord | `shift-records` | list | List | Served | +| Badge | `badges` | `Badge.list` via `useBadges` | **`Ops: 0` — no routes** | Dead code | +| User | `users` | *(none)* | **no resource** | Dead code | + +`filter()` is not a separate endpoint — it is `GET /{resource}` with field +params. Arrays become repeated params (`?status=applied&status=hired`); +`undefined` is omitted; `sort` is always sent, because `?sort=` means "no +ordering" while omitting it applies the endpoint default. + +### The two dead-code rows + +Neither is a backend gap, and neither warrants a new endpoint: + +- **Badge.** `useBadges` (`src/lib/krowHooks.js:537`) has **zero consumers**. + The backend contract §2 records why: every badge the UI renders comes from + `worker_profiles.earned_badges`. `GET /api/v1/badges` would 404 if this hook + were ever wired. Fix in this repo by deleting the hook, or accept it as + unreachable. +- **User.** `'User'` is in `ENTITY_NAMES` and `RESOURCE_PATHS`, so + `createEntity('User')` builds a client for `/api/v1/users` — a path with no + backend resource. **No call site exists.** Authentication goes through `/me`. + +### Defaults line up + +The backend's `DefaultSort` / `DefaultLimit` were derived from these call +sites, and match them: + +| Resource | Frontend call | Backend default | +| --- | --- | --- | +| job-applications | `list('-ai_score', 200)` | `-ai_score` / 200 | +| worker-profiles | `list('-krow_score', 500)` | `-krow_score` / 500 | +| user-activity, assignments, shift-records | `list('-created_date', 500)` | `-created_date` / 500 | +| courses, certifications, evidence, badges | `list('-created_date', 200)` | `-created_date` / 200 | +| job-postings, ai-interviews, staff, learning-paths, role-categories | `list('-created_date', 100)` | `-created_date` / 100 | + +Filters actually sent: `job_posting_id` (applications), +`email` (worker profiles, `limit 1`), `worker_email` (evidence), +`user_email` (activity, `limit 20` — `src/pages/Profile.jsx:28`). + +--- + +## 4. Auth and preferences — Served + +| Method | Path | Frontend | +| --- | --- | --- | +| `GET` | `/api/v1/me` | `auth.me()`, and a module-load hydration promise | +| `PATCH` | `/api/v1/me` | `auth.updateMe(patch)` | +| `PATCH` | `/api/v1/me/preferences` | `auth.updatePreferences(patch)` | +| `POST` | `/api/v1/auth/login` | `{ email, password, remember_me }` | +| `POST` | `/api/v1/auth/logout` | Redirects locally even if the call fails | + +- `GET /me` is fired once at module load and shared, so the eleven `me()` call + sites in the first render make one request between them + (`src/api/base44Client.js:130-143`). +- A 401 is the ordinary signed-out state, not a failure. Every credential + failure returns the same 401 with the same message, by design. +- `auth.preferences()` is **synchronous** and must stay so — + `AssistantPanelContext` decides whether Owliver starts open in a `useState` + initialiser. That is why the last user is mirrored to `localStorage` under + `krow_demo_user`. + +### Preference keys in use + +| Key | Written by | Note | +| --- | --- | --- | +| `owliverDefault` | Settings | Seeded (`src/api/seed.js:1891`) | +| `compactDensity` | Settings | Seeded; read by `ds/DataTable.jsx:67` | +| `emailDigest` | Settings | Seeded | +| `customSkills` | Skill editors, `WorkspaceSkills.jsx` | Array of `{ path: 'custom/.md', raw: }` | +| `customAgents` | `lib/agents/useAgents.js:68,78` | Same shape, for agents | +| `disabledSkills` | `WorkspaceSkills.jsx`, `AgentDetail.jsx` | Array of skill ids | +| `removedSkills` | `WorkspaceSkills.jsx:331,353` | Array of skill ids | + +> **Drift:** the backend contract §1 still says *"Auth: None in v1. Every +> endpoint is unauthenticated."* That is stale — sessions, argon2id and cookie +> middleware are implemented (`migrations/000004_auth_sessions.up.sql`, +> `internal/auth/`, `internal/httpserver/auth.go`). + +--- + +## 5. Adopt — 12 endpoints the backend already has and the frontend ignores + +None of these needs server work. All exist and are registered today. + +### 5.1 Workflow endpoints (2) — **Served** + +The backend built these specifically to replace frontend call sequences, and +says so in the source. Both are now adopted. + +| Endpoint | Frontend call site | Replaced | +| --- | --- | --- | +| `POST /api/v1/job-applications/{id}/hire` | `base44.workflows.hire` ← `useHireCandidate` | `PATCH /job-applications/{id}` + `POST /staff` + `POST /user-activity` | +| `POST /api/v1/job-postings/{id}/assignments` | `base44.workflows.assign` ← `useAssignWorkers` | per worker: `POST /assignments` + `POST`/`PATCH` `/job-applications` + `POST /user-activity` | + +`internal/httpserver/workflows.go:85` reads verbatim: + +> *"handleHire moves an application to `hired` and creates the staff record in +> one transaction. Replaces the two-call sequence at krowHooks.js:302-303."* + +That sequence is gone. `useHireCandidate` now issues one request and sends only +what a hiring decision chooses — `role`, `profile_tier`, `hire_date`, `status`; +name, email, phone, score, posting and application id are carried across from +the application by the server. `useAssignWorkers` sends the whole batch in one +body, naming each worker's application by `application_id` where the page +already holds it and by an `application` payload where it does not, so a worker +with no application is found-or-filed inside the same transaction rather than in +a second, untransacted request. + +Both handlers also write their own `user-activity` entry inside the transaction +— `hire_candidate` and `assign_employee` — so the frontend no longer logs +either. It invalidates `['userActivity']` instead. + +The server-side handlers authorize on the operations they perform — hire needs +UPDATE on job-applications, CREATE on staff, CREATE on user-activity — checked +all-or-nothing before any transaction opens. + +### 5.1a Interview completion — **Served** + +`POST /api/v1/ai-interviews` is routed through the same workflow service +(`httpserver/api.go:156`): creating an interview also moves its application to +`status: 'interview'`, sets `interview_id`, and copies +`overall_interview_score` onto `ai_score` when the request supplied one — all in +one transaction. `AIInterviewModal.finishInterview` no longer patches the +application afterwards. That second call was not merely redundant: for a talent +user sitting their own interview it was a 403, because `ai-interviews:Create` is +open to everyone and `job-applications:Update` is operators only — the interview +existed, the application still read `applied`, and nothing that counts +`status === 'interview' || interview_id` could see it. + +### 5.2 Definition endpoints (10) + +`agent-definitions` and `skill-definitions`, five methods each: `GET`, `POST` +on the collection; `GET`, `PATCH`, `DELETE` on `/{id}`. + +**This is where account-authored Owliver skills are supposed to live.** +`migrations/000005_agent_skill_definitions.up.sql` states the intent verbatim: + +> *"Before this migration the last two lived in `user_preferences.extra`, a +> jsonb blob with no owner, no tenancy, no size bound, no server-side +> validation and no query surface — and returned in full by GET /api/v1/me on +> every page load. This migration is that move."* + +**The frontend has not moved.** It still writes `customSkills` (121 references +across the repo) and `customAgents` through `PATCH /me/preferences`. + +What the tables give that the preferences blob does not: + +| Column | Purpose | +| --- | --- | +| `markdown` | The definition verbatim — the authoritative artefact | +| `definition_id` | Author-facing id, `^[a-z0-9][a-z0-9-]*$`, unique per owner or per org | +| `visibility` | `personal` \| `organization` — shadow-by-id is deliberate | +| `owner_user_id` / `org_id` | Tenancy and ownership; personal definitions cascade with their owner | +| `status` | agents: `draft`/`published`/`archived`; skills: `active`/`inactive` | +| `version` | Agents only, monotonic. **Skills have no version** — none is invented | +| `name`, `description`, `pages` | Server-parsed projections, never accepted from a request body | + +Plus a `length(markdown) BETWEEN 1 AND 65536` bound the jsonb blob has no +equivalent of. + +**`disabledSkills` and `removedSkills` stay in preferences** — the same +migration says so. They are per-account arrays of skill *ids*, mostly shipped +ids, so they are suppression preferences over a namespace, not definitions. + +### 5.3 Owliver suggestions (1) + +`GET /api/v1/owliver/suggestions?page={surface}&query={typed}` — specified in +the backend contract's §2A. + +**Called, registered in backend source, and NOT deployed.** This is the one row +in this document where those three come apart, so it is worth stating plainly: + +| | | +| --- | --- | +| Frontend calls it | `src/api/base44Client.js:300`, via `useSuggestions.js` | +| Backend source registers it | `go-api/internal/httpserver/owliver.go:22`, commit `b6f8655`, pushed to `origin/main` | +| `https://mcp.krowforce.com` serves it | **No — 404** | + +So a signed-in dev server pointed at `mcp.krowforce.com` answers this route +`404 Not Found`, and no prompt chips appear. **That is a deployment lag, not a +frontend bug, and no change in this repository fixes it.** The two ways out are +to deploy `b6f8655` to that host, or to point `VITE_API_PROXY_TARGET` at a +backend built from current source. + +Two things make this expensive to diagnose, and both are worth knowing before +reaching for the frontend code again: + +- **An unauthenticated probe cannot tell you anything.** Authentication wraps the + whole mux (`server.go:153`), so *every* path under `/api/v1` answers `401` + when the session cookie is absent — a registered route and a nonexistent one + alike. `curl` against the host proves nothing on its own. The `404` only + appears once a request is authenticated and reaches the router, which is why + it shows up in the browser and not in a terminal. +- **`401` versus `404` is the whole diagnosis.** Through the dev proxy, `401` + means the session is not signed in; `404` means the session is fine and the + route is not on that backend. + +The panel treats the two differently as of `useSuggestions.js`: a `404` closes a +circuit breaker and it stops asking for the rest of the page load, because no +further keystroke can change the answer. A `401`, a `500` and an unreachable API +are all still retried. + +The endpoint replaces the *selection*, not +the answering: it returns at most three `{ text, intent }` pairs, where `intent` +is the frontend capability id verbatim — `position-strength`, +`hiring-operations` — so a chosen suggestion dispatches through the capability +manifests that already exist. An optional `capability` names the section type +when the query asked for one ("as a flow"), from `OWLIVER_CAPABILITIES`. + +`page` is a `SKILL_SURFACES` id, not a route and not a context id, so the call +site is `surfaceForRoute()` / the page key the skill registry already derives — +not `ASSISTANT_CONTEXTS`. Aliases resolve, so `hired` and `hired-history` both +work. + +What the server adds over computing this in the browser is the permission +filter: each intent declares what it reads, checked against the policy table, so +a caller is never offered a reading their role cannot perform. That check cannot +be done client-side, which is the reason this one is worth adopting rather than +keeping local. + +No table and no migration — the catalogue is derived from the capability +manifests and the closed Owliver vocabulary. §6's `owliver:` suggestions are a +different thing and unaffected: those are what an *authored skill* declares, and +the backend still treats the block as opaque. + +### 5.4 Registered, deliberately not adopted (1) + +`GET /api/v1/me/preferences` exists. The frontend reads preferences off +`GET /me` plus the `krow_demo_user` mirror instead, because +`auth.preferences()` must answer synchronously on first paint. Not a gap. + +--- + +## 6. Owliver skill response API — Local stub + +What a skill definition may declare about what Owliver can be *asked for*. The +`owliver:` block, normalized by `src/lib/skills/owliverConfig.js` against the +closed vocabulary in `src/lib/skills/surfaces.js`. + +```yaml +owliver: + enabled: true + suggestions: + - Show hiring activity + - label: Summarize it + prompt: Summarize hiring activity + capability: summary + capabilities: + - summary + - flow + responses: + flow: + title: Hiring Activity Flow + source: position.activity + steps: [today, yesterday, last-week] +``` + +### 6.1 Resolution rules + +- **A response is a section.** It normalizes through the same + `normalizeSection()` the page's `ui:` sections go through, so a capability + resolves to the same record, the same data source and the same renderer. + There is no second shape for "the chat version". +- **The block is optional.** No `owliver:` normalizes to `NO_OWLIVER` — + disabled, and behaviourally identical to before the feature existed. +- `enabled: false` switches it off without deleting what was written. +- **`steps:` ≡ `periods:`** — the same list under two names. +- **Inheritance.** A response with no `source` takes the first declared `ui:` + section's `source`, `periods`, `limit` and `editable`, in declaration order. +- **A capability that resolves no source is dropped**, with a named error — + otherwise it would register as something Owliver offers and then have nothing + to answer with. +- **Nothing unknown survives.** Capabilities, sources, periods and shapes are + checked against the tables below; an unrecognised value is a named error, not + a dropped key. +- **`editable` needs both halves.** The source must be `writable` *and* the + page must publish a handler (`usePublishPageActions`). Neither side can + enable editing alone. + +### 6.2 Capabilities (10) + +`shape` is the section type the answer is drawn with. `terms` are how a +question is recognised as asking for that *shape* — never for a subject; a +subject belongs in the definition's `triggers`. + +| id | shape | Reads as | +| --- | --- | --- | +| `summary` | *(none — prose)* | The figures read back as sentences | +| `flow` | `flow` | Stages or periods as a sequence | +| `stats` | `stats` | A row of counted figures | +| `list` | `list` | A ranked or plain list of records | +| `table` | `table` | Rows and columns | +| `timeline` | `timeline` | Dated events, most recent first | +| `progress` | `progress` | Bars against a total | +| `weights` | `weights` | Weighted criteria, adjustable where the page accepts the write | +| `insight` | `insight` | One finding, stated plainly | +| `card` | `card` | A titled panel of figures | + +`summary` is the one capability with no component, so it is compatible with +every source. + +### 6.3 Data sources (25) + +`context` is what the page must supply for the reading to be possible. +`options` are the knobs a definition may set. Only `position.vetting` is +writable. + +| id | context | options | +| --- | --- | --- | +| `position.activity` | positionId | periods | +| `position.pipeline` | positionId | — | +| `position.candidates` | positionId | limit | +| `position.matches` | positionId | limit | +| `position.requirements` | positionId | — | +| `position.vetting` | positionId | — · **writable** | +| `candidate.readiness` | candidateId | — | +| `candidate.activity` | candidateId | — | +| `candidates.pipeline` | — | — | +| `candidates.activity` | — | periods | +| `candidates.quality` | — | periods, limit | +| `positions.demand` | — | limit | +| `positions.risk` | — | limit | +| `talent.pool` | — | limit | +| `hires.recent` | — | limit | +| `hires.performance` | — | — | +| `workforce.training` | — | limit | +| `workforce.coverage` | — | limit | +| `workforce.attendance` | — | periods, limit | +| `workforce.overtime` | — | periods, limit | +| `activity.signals` | — | limit | +| `activity.breakdown` | — | periods, limit | +| `activity.events` | — | limit | +| `operations.risk` | — | — | +| `workspace.summary` | — | — | + +Each source declares which `shapes` it can be drawn as; `sourceSupportsShape()` +is the single resolver all three consumers use (the normalizer, the Board +editor's picker, the Owliver editor's per-capability picker). + +### 6.4 Section types (9) and periods (6) + +`card`, `stats`, `list`, `timeline`, `flow`, `table`, `progress`, `insight`, +`weights`. + +`today`, `yesterday`, `last-7-days`, `last-week`, `this-month`, +`previous-month` — each a window over `created_date`, resolved at read time. +Never a stored figure, never a hard-coded date. + +### 6.5 Surfaces (18) + +`control-center`, `positions`, `create-position` *(alias `new-position`)*, +`candidates`, `hired-history` *(alias `hired`)*, `talent-pool`, `krow-forge` +*(aliases `university`, `forge`)*, `analytics`, `activity`, +`workspace-agent-configure`, `settings`, `workspace`, `workspace-agents`, +`workspace-skills`, `workspace-skill-configure`, `skill-development`, +`profile`, `candidates-analysis`. + +The nine with placements are domain surfaces; the rest declare +`placements: []` — configuration screens holding no workforce records, where a +`ui:` skill is refused at validation rather than validating and drawing +nothing. `placementProvides()` says which placement supplies a `positionId` or +a `candidateId`, so a section reading `position.activity` cannot be attached +where no position is in context. + +### 6.6 Where this is enforced + +**Client-side only.** The backend mirrors `SKILL_SURFACES` exactly — all 18 +ids and the same aliases, in `internal/definition/vocabulary.go:18-40` — and +validates a definition's `pages:` against it. It treats `ui:` and `owliver:` +as **opaque** (`internal/definition/skill.go:237`). + +So if the definition endpoints in §5.2 are adopted, the server will store and +round-trip an `owliver:` block verbatim and check its `pages:`, but will not +reject an unknown capability or data source. That validation stays in +`owliverConfig.js`. + +--- + +## 7. Assistant streaming API — Local stub (wired, unused) + +`src/components/ai-assistant/provider.js`. The UI never generates an answer and +never knows where one came from; it calls `provider.stream(request)` and renders +snapshots. + +``` +provider.id: string +provider.stream(request): AsyncIterable + +request = { contextId, capability, question, facts, signal } +``` + +Each yielded value is the response **so far** as a full block array — snapshots, +not deltas, because a table or a KPI row has no meaningful half-state. + +`createHttpProvider({ endpoint })` sends: + +```http +POST +Content-Type: application/json + +{ "contextId": "admin.positions", "capability": "flow", + "question": "...", "agent": {...}, "owliverContext": {...} } +``` + +and reads SSE-style lines: + +- `data: {"block": {...}}` — appended as a whole block +- `data: {"delta": "..."}` — appended to the trailing text block +- `data: [DONE]` — ignored +- unparseable payload — appended as text + +Requires a streaming `response.body`; a non-2xx throws +`Assistant request failed: `. + +**The fact sheet is deliberately not sent.** Dashboard data is to be read +server-side from the caller's own session, so the client cannot ask about +records it is not entitled to see — and an agent cannot widen that by being +named in the body. + +**Status.** `createAssistantProvider()` returns `createHttpProvider` only when +`VITE_ASSISTANT_ENDPOINT` is set. It is unset, so the app runs +`createLocalProvider()` — deterministic answers computed from the dashboard's +own data. **No backend route exists for this.** + +Related, and honest about itself: an agent with `webSearch` set gets a note +saying no search provider is configured in this deployment, rather than an +answer that quietly came from nowhere. + +--- + +## 8. AI integrations — Local stub + +`src/api/aiEngine.js`. No network, no key, no server route anywhere in the +backend. + +### `integrations.Core.InvokeLLM({ prompt, response_json_schema, model })` + +Recognises each workflow by a stable phrase its prompt opens with, reads the +structured fields the prompt already carries, and scores deterministically — +same input, same output. An unrecognised prompt logs a warning and returns `{}` +(or `''` with no schema) rather than a shape the caller cannot use. + +Consumers — `src/lib/krowAi.js`: `generateJobDescription`, +`buildResumeFromText`, `screenCandidate`, `generateInterviewQuestion`, +`matchTalentForJob`, `evaluateInterview`, `owliverQuestion`, +`buildProfileFromConversation`. And `src/lib/provingGround.js`: +`challengeFollowUp`, `evaluateChallenge`. + +### `integrations.Core.UploadFile({ file })` + +Returns `{ file_url, file_name, file_size }` where `file_url` is a `blob:` URL +from `URL.createObjectURL`. **There is no file storage backend.** The URL is +valid for the rest of the session and dies on reload — anything persisted +through it (a selfie on a worker profile, challenge media) is a dangling +reference afterwards. + +Consumers: `src/pages/KrowIdentity.jsx`, +`src/components/krow/proving/MediaChallenge.jsx`, +`src/components/krow/proving/VideoRecorder.jsx`. + +### `analytics.track({ eventName, properties })` + +A DEV-only `console.debug` (`src/api/base44Client.js:287-293`). No telemetry +endpoint exists. Note that real activity logging is separate and *is* served — +`logActivity` writes through `POST /api/v1/user-activity`. + +### `GET /health` + +Registered on the backend and public. `vite.config.js` proxies it. **No +frontend code calls it.** + +--- + +## 9. Browser-only state + +| Key | Store | Holds | +| --- | --- | --- | +| `krow_demo_user` | localStorage | Mirror of the last `GET /me`, so `auth.preferences()` can be synchronous | +| `krow_assistant:history` | localStorage | Owliver conversation history across sessions | +| `krow_assistant_open` | sessionStorage | Whether the panel is open | +| `krow_assistant_expanded` | sessionStorage | Expanded state | +| `krow_assistant_width` | sessionStorage | Panel width, throttled on drag | +| `krow_assistant:agent` | sessionStorage | Selected agent | +| `` / `:flow` / `:id` | sessionStorage | Current thread, in-flight flow and conversation id, per page context | + +Other platform APIs: `window.matchMedia` and `window.visualViewport` +(§11), `URL.createObjectURL` (§8), `navigator.mediaDevices.getUserMedia` and +`MediaRecorder` (`components/krow/proving/VideoRecorder.jsx:21,37`), and +`html2canvas` (`components/krow/talent/IdentityCareerCard.jsx`). + +--- + +## 10. External hotlinks — External + +No auth, no versioning, no contract. Each is a live dependency on a third party. + +| Host | Used for | Where | +| --- | --- | --- | +| `fonts.googleapis.com` | Sora webfont | `src/index.css:1` | +| `api.qrserver.com` | vCard QR for the KROW ID | `KrowIdPreview.jsx:52`, `talent/IdentityCareerCard.jsx:132` | +| `images.unsplash.com` | Portrait fallback when `selfie_url` is empty | `SuggestedTalent.jsx`, `CandidateCard.jsx`, `TalentPoolCard.jsx` | +| `i.pravatar.cc` | Seed avatars | `src/api/seed.js` (15 records) | +| `static.wixstatic.com` | Image host allowlist | `src/components/ui/image.jsx:6` | + +--- + +## 11. Owliver panel responsiveness + +**This section is UI layout, not an API contract.** It is here because it was +asked for alongside the API surface, not because anything below crosses a +network boundary. + +`src/components/ai-assistant/viewport.js`: + +- **`STACK_BREAKPOINT = 768`**, i.e. Tailwind's `md`, exported so the panel and + the provider switch on the same number. Below it Owliver stops being a column + beside the dashboard and becomes an overlay — a 380px track on a 375px phone + collapses `main` to nothing. Deliberately `md` and not `lg`: tablets lay the + inline column out acceptably, so only phones change presentation. +- **`useIsPhone()` uses `matchMedia`, not `innerWidth`.** When anything on the + page overflows, mobile browsers widen the layout viewport to fit it, and + `innerWidth` reports the widened value — so a width comparison reads the + viewport as a tablet and hands it a sidebar, defeating the very layout that + exists to rescue the page. A media query is evaluated against the media, not + the content. +- **`useVisualViewport()`** returns the region left over with the on-screen + keyboard up. `100dvh` follows the address bar but knows nothing about the + keyboard, so a `100dvh` sheet puts its own composer underneath the keys. + Returns `null` where the API is missing, and the caller falls back to the + `100dvh` class. No fixed pixel heights anywhere. +- **Width** is clamped against `useViewportWidth()` and persisted to + `krow_assistant_width` on a throttle rather than sixty times a second. + +--- + +## 12. Drift found + +Recorded, not fixed — each belongs in its own repo. + +| Where | Issue | +| --- | --- | +| `krow-backend/docs/api-contract.md` §1 | Says "Auth: None in v1. Every endpoint is unauthenticated." Sessions and cookie middleware have been implemented since. | +| `krow-demo/src/api/store.js` | 173 lines, **imported by nothing**. Superseded by `httpClient.js`. Dead. | +| `krow-demo/src/api/seed.js` | 1,928 lines, retained for one export — `DEMO_USER.preferences`, the default shape the synchronous accessor needs. Entity data lives in PostgreSQL. | +| `krow-demo/src/lib/krowHooks.js:537` | `useBadges` has no consumers and no route. | +| `krow-demo/src/api/httpClient.js:96` | `User: 'users'` names a resource the backend does not have. No call site, so harmless today. | + +## 13. Summary + +- **Nothing the frontend calls is missing from the backend.** All 14 live + entities and all 5 auth/preference routes are served. +- **The two workflow endpoints are adopted.** Hire, assign and interview + completion each go over one request and one transaction; the frontend no + longer sequences the writes itself, and no longer writes the `hire_candidate` + or `assign_employee` audit entries the server now writes inside those + transactions. +- **12 registered endpoints still go unused**, of which 11 are worth adopting: + 10 definition endpoints that are where Owliver's authored skills and agents + were meant to move, and the Owliver suggestion endpoint, which is the only one + that cannot be done correctly in the browser — it is permission filtered. +- **Four surfaces have no server at all** — `InvokeLLM`, `UploadFile`, the + assistant streaming endpoint, and web search. They are local stubs today, and + the app is honest about each. +- **The Owliver skill vocabulary is enforced in the browser only.** The backend + validates `pages:` and treats the rest of a definition as opaque. diff --git a/src/api/base44Client.js b/src/api/base44Client.js index 401beaf..969aa77 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -275,6 +275,58 @@ const auth = { }, }; +/* ── 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 }, + }); + }, +}; + /* ── Integrations & analytics ───────────────────────────────────────────── */ const integrations = { @@ -292,7 +344,7 @@ const analytics = { }, }; -export const base44 = { entities, auth, integrations, analytics }; +export const base44 = { entities, auth, integrations, analytics, workflows }; /** Where the entity data actually comes from, for diagnostics. */ export { API_BASE_URL }; diff --git a/src/components/agents/AgentConfigure.jsx b/src/components/agents/AgentConfigure.jsx index 1b34dcb..eb3ce61 100644 --- a/src/components/agents/AgentConfigure.jsx +++ b/src/components/agents/AgentConfigure.jsx @@ -1,14 +1,12 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; import { BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus, - SlidersHorizontal, Trash2, X, Zap, + SlidersHorizontal, Trash2, X, } from 'lucide-react'; import { Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from '@/components/ds'; -import { allSkills } from '@/lib/skills/registry'; import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces'; import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary'; import { @@ -116,9 +114,6 @@ export function AgentConfigure({ [fields.instructions] ); - const registry = React.useMemo(() => allSkills(customSkills), [customSkills]); - const skillById = React.useMemo(() => new Map(registry.map((s) => [s.id, s])), [registry]); - const availableSubagents = React.useMemo( () => agents.filter((a) => a.id !== fields.id && a.status === 'published'), [agents, fields.id] @@ -181,10 +176,9 @@ export function AgentConfigure({ id: 'capabilities', icon: Boxes, label: 'Capabilities', - meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`, + meta: `${fields.pages.length + fields.knowledge.length}`, items: [ { id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length }, - { id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length }, { id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length }, ], }, @@ -313,7 +307,7 @@ export function AgentConfigure({ icon={Boxes} title="Capabilities" description="What this agent can reach and use when it answers." - meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.skills.length} skill${fields.skills.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`} + meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`} open={open.capabilities} onToggle={() => toggleSection('capabilities')} > @@ -390,80 +384,6 @@ export function AgentConfigure({

- {/* Skills ── what it can do. */} -
- - Browse catalog - - )} - /> - - {/* Attaching happens in the Skills workspace, not here. A - checkbox list could say which skills exist; it could not show - what an agent is *made of*, which is the question someone - composing one is actually asking. */} - {fields.skills.length > 0 ? ( -
    - {fields.skills.map((id) => { - const skill = skillById.get(id); - return ( - set({ skills: fields.skills.filter((x) => x !== id) })} - /> - ); - })} -
- ) : ( -

- No skills attached. This agent can still answer from the page's own reader — - or open the{' '} - {' '} - to give it one. -

- )} - -

- Attached under{' '} - - , where Owliver skills and Board skills both live. Definitions are written in the{' '} - - skill library - - . -

-
- {/* Knowledge ── what it has been told. */}
buildIntro(context.id, facts, userName), [context.id, facts, userName] ); - /* Suggestions are the page's own, with any skill that offers one on the front. - The last answer can also propose follow-ups — the role list after "create a - position" — which replace the standing set until the next turn. */ - const followUp = messages[messages.length - 1]?.followUp; - const prompts = React.useMemo(() => { - if (followUp?.length) return followUp; + /** + * Everything this page can be asked, in the chips that already know how to + * ask it. + * + * Unchanged in what it collects and in what order: the agent's starters, the + * declared suggestions of every attached skill, the skills that carry a + * prompt, and the page's own derived prompts, de-duplicated by intent. Each + * chip keeps the metadata that makes it executable — a page `capability`, a + * `skillId`/`skillCapability` pair, a `positionId`, a `route` — because that + * is what `runPrompt` dispatches on. + * + * What changed is only that this is no longer what the panel renders. It is + * the set that `prompts` below chooses from, so an opening panel can offer + * nothing while a typed query can still reach any of it. Building it eagerly + * costs nothing — it is derived from data already in hand and memoized on it + * — and building it lazily would mean the first keystroke paid for the whole + * catalogue. + */ + const catalogue = React.useMemo(() => { /* A definition's own suggestions come first: they are the only ones written for this workspace rather than derived from the page, and they are capped by the resolver so a workspace with several skills attached cannot bury @@ -586,7 +609,40 @@ export default function KrowAssistant({ }); /* `pageContext` decides which suggestions can answer without asking, so the chips re-rank when a position is opened or closed. */ - }, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]); + }, [skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]); + + /** + * What the chip row actually shows, which is one of three separate things. + * + * They are separate states, not one merged list, because they answer to + * different owners. Follow-ups belong to the answer that raised them; typed + * matches belong to the composer; the catalogue belongs to the page. Only one + * of them can be true at a time, and the order below is that precedence. + * + * 1. Follow-ups. When an answer ends by asking something, its chips *are* + * the answers to it — the role list after "create a position". They are + * never capped and never filtered, and they stand until the next turn or + * until the reader starts typing something else. + * + * 2. Typed matches. From the second meaningful character, the catalogue + * above is ranked against the query and the best three are offered. + * These are the catalogue's own chip objects, so clicking one runs the + * same capability or skill it would have run when the panel offered the + * whole list outright. + * + * 3. Nothing. An empty composer offers no chips at all. The panel used to + * open on a dozen of them, which taught the range of what could be asked + * by saying all of it at once and pushed the composer — the thing the + * reader came for — under a wall of suggestions. The greeting still + * carries the page's context; `buildIntro` reads the same fact sheet it + * always did. + */ + const followUp = messages[messages.length - 1]?.followUp; + const typed = input.trim(); + const prompts = React.useMemo(() => { + if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS; + return rankPrompts(catalogue, typed); + }, [typed, followUp, catalogue]); /* The newest assistant turn, which is the one that carries the rating. */ const lastAnswerIndex = React.useMemo( diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js index 87f585f..faee0dd 100644 --- a/src/components/ai-assistant/capabilities/admin.js +++ b/src/components/ai-assistant/capabilities/admin.js @@ -697,7 +697,7 @@ const userActivityInsights = (f) => { * panel and the log can never disagree about what matters. */ const HIGH_SEVERITY = ['hire_candidate', 'create_position']; -const MEDIUM_SEVERITY = ['screen_candidate', 'start_interview']; +const MEDIUM_SEVERITY = ['assign_employee', 'screen_candidate', 'start_interview']; /** * Unusual activity — what is out of pattern, stated as a pattern. diff --git a/src/components/ai-assistant/matchPrompts.js b/src/components/ai-assistant/matchPrompts.js new file mode 100644 index 0000000..8e7368c --- /dev/null +++ b/src/components/ai-assistant/matchPrompts.js @@ -0,0 +1,171 @@ +/** + * Ranking the panel's own suggestions against what is being typed. + * + * This is not a catalogue and deliberately does not own one. Owliver already + * decides what can be asked on a page — the agent's starters, every attached + * skill's declared suggestions, the page's own derived prompts — and each of + * those chips carries the metadata that makes it *executable*: a `capability` + * that names one of the page's answers, a `skillId`/`skillCapability` pair that + * names a skill's reading, a `positionId` that says which record, a `route` for + * the ones that navigate. All this module does is choose which of those + * already-built chips are worth showing for a given query, and hand them back + * unchanged so that clicking one runs exactly what it always ran. + * + * Returning the original object rather than a copy is the whole contract. A + * ranked chip is the same chip; `runPrompt` cannot tell it apart from one that + * arrived unfiltered, so nothing about how a suggestion executes depends on + * whether it was typed towards or offered outright. + */ + +/** + * The shortest query worth ranking. Below it the panel shows nothing at all — + * one character matches most of the catalogue, which is the wall of chips this + * replaced. + */ +export const MIN_QUERY_CHARS = 2; + +/** Never more than a row. The composer is what the reader came for. */ +export const MAX_MATCHES = 3; + +/** One shared empty array, so a keystroke that matches nothing is referentially + stable and does not rerender the chip row. */ +const NONE = []; + +const lower = (value) => String(value ?? '').toLowerCase(); + +/** + * Letters and digits only, Unicode aware — the same thing the reader would + * count. Punctuation alone never counts as having typed anything. + */ +const meaningful = (value) => (String(value ?? '').match(/[\p{L}\p{N}]/gu) || []).length; + +/** A string as the words worth matching on. */ +const words = (value) => lower(value).split(/[^\p{L}\p{N}]+/u).filter(Boolean); + +/** + * Words too common to carry a subject. + * + * Only used to stop a query made *entirely* of them from matching the whole + * catalogue: "show me the" should offer nothing rather than everything. A + * stop word alongside a real term is still scored, because "show pipeline" + * should rank a chip saying both above one saying only the second. + */ +const STOP_WORDS = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'do', 'does', 'did', + 'show', 'me', 'my', 'our', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', + 'with', 'what', 'how', 'why', 'can', 'you', 'i', 'it', 'please', 'give', + 'tell', 'about', 'this', 'that', 'any', 'all', +]); + +/** + * How well one term matches one field. + * + * Prefix matching is what makes this feel like typing rather than searching: + * "platf" has to find "Platform health" four characters before the word is + * finished. Infix matching is allowed only from four characters, where a + * fragment is specific enough that finding it mid-word is a hit rather than an + * accident — "line" must not match "pipeline" while "peli" reasonably does. + */ +function fieldScore(term, fieldWords, exact, partial) { + let best = 0; + for (const word of fieldWords) { + if (word === term) return exact; + if (word.startsWith(term)) best = Math.max(best, partial); + else if (term.length >= 4 && word.includes(term)) best = Math.max(best, partial - 1); + } + return best; +} + +/** + * What a chip resolves to, as words. + * + * A capability id is not decoration — `pipeline-health` is the name of the + * reading the chip runs, and it is frequently the only place the subject + * appears. The Control Center's bottleneck chip reads "Bottleneck at + * interviewed" and sends a sentence about candidates dropping between stages; + * nothing in either says "pipeline", and typing that word is exactly how a + * reader would look for it. So the chip's own routing metadata is matched too, + * at the lowest weight of the three fields — it is what the chip *is*, not what + * it says, and a chip that says the word should always rank above one that + * merely resolves to it. + */ +const intentWords = (chip) => words( + [chip?.capability, chip?.skillId, chip?.skillCapability].filter(Boolean).join(' ') +); + +/** + * A chip's score for a query, or 0 for "do not offer this". + * + * The label is weighted above the prompt because the label is what the reader + * sees: a chip that reads "Platform health" is a better answer to "platform" + * than one that happens to mention the word in the sentence it sends, even + * though both would answer. + */ +function score(chip, terms, phrase) { + const label = lower(chip?.label); + const prompt = lower(chip?.prompt); + if (!label && !prompt) return 0; + + let total = 0; + let matched = 0; + + /* The whole query as one phrase, which is the strongest signal there is — + "pipeline health" typed in full should beat two chips that each carry one + of those words. */ + if (label.includes(phrase)) total += 12; + else if (prompt.includes(phrase)) total += 7; + + const labelWords = words(label); + const promptWords = words(prompt); + const routeWords = intentWords(chip); + + for (const term of terms) { + const hit = Math.max( + fieldScore(term, labelWords, 6, 4), + fieldScore(term, promptWords, 4, 2), + fieldScore(term, routeWords, 3, 2) + ); + if (hit) { + matched += 1; + total += hit; + } + } + + /* A chip has to actually be about something that was typed. */ + if (!matched) return 0; + + /* Every term landing somewhere is worth more than most of them landing. */ + if (matched === terms.length) total += 3; + + /* A suggestion that would have to ask which record before it could answer + ranks below one that answers — the same order the resolver already puts + them in when they are offered unfiltered. */ + if (chip?.deferred) total -= 3; + + return total; +} + +/** + * The best few of `prompts` for `query`, in the chips' own objects. + * + * Stable: chips scoring equally keep the order they arrived in, which is the + * order the panel already considers most useful — agent starters, then declared + * skill suggestions, then the page's derived prompts. + */ +export function rankPrompts(prompts = [], query = '', max = MAX_MATCHES) { + const text = String(query ?? '').trim(); + if (meaningful(text) < MIN_QUERY_CHARS) return NONE; + + const terms = words(text); + if (!terms.length || terms.every((term) => STOP_WORDS.has(term))) return NONE; + + const phrase = lower(text); + const scored = []; + prompts.forEach((chip, index) => { + const value = score(chip, terms, phrase); + if (value > 0) scored.push({ chip, value, index }); + }); + + scored.sort((a, b) => b.value - a.value || a.index - b.index); + return scored.length ? scored.slice(0, max).map((entry) => entry.chip) : NONE; +} diff --git a/src/components/krow/AIInterviewModal.jsx b/src/components/krow/AIInterviewModal.jsx index 6a87cc3..c41b493 100644 --- a/src/components/krow/AIInterviewModal.jsx +++ b/src/components/krow/AIInterviewModal.jsx @@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button'; import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react'; import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi'; -import { useCreateInterview, useUpdateApplication } from '@/lib/krowHooks'; +import { useCreateInterview } from '@/lib/krowHooks'; import OwliverAvatar from '@/components/krow/OwliverAvatar'; import { cn } from '@/lib/utils'; @@ -28,7 +28,6 @@ export default function AIInterviewModal({ open, onClose, application, job, cond const scrollRef = useRef(null); const createInterview = useCreateInterview(); - const updateApplication = useUpdateApplication(); const jobTitle = job?.title || 'the role'; const candidateName = application?.applicant_name || 'Candidate'; @@ -153,11 +152,23 @@ export default function AIInterviewModal({ open, onClose, application, job, cond setTimeout(() => askNextQuestion(newMessages), 300); }, [transcript, messages, stopListening, askNextQuestion]); + /** + * Write the interview and let the server carry the verdict onto the + * application. + * + * There is deliberately no second call here. `POST /ai-interviews` moves the + * application to `interview`, sets `interview_id` and copies the score across + * in the same transaction as the interview itself. Doing it from the browser + * was two independent requests, and for a talent user sitting their own + * interview the second one was a 403 — the interview existed, the application + * still read `applied`, and every consumer counting + * `status === 'interview' || interview_id` could not see it. + */ const finishInterview = useCallback(async (finalMessages) => { setPhase('evaluating'); try { const result = await evaluateInterview(finalMessages, job, candidateName, language); - const interviewRecord = await createInterview.mutateAsync({ + await createInterview.mutateAsync({ application_id: application.id, job_posting_id: application.job_posting_id, job_title: jobTitle, @@ -175,18 +186,13 @@ export default function AIInterviewModal({ open, onClose, application, job, cond summary: result.summary, reasoning: result.reasoning, }); - await updateApplication.mutateAsync({ id: application.id, data: { - status: 'interview', - interview_id: interviewRecord.id, - ai_score: result.overall_interview_score, - } }); setEvaluation(result); setPhase('done'); } catch { setError('Evaluation failed. Please try again.'); setPhase('active'); } - }, [application, job, candidateName, createInterview, updateApplication, language]); + }, [application, job, candidateName, createInterview, language]); const startInterview = useCallback(() => { setPhase('active'); diff --git a/src/components/krow/ActivityLogTable.jsx b/src/components/krow/ActivityLogTable.jsx index 87670c1..5e3ca0c 100644 --- a/src/components/krow/ActivityLogTable.jsx +++ b/src/components/krow/ActivityLogTable.jsx @@ -8,6 +8,7 @@ const EVENT_LABELS = { create_position: 'Create Position', screen_candidate: 'Screen Candidate', hire_candidate: 'Hire Candidate', + assign_employee: 'Assign Employee', apply_job: 'Apply to Job', start_interview: 'Start Interview', }; @@ -19,6 +20,7 @@ const EVENT_COLORS = { create_position: 'bg-[#F5F3FF] text-[#5B21B6]', screen_candidate: 'bg-[#FFFBEB] text-[#92400E]', hire_candidate: 'bg-[#ECFDF5] text-[#065F46]', + assign_employee: 'bg-[#F0F9FF] text-[#075985]', apply_job: 'bg-[#EEF3FE] text-[#1E40AF]', start_interview: 'bg-[#FEF2F2] text-[#991B1B]', }; diff --git a/src/lib/krowHooks.js b/src/lib/krowHooks.js index 19c26ec..5f3a191 100644 --- a/src/lib/krowHooks.js +++ b/src/lib/krowHooks.js @@ -294,30 +294,44 @@ export function useGenerateJobDescription() { }); } +/** + * 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 updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' }); - await base44.entities.Staff.create({ - name: application.applicant_name, - email: application.email, - phone: application.phone, + 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], - application_id: application.id, - job_posting_id: application.job_posting_id, - ai_score: application.ai_score, status: 'onboarding', }); - return updated; + return result?.application; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: ['staff'] }); - logActivity('hire_candidate'); + /* 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'] }); }, }); } @@ -328,6 +342,15 @@ export function useMatchTalentForJob() { }); } +/** + * 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({ @@ -400,15 +423,32 @@ export function useAssignments() { * time anything reaches here the admin has seen exactly who and how many. There * is no code path that assigns somebody without that preview having been shown. * - * Writes three things per person, because an assignment that updated only one - * of them would leave the system disagreeing with itself: the assignment - * record, the application's status where the person applied, and an activity - * event carrying every id involved. + * 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(); @@ -416,37 +456,25 @@ export function useAssignWorkers() { ? null : new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString(); - const created = []; - for (const worker of workers) { - const record = await base44.entities.Assignment.create({ - job_posting_id: position.id, + const batch = workers.map((worker) => { + const entry = { worker_email: worker.email, worker_name: worker.name, starts_at: startsAt, ends_at: endsAt, - status: 'active', source: 'owliver', match_score: worker.score ?? null, - }); - created.push(record); + }; - /** - * The application is what puts a person *in the pipeline for this role*, - * and it is what every downstream step keys on — the candidate profile - * is addressed by it, and the AI interview takes one as its subject. - * - * So assigning somebody from the existing workforce creates one where - * none exists, exactly as the Position page's own "admit talent" path - * does. Without it an assigned worker would be unreachable: no record to - * open, and no way to interview them. - */ - let application = applications.find( + const application = applications.find( (a) => a.job_posting_id === position.id && String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase() ); - if (!application) { - application = await base44.entities.JobApplication.create({ + if (application) { + entry.application_id = application.id; + } else { + entry.application = { applicant_name: worker.name, email: worker.email || '', phone: worker.profile?.phone || '', @@ -461,20 +489,17 @@ export function useAssignWorkers() { job_title: position.title, status: 'assigned', ai_score: worker.score ?? 0, - }); - } else { - await base44.entities.JobApplication.update(application.id, { status: 'assigned' }); + }; } - record.application_id = application.id; - await logActivity('assign_employee', { - details: `${worker.name} assigned to ${position.company ? `${position.company} — ` : ''}${position.title}`, - position_id: position.id, - application_id: application?.id || null, - worker_email: worker.email, - }); - } - return created; + 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 diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx index 2e59458..c1438ef 100644 --- a/src/pages/Profile.jsx +++ b/src/pages/Profile.jsx @@ -11,6 +11,7 @@ const EVENT_LABELS = { create_position: 'Created a position', screen_candidate: 'Screened a candidate', hire_candidate: 'Hired a candidate', + assign_employee: 'Assigned an employee', apply_job: 'Applied for a job', start_interview: 'Started an interview', }; diff --git a/src/pages/admin/Activity.jsx b/src/pages/admin/Activity.jsx index 3c91355..4012759 100644 --- a/src/pages/admin/Activity.jsx +++ b/src/pages/admin/Activity.jsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react'; import { Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus, + Users, } from 'lucide-react'; import { Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline, @@ -23,6 +24,7 @@ import { SkillSurface } from '@/components/skills/SkillSurface'; const SEVERITY = { hire_candidate: 'high', create_position: 'high', + assign_employee: 'medium', screen_candidate: 'medium', start_interview: 'medium', signup: 'low', @@ -50,6 +52,7 @@ const TIMELINE_LIMIT = 12; */ const EVENT_ICON = { hire_candidate: UserCheck, + assign_employee: Users, create_position: FilePlus2, screen_candidate: ScanSearch, start_interview: Mic, diff --git a/vite.config.js b/vite.config.js index bc6b80e..94a6072 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,12 +1,25 @@ import path from 'node:path'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; // The reference app resolves `@/*` through the Base44 Vite plugin. This demo is // backend-free, so the alias is declared directly. // The API host the dev server forwards to. Only read here; nothing in client // code names a backend host. -const PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || 'https://mcp.krowforce.com'; +// +// It has to come from `loadEnv`, not `process.env`. Vite reads `.env` files for +// *client* code and exposes them on `import.meta.env`; it never copies them into +// `process.env`, and this config file runs in Node before any of that happens. +// So `process.env.VITE_API_PROXY_TARGET` was always undefined unless the +// variable was exported in the shell, and the line in `.env` documenting this +// knob did nothing at all — every dev server silently used the hard-coded +// fallback no matter what `.env` said. `loadEnv(mode, cwd, '')` reads the same +// files Vite would, with no prefix filter, so the `.env` value now actually +// takes effect. +function proxyTargetFor(mode) { + const env = loadEnv(mode, process.cwd(), ''); + return env.VITE_API_PROXY_TARGET || 'https://mcp.krowforce.com'; +} /** * One proxy entry per path prefix, all pointing at the same backend. @@ -18,7 +31,7 @@ const PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || 'https://mcp.krowforce * `changeOrigin` rewrites the Host header to the target, which the backend's * TLS termination requires to route the request at all. */ -function proxyRoutes(prefixes) { +function proxyRoutes(prefixes, PROXY_TARGET) { return Object.fromEntries(prefixes.map((prefix) => [prefix, { target: PROXY_TARGET, changeOrigin: true, @@ -37,60 +50,66 @@ function proxyRoutes(prefixes) { }])); } -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - '@': path.resolve(import.meta.dirname, './src'), +export default defineConfig(({ mode }) => { + const PROXY_TARGET = proxyTargetFor(mode); + + console.log(`[vite] proxying /api and /health to ${PROXY_TARGET}`); + + return { + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(import.meta.dirname, './src'), + }, }, - }, - build: { - rollupOptions: { - output: { - // Split the heavy libraries out of the app bundle. They change far less - // often than app code, so browsers keep them cached across deploys. - manualChunks: { - react: ['react', 'react-dom', 'react-router-dom'], - charts: ['recharts'], - motion: ['framer-motion'], - markdown: ['react-markdown'], - canvas: ['html2canvas'], + build: { + rollupOptions: { + output: { + // Split the heavy libraries out of the app bundle. They change far less + // often than app code, so browsers keep them cached across deploys. + manualChunks: { + react: ['react', 'react-dom', 'react-router-dom'], + charts: ['recharts'], + motion: ['framer-motion'], + markdown: ['react-markdown'], + canvas: ['html2canvas'], + }, }, }, }, - }, - server: { - // Expose server over network (0.0.0.0) - host: true, - // Honor a port assigned by the environment; fall back to Vite's default. - port: process.env.PORT ? Number(process.env.PORT) : 5173, + server: { + // Expose server over network (0.0.0.0) + host: true, + // Honor a port assigned by the environment; fall back to Vite's default. + port: process.env.PORT ? Number(process.env.PORT) : 5173, - /** - * Proxy the API so the browser only ever talks to one origin. - * - * This exists for the session cookie. The cookie is HttpOnly with - * SameSite=Lax, and a Lax cookie is not sent on a cross-site subresource - * request — which is exactly what `fetch('http://127.0.0.1:8080/...')` from - * a page served by `http://localhost:5173` is, because a browser treats - * those two as different sites. Every request after login would arrive - * without the cookie and be answered 401. - * - * The alternatives are both worse. SameSite=None would send the cookie on - * genuine cross-site requests too, which is the CSRF hole Lax closes, and - * it requires Secure — so it cannot work over plain HTTP on localhost at - * all. Widening CORS with credentials would keep the cross-site problem and - * add a second one. - * - * browser → localhost:5173/api/v1 → this proxy → 127.0.0.1:8080/api/v1 - * - * Same origin from the browser's point of view, so the cookie is a - * first-party cookie, CORS never enters into it, and nothing in the React - * code names a backend host. - * - * Production serves the built assets and the API from one origin (see - * nginx.conf), so this is a development-only shim for a property the - * deployed app has for free. - */ - proxy: proxyRoutes(['/api', '/health']), - }, + /** + * Proxy the API so the browser only ever talks to one origin. + * + * This exists for the session cookie. The cookie is HttpOnly with + * SameSite=Lax, and a Lax cookie is not sent on a cross-site subresource + * request — which is exactly what `fetch('http://127.0.0.1:8080/...')` from + * a page served by `http://localhost:5173` is, because a browser treats + * those two as different sites. Every request after login would arrive + * without the cookie and be answered 401. + * + * The alternatives are both worse. SameSite=None would send the cookie on + * genuine cross-site requests too, which is the CSRF hole Lax closes, and + * it requires Secure — so it cannot work over plain HTTP on localhost at + * all. Widening CORS with credentials would keep the cross-site problem and + * add a second one. + * + * browser → localhost:5173/api/v1 → this proxy → 127.0.0.1:8080/api/v1 + * + * Same origin from the browser's point of view, so the cookie is a + * first-party cookie, CORS never enters into it, and nothing in the React + * code names a backend host. + * + * Production serves the built assets and the API from one origin (see + * nginx.conf), so this is a development-only shim for a property the + * deployed app has for free. + */ + proxy: proxyRoutes(['/api', '/health'], PROXY_TARGET), + }, + }; });