HR-ATS-Portal/frontend/src/api/inbox.js

252 lines
8.7 KiB
JavaScript

import { request } from '../lib/apiClient'
/**
* Persisted mailbox messages.
*
* NOTE: this endpoint currently has no auth dependency server-side
* (backend/inbox/app.py). We send the bearer anyway, so adding
* Depends(get_current_user) later is a zero-diff change on this side.
*/
export function listMessages() {
return request('/inbox/fetch')
}
/**
* Persisted applications — the shape the All Applications tab renders.
*
* Unlike /inbox/fetch this one IS permissioned server-side
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
*
* `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue.
*/
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate } = {}) {
return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
// to true = no filter), send false for the Unread tab only. buildUrl drops
// undefined but keeps false, so `isread: undefined` sends no param at all.
// Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
// Same for `is_duplicate`: omit unless the Duplicates tab.
params: {
search,
top,
skip,
record_id: recordId,
isread,
application_status: applicationStatus,
assigned,
is_duplicate: isDuplicate,
},
})
}
/** Unfiltered application total. Called once when Inbox Email opens. */
export function countApplications() {
return request('/inbox/all-applications/count')
}
/**
* One persisted message by id — the detail behind an inbox row.
*
* `record_id` is the inbox_messages PRIMARY KEY, not the Graph message_id:
* get_inbox_message_by_id runs uuid.UUID(record_id) and matches on `id`, so the
* external string id would fail the parse and 404. The `id` field on both
* /inbox/fetch and /inbox/all-applications rows is already that primary key.
*/
export function getMessage(recordId) {
return request('/inbox/fetch', { params: { record_id: recordId } })
}
/** Enqueue Outlook pull on the mailbox_sync worker. Returns a run immediately. */
export function startMailboxSync({ top, skip, testOn } = {}) {
return request('/email/sync', {
method: 'POST',
params: { top, skip, test_on: testOn },
})
}
/** Poll one sync run (or the active/latest run when runId is omitted). */
export function getMailboxSync(runId) {
return request('/email/sync/fetch', { params: { run_id: runId } })
}
/**
* Legacy synchronous sync — blocks until the page is ingested. Prefer
* startMailboxSync + getMailboxSync so closing the tab cannot kill the job.
*/
export function syncMailbox({ token, top, skip } = {}) {
return request('/email/fetch', { params: { token, top, skip } })
}
/**
* Flips one persisted inbox row read/unread (local DB only — nothing is pushed
* back to Outlook). The body is optional server-side and defaults to read=true.
*/
export function markRead(recordId, read = true) {
return request(`/inbox/${recordId}/read`, { method: 'POST', body: { read } })
}
/**
* Flips a hand-picked selection in one statement. Requires inbox.edit.
*
* Capped at 500 ids server-side (MAX_BULK_READ_IDS in backend/inbox/views.py),
* which answers 413 — callers with a longer list chunk it.
*
* Resolves to `{requested, updated, read}`. `updated < requested` means some ids
* no longer exist, not that the call failed: the rows that did exist committed.
*/
export function bulkSetRead(recordIds, read) {
return request('/inbox/read', {
method: 'PATCH',
body: { record_ids: recordIds, read },
})
}
/**
* Flips EVERY row matching a list filter — the "mark all in this view" button.
*
* The filter params are deliberately the same ones listApplications takes, and
* the server runs them through the same WHERE builder the list uses
* (Inbox_Messages._apply_filters). Omit them all and the scope is the whole
* mailbox, which is exactly what the All Applications tab shows.
*
* `search` is NOT the Inbox screen's search box: that filters client-side on
* name/position/source, while the server matches subject/from/body. Passing one
* for the other would mark rows the user never saw — the screen sends the
* visible ids to bulkSetRead instead whenever its search box is non-empty.
*
* Resolves to `{updated, read}`, where `updated` counts rows that actually
* CHANGED state, so it is safe to show in a toast.
*/
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate } = {}) {
return request('/inbox/read-all', {
method: 'PATCH',
body: {
read,
search,
isread,
application_status: applicationStatus,
assigned,
is_duplicate: isDuplicate,
},
})
}
/** Assign (or clear with null) the job post for one application. Requires inbox.edit. */
export function assignJobPost(recordId, jobPostId) {
return request(`/inbox/${recordId}/assign-job-post`, {
method: 'PATCH',
body: { job_post_id: jobPostId },
})
}
/** Re-queue the matching agent for one application. Requires inbox.edit. */
export function rematch(recordId) {
return request(`/inbox/${recordId}/match`, { method: 'POST' })
}
/** Tab badges — `{all, unread, imported, processed, rejected, duplicates, assigned, unassigned}`. */
export function counts() {
return request('/inbox/counts')
}
/**
* The counts object itself, unwrapped.
*
* Every consumer must go through this. The Inbox tabs and the sidebar badge
* share one React Query key (qk.mailbox.counts), and React Query caches on the
* key alone — so two queryFns returning different shapes overwrite each other.
* That is precisely what happened: the badge's queryFn returned a number, the
* tabs' returned the object, and whichever ran last defined the cache. When the
* object won, the badge tried to render it as a child and React threw #31.
* A consumer that wants one field selects it with `select`, never by narrowing
* the fetcher.
*/
export async function fetchCounts() {
const res = await counts()
return res?.data ?? {}
}
/** `processing_state` is `unread|imported|processed|rejected`. Requires inbox.edit. */
export function setProcessingState(recordId, processingState) {
return request(`/inbox/${recordId}/processing-state`, {
method: 'PATCH',
body: { processing_state: processingState },
})
}
export function setDuplicate(recordId, isDuplicate) {
return request(`/inbox/${recordId}/duplicate`, {
method: 'PATCH',
body: { is_duplicate: isDuplicate },
})
}
export function sendEmail({ to, subject, body, contentType = 'html', inboxId } = {}) {
return request('/email/send', {
method: 'POST',
body: {
to,
subject,
body,
content_type: contentType,
inbox_id: inboxId,
},
})
}
/** Reply sends a new message with a `Re:` subject — no thread headers. */
export function replyEmail({ recordId, body } = {}) {
return request('/email/reply', {
method: 'POST',
body: { record_id: recordId, body },
})
}
/* ============================================================
Intake gate (backend/inbox_classifier/).
/email/fetch now classifies every message on subject + body and only persists
the job applications, so the mail that never became an inbox row is only
visible through these two calls. Both require the same tags as the rest of the
inbox: INBOX_VIEW to read, INBOX_EDIT to override.
============================================================ */
/**
* The verdict ledger — one row per upstream message id.
*
* `isApplication` is tri-valued: omit for no filter, false for the mail the gate
* dropped, true for the mail it let through. buildUrl drops undefined but keeps
* false, so `isApplication: undefined` sends no param at all — the same
* convention `isread` uses above.
*
* `status` is `classified|low_confidence|error`; anything else 422s server-side.
*/
export function listTriage({ search, top, skip, isApplication, status } = {}) {
return request('/inbox/triage', {
params: {
search,
top,
skip,
is_application: isApplication,
status,
},
})
}
/**
* Overturn one verdict. `recordId` is the triage row's own uuid, NOT the inbox
* message id — a dropped message has no inbox row to point at.
*
* true re-fetches the mail from upstream and runs the normal ingestion path
* (which is why the body was never stored). false moves an already-ingested row
* to processing_state 'rejected'; it never deletes it.
*/
export function overrideTriage(recordId, isApplication) {
return request(`/inbox/triage/${recordId}/override`, {
method: 'PATCH',
body: { is_application: isApplication },
})
}