255 lines
8.9 KiB
JavaScript
255 lines
8.9 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, noSuggestions } = {}) {
|
|
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.
|
|
// `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty
|
|
// suggested_job_post_ids. Omit unless that tab.
|
|
params: {
|
|
search,
|
|
top,
|
|
skip,
|
|
record_id: recordId,
|
|
isread,
|
|
application_status: applicationStatus,
|
|
assigned,
|
|
is_duplicate: isDuplicate,
|
|
no_suggestions: noSuggestions,
|
|
},
|
|
})
|
|
}
|
|
|
|
/** 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 },
|
|
})
|
|
}
|