Is_READ_UPDATE #5
|
|
@ -0,0 +1,228 @@
|
||||||
|
# Read-status sync (`/sync/*`)
|
||||||
|
|
||||||
|
Tracks **which messages got read or unread** — and which were deleted — without
|
||||||
|
re-downloading the mailbox. It sits on Microsoft Graph's **delta query**: Graph
|
||||||
|
hands you a cursor, and every later call with that cursor returns *only* what
|
||||||
|
changed since it was issued.
|
||||||
|
|
||||||
|
Five of the six endpoints share one piece of state: a delta cursor per
|
||||||
|
**(signed-in user + folder)**, persisted to disk so a restart doesn't re-backfill
|
||||||
|
the whole folder. The sixth — the per-message lookup — is deliberately outside
|
||||||
|
that machinery: it reads one id live and touches no cursor.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| ------ | ---- | ------- |
|
||||||
|
| GET | `/sync/read-status` | Run one sync round **now** (synchronous) |
|
||||||
|
| GET | `/sync/read-status/changes` | Replay the last round's **full** result |
|
||||||
|
| GET | `/sync/read-status/message/{id}` | One message's status, by id — cursor-free |
|
||||||
|
| GET | `/sync/read-status/status` | Watcher health + cursor state |
|
||||||
|
| POST | `/sync/read-status/watch` | Start the background poller |
|
||||||
|
| DELETE | `/sync/read-status/watch` | Stop the background poller |
|
||||||
|
|
||||||
|
All require the API bearer token, and act on the **signed-in user's** mailbox —
|
||||||
|
they answer `401` until device-code sign-in completes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /sync/read-status`
|
||||||
|
|
||||||
|
The workhorse. Asks Graph "what changed in this folder since my cursor?", emits
|
||||||
|
the changes, and advances the cursor.
|
||||||
|
|
||||||
|
The **first** call has no cursor, so it backfills the entire folder — an Inbox
|
||||||
|
with 4,700 messages is 47 pages of 100. Every call after that is incremental and
|
||||||
|
usually near-empty.
|
||||||
|
|
||||||
|
| Param | Default | Meaning |
|
||||||
|
| ----- | ------- | ------- |
|
||||||
|
| `folder` | `inbox` | Well-known name (`inbox`, `sentitems`, …) or folder id. Graph delta is **folder-scoped** — there is no all-mail delta |
|
||||||
|
| `since` | – | ISO8601 lower bound, **initial sync only** (`receivedDateTime ge …`). The way to keep a first backfill small |
|
||||||
|
| `reset` | `false` | Discard the saved cursor and start a fresh baseline |
|
||||||
|
| `max_pages` | `10` | Cap on Graph pages (100 msgs each) fetched **per call** |
|
||||||
|
| `limit` | `10` | Cap on messages returned **in this response** |
|
||||||
|
|
||||||
|
`max_pages` and `limit` are independent and easy to confuse:
|
||||||
|
|
||||||
|
- **`max_pages` bounds the work.** Hit the cap and the call returns
|
||||||
|
`complete: false`, having saved its position; the next call resumes exactly
|
||||||
|
where it stopped. No changes are skipped, and no cursor is written until the
|
||||||
|
backfill genuinely finishes.
|
||||||
|
- **`limit` only trims the JSON.** It has no effect on how much is fetched.
|
||||||
|
`count` stays the true total, and the untruncated set is on
|
||||||
|
`/sync/read-status/changes`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"synced_at": "2026-08-07T10:15:00Z",
|
||||||
|
"folder": "inbox",
|
||||||
|
"count": 1000, // changed messages this call actually fetched
|
||||||
|
"removed_count": 0, // deleted / moved out of the folder
|
||||||
|
"initial_sync": true, // this round is part of the first backfill
|
||||||
|
"complete": false, // hit max_pages — call again to continue
|
||||||
|
"pages": 10, // Graph pages fetched by this call
|
||||||
|
"truncated": true, // limit cut the lists below
|
||||||
|
"value": [ { "id": "AAMk…", "isRead": true,
|
||||||
|
"lastModifiedDateTime": "2026-08-07T10:14:52Z",
|
||||||
|
"subject": "Invoice #421" } ],
|
||||||
|
"removed": [ { "id": "AAMk…", "reason": "deleted" } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`value` is sorted newest-modified first before `limit` is applied, so a
|
||||||
|
truncated response shows the most recent changes rather than an arbitrary slice.
|
||||||
|
Only the four `$select` fields above come back — this endpoint is about *status*,
|
||||||
|
not content; use `GET /emails/{id}` for bodies.
|
||||||
|
|
||||||
|
## `GET /sync/read-status/changes`
|
||||||
|
|
||||||
|
Read-only replay of whatever the **last** round produced. No Graph call, cursor
|
||||||
|
untouched, safe to hit repeatedly.
|
||||||
|
|
||||||
|
Two reasons it exists:
|
||||||
|
|
||||||
|
1. It holds the **untruncated** lists — this is how you get the other 990 items
|
||||||
|
when `limit` trimmed the response.
|
||||||
|
2. It's the only way to collect what the **background watcher** found, since the
|
||||||
|
watcher has no caller to return to.
|
||||||
|
|
||||||
|
`404` until some sync has run. One buffer, last-writer-wins: the next round
|
||||||
|
overwrites it, so with the watcher running you must read it faster than
|
||||||
|
`interval` or you will miss rounds.
|
||||||
|
|
||||||
|
## `GET /sync/read-status/message/{message_id}`
|
||||||
|
|
||||||
|
One message, one record — a point lookup rather than a batch:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "id": "AAMk…", "isRead": true,
|
||||||
|
"lastModifiedDateTime": "2026-08-07T10:14:52Z", "subject": "Invoice #421" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Identical shape to an entry in a sync `value` list, so both parse with the same
|
||||||
|
code. What makes it different from the endpoints above:
|
||||||
|
|
||||||
|
- **Cursor-free.** Touches no delta cursor, no cached state, and advances
|
||||||
|
nothing. Call it as often as you like without affecting a sync in progress.
|
||||||
|
- **Live.** Reports the mailbox *now*, straight from Graph — not what the last
|
||||||
|
round happened to capture. That makes it the right tool for re-checking one
|
||||||
|
message ("has this been read yet?") and for confirming a status after the fact.
|
||||||
|
- **Any id.** Works whether or not the message appeared in a sync, and whatever
|
||||||
|
folder it lives in.
|
||||||
|
|
||||||
|
It costs one Graph call per message, so it's a lookup, not a substitute for
|
||||||
|
delta — walking a mailbox with it would be far slower than a single sync round.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "$A" "$B/sync/read-status/message/AAMkAGI2TG93..."
|
||||||
|
```
|
||||||
|
|
||||||
|
URL-encode the id. Ids containing `/`, `+`, or `=` are handled (the route uses a
|
||||||
|
`:path` converter), so an already-encoded `%2F` works too. Unknown or deleted
|
||||||
|
ids surface Graph's own `404 ErrorItemNotFound`.
|
||||||
|
|
||||||
|
## `GET /sync/read-status/status`
|
||||||
|
|
||||||
|
Health check for the whole subsystem.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ----- | ------- |
|
||||||
|
| `watching` / `interval` | Is the poller thread alive, and at what period |
|
||||||
|
| `folder` | Folder the cursor belongs to |
|
||||||
|
| `last_sync_at` | Timestamp of the most recent round |
|
||||||
|
| `last_change_count` / `last_removed_count` | Size of that round |
|
||||||
|
| `has_delta_link` | A real cursor exists ⇒ running incrementally |
|
||||||
|
| `backfill_in_progress` | Paused mid-backfill at the page cap ⇒ more rounds to go |
|
||||||
|
| `last_error` | Last Graph failure from the background thread, else `null` |
|
||||||
|
|
||||||
|
`has_delta_link: false` + `backfill_in_progress: true` is the normal state
|
||||||
|
*during* a long first sync.
|
||||||
|
|
||||||
|
## `POST /sync/read-status/watch`
|
||||||
|
|
||||||
|
Starts a daemon thread that runs the same sync every `interval` seconds and
|
||||||
|
writes each change to stdout.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "interval": 60, "folder": "inbox" } // interval min 10, both optional
|
||||||
|
```
|
||||||
|
|
||||||
|
- Idempotent — a second POST while running just answers
|
||||||
|
`{"message": "Already watching read-status changes"}`.
|
||||||
|
- While a backfill is still incomplete the loop continues immediately instead of
|
||||||
|
sleeping out the interval, so a big first sync finishes in consecutive chunks.
|
||||||
|
- Delivery is `_emit_read_status_changes()`, which prints. **That's the hook
|
||||||
|
point** — replace it to push to Slack, a webhook, or a queue.
|
||||||
|
|
||||||
|
## `DELETE /sync/read-status/watch`
|
||||||
|
|
||||||
|
Signals the thread to stop; `404` if nothing is running. The cursor survives, so
|
||||||
|
restarting the watcher resumes from where it left off rather than re-backfilling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typical first run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export EMAIL_API_TOKEN=...
|
||||||
|
A="Authorization: Bearer $EMAIL_API_TOKEN"
|
||||||
|
B=http://localhost:5000
|
||||||
|
|
||||||
|
curl -X POST -H "$A" $B/auth/start # sign in once (see README)
|
||||||
|
|
||||||
|
# Baseline. Keep calling while "complete": false.
|
||||||
|
curl -H "$A" "$B/sync/read-status?since=2026-08-01T00:00:00Z"
|
||||||
|
|
||||||
|
# From here on, each call returns only what changed.
|
||||||
|
curl -H "$A" "$B/sync/read-status"
|
||||||
|
|
||||||
|
# Or hand it to the background poller and read results out of /changes.
|
||||||
|
curl -X POST -H "$A" -H "Content-Type: application/json" \
|
||||||
|
-d '{"interval":60,"folder":"inbox"}' $B/sync/read-status/watch
|
||||||
|
curl -H "$A" $B/sync/read-status/status
|
||||||
|
curl -H "$A" $B/sync/read-status/changes
|
||||||
|
|
||||||
|
# Re-check one message any time — no cursor involved.
|
||||||
|
curl -H "$A" "$B/sync/read-status/message/AAMkAGI2TG93..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Which endpoint do I want?
|
||||||
|
|
||||||
|
| You want | Use |
|
||||||
|
| -------- | --- |
|
||||||
|
| Everything that changed since last time | `GET /sync/read-status` |
|
||||||
|
| The full list a round produced (or the watcher's) | `GET /sync/read-status/changes` |
|
||||||
|
| The status of **one** message you already have an id for | `GET /sync/read-status/message/{id}` |
|
||||||
|
| Continuous tracking without calling in a loop | `POST /sync/read-status/watch` |
|
||||||
|
| Whether any of the above is healthy | `GET /sync/read-status/status` |
|
||||||
|
|
||||||
|
Rule of thumb: **delta for "what changed", point lookup for "what about this
|
||||||
|
one".** Using the lookup in a loop over a mailbox works but costs one Graph call
|
||||||
|
per message — a single sync round does the same job in pages of 100.
|
||||||
|
|
||||||
|
## How the cursor works
|
||||||
|
|
||||||
|
- A finished round returns Graph's **deltaLink**, saved to
|
||||||
|
`.delta_cache.json` (override with `EMAIL_API_DELTA_CACHE`; in Docker it lives
|
||||||
|
on the `/data` volume beside the token cache). Keyed by user + folder — change
|
||||||
|
either and the cache is ignored rather than misapplied.
|
||||||
|
- A round stopped by `max_pages` has no deltaLink yet, so it saves Graph's
|
||||||
|
**nextLink** instead. That resume position takes priority over any older
|
||||||
|
deltaLink on the following call, which is what makes a capped backfill safe:
|
||||||
|
the cursor never advances past data you haven't received.
|
||||||
|
- Cursors expire. Graph answers `410 Gone`, and the sync automatically falls
|
||||||
|
back to a fresh baseline for that folder.
|
||||||
|
- `reset=true` throws the cursor away deliberately — expect a full backfill, and
|
||||||
|
pass `since` with it unless you want the whole history again.
|
||||||
|
|
||||||
|
## Limits worth knowing
|
||||||
|
|
||||||
|
- **Folder-scoped only.** `/me/messages/delta` is not supported by Graph. Watch
|
||||||
|
another folder by passing `folder=`, but each folder is its own cursor and the
|
||||||
|
disk cache holds one at a time — switching folders forces a re-backfill.
|
||||||
|
- **Polling, not push.** Latency floor is the poll `interval`. True push needs a
|
||||||
|
Graph change-notification subscription (public HTTPS endpoint, validation
|
||||||
|
handshake, ~3-day renewals) — and you'd keep delta anyway as the catch-up path
|
||||||
|
for dropped notifications.
|
||||||
|
- **Single worker.** Cursor, watcher thread, and the `last_changes` buffer are
|
||||||
|
in-memory per process, so this only behaves with one uvicorn worker (which is
|
||||||
|
what the Docker service runs, for the same reason auth needs it).
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Email service — write read-status (`PATCH /sync/read-status/...`)
|
||||||
|
|
||||||
|
Copy everything below the line into any LLM session (or hand it to whoever owns the
|
||||||
|
email microservice) before implementing the write endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are extending the **email microservice** that already exposes the read-status
|
||||||
|
delta and point-lookup APIs documented in `Sync_read.md`. Implement a **write**
|
||||||
|
path that marks a message read (or unread) in the signed-in user's Outlook mailbox
|
||||||
|
via Microsoft Graph. Mirror the existing `/sync/read-status/*` style exactly —
|
||||||
|
same bearer auth, same `:path` id handling, same response shape.
|
||||||
|
|
||||||
|
## Why we need this
|
||||||
|
|
||||||
|
The HR-ATS inbox app learns that a user opened a message before Outlook does.
|
||||||
|
Today that signal dies in our database: we have no Graph write permission and the
|
||||||
|
email service exposes no write endpoint. Without this PATCH, local mark-read and
|
||||||
|
Outlook drift permanently (and a later delta can even revert our flag).
|
||||||
|
|
||||||
|
## Requested contract
|
||||||
|
|
||||||
|
Mirror the existing read endpoints so both parse with one code path:
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /sync/read-status/message/{id}
|
||||||
|
Authorization: Bearer <api token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "isRead": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
**200 response** — identical shape to `GET /sync/read-status/message/{id}`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "AAMk…",
|
||||||
|
"isRead": true,
|
||||||
|
"lastModifiedDateTime": "2026-08-07T10:14:52Z",
|
||||||
|
"subject": "Invoice #421"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same `:path` converter for Graph ids that contain `/`, `+`, or `=`. Same bearer
|
||||||
|
auth as every other `/sync/*` route. Answer `401` until device-code sign-in
|
||||||
|
completes.
|
||||||
|
|
||||||
|
## Required behaviour
|
||||||
|
|
||||||
|
- **Idempotent.** Re-PATCHing `isRead: true` when already true is a no-op `200`
|
||||||
|
with the current record.
|
||||||
|
- **Must not advance or disturb the delta cursor.** This is a point write, not a
|
||||||
|
sync round. Cursor, watcher, and `/changes` buffer stay untouched.
|
||||||
|
- **404 `ErrorItemNotFound`** for unknown or deleted ids (same as the GET).
|
||||||
|
- **403 surfaced distinctly** if the Graph scope is missing, so callers can tell
|
||||||
|
"not permitted" from "not found".
|
||||||
|
|
||||||
|
## Graph scope prerequisite
|
||||||
|
|
||||||
|
Needs `Mail.ReadWrite`. The service currently signs in read-only. Treat upgrading
|
||||||
|
the consent / device-code scopes as an explicit product decision before shipping
|
||||||
|
the route — not an implementation footnote.
|
||||||
|
|
||||||
|
## Optional batch form
|
||||||
|
|
||||||
|
For bulk reconcile without N round-trips:
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /sync/read-status/messages
|
||||||
|
{ "ids": ["AAMk…", "AAMk…"], "isRead": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Return a list of the same per-message records (or per-id errors). Nice-to-have;
|
||||||
|
the single-id PATCH is the hard requirement.
|
||||||
|
|
||||||
|
## What the caller will do with it
|
||||||
|
|
||||||
|
HR-ATS will enqueue one Taskiq task per human mark-read, retried via existing
|
||||||
|
smart-retry middleware. Expected volume is low (opens, not sweeps). After this
|
||||||
|
lands we will stop treating local-only mark-read as a known divergence.
|
||||||
|
|
||||||
|
## Out of scope for this request
|
||||||
|
|
||||||
|
- Changing the delta `/sync/read-status` contract
|
||||||
|
- Push / Graph change-notification subscriptions
|
||||||
|
- Writing any field other than `isRead`
|
||||||
|
|
@ -4,6 +4,10 @@ DB_HOST=
|
||||||
DB_PORT=
|
DB_PORT=
|
||||||
DB_NAME=
|
DB_NAME=
|
||||||
EMAIL_URL=
|
EMAIL_URL=
|
||||||
|
EMAIL_API_TOKEN=
|
||||||
|
EMAIL_SYNC_FOLDER=inbox
|
||||||
|
EMAIL_SYNC_SINCE=
|
||||||
|
EMAIL_SYNC_CRON=* * * * *
|
||||||
|
|
||||||
JWT_SECRET_KEY=
|
JWT_SECRET_KEY=
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter,Depends, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
|
from inbox.enums import Candidate_application_Status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from inbox.views import Email
|
from inbox.views import Email
|
||||||
from users.permissions import PermissionTag, require_permission
|
from users.permissions import PermissionTag, require_permission
|
||||||
|
|
@ -14,13 +15,13 @@ router = APIRouter()
|
||||||
async def fetch_email(
|
async def fetch_email(
|
||||||
top:int=Query(100),
|
top:int=Query(100),
|
||||||
skip:int=Query(0,ge=0),
|
skip:int=Query(0,ge=0),
|
||||||
token=Query(...),
|
token: str | None = Query(None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=401,detail="Unauthorized")
|
|
||||||
service=Email(session=session,token=token)
|
service=Email(session=session,token=token)
|
||||||
|
if not service.token:
|
||||||
|
raise HTTPException(status_code=401,detail="Unauthorized")
|
||||||
data=await service.service_email(top,skip)
|
data=await service.service_email(top,skip)
|
||||||
value=data.get("value")
|
value=data.get("value")
|
||||||
items_lst=[]
|
items_lst=[]
|
||||||
|
|
@ -78,3 +79,70 @@ async def rematch_inbox(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inbox/{record_id}/read")
|
||||||
|
async def mark_inbox_read(
|
||||||
|
record_id: str,
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Email(session=session)
|
||||||
|
data=await service.mark_read(record_id)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inbox/{record_id}/read-status")
|
||||||
|
async def get_inbox_read_status(
|
||||||
|
record_id: str,
|
||||||
|
token: str | None = Query(None),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Email(session=session,token=token)
|
||||||
|
data=await service.refresh_read_status(record_id)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/inbox/all-applications")
|
||||||
|
async def get_all_applications(
|
||||||
|
record_id: str | None = Query(None),
|
||||||
|
application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED),
|
||||||
|
isread: bool = Query(default=True),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
top: int | None = Query(None),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=Email(session=session)
|
||||||
|
|
||||||
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
|
items=await service.get_all_applications(top, skip, search, application_status=application_status)
|
||||||
|
total=await service.count_inbox_messages(search, application_status=application_status)
|
||||||
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
|
if isread==False:
|
||||||
|
items=await service.get_all_applications(top, skip, search, isread=False)
|
||||||
|
total=await service.count_inbox_messages(search, isread=False)
|
||||||
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
|
if record_id:
|
||||||
|
item=await service.get_application_by_id(record_id)
|
||||||
|
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
||||||
|
|
||||||
|
items=await service.get_all_applications(top,skip,search)
|
||||||
|
total=await service.count_inbox_messages(search)
|
||||||
|
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
# (str, Enum), like EnumRoles and PermissionTag: a bare Enum member is not JSON
|
||||||
|
# serializable, so JSONResponse raises the moment a serializer emits this field.
|
||||||
|
class Candidate_application_Status(str, Enum):
|
||||||
|
PROCESS="PROCESS"
|
||||||
|
PENDING="PENDING"
|
||||||
|
APPROVED="APPROVED"
|
||||||
|
REJECTED="REJECTED"
|
||||||
|
ONHOLD="ONHOLD"
|
||||||
|
CLOSED="CLOSED"
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import Column, DateTime, func, or_
|
from inbox.enums import Candidate_application_Status
|
||||||
|
from sqlalchemy import Column, DateTime, func, or_, update
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select, true
|
||||||
|
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
full_email_response: dict[str, Any] | None = Field(
|
full_email_response: dict[str, Any] | None = Field(
|
||||||
default=None, sa_column=Column(JSONB)
|
default=None, sa_column=Column(JSONB)
|
||||||
)
|
)
|
||||||
|
application_status: Candidate_application_Status = Field(default=Candidate_application_Status.CLOSED)
|
||||||
message_subject: str
|
message_subject: str
|
||||||
message_body: str
|
message_body: str
|
||||||
message_sent_time: str
|
message_sent_time: str
|
||||||
|
|
@ -82,6 +84,17 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
return email_data.get("bodyPreview") or ""
|
return email_data.get("bodyPreview") or ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
async def get_all_applications(cls,session:AsyncSession,message_id:uuid.UUID|int|None=None):
|
||||||
|
try:
|
||||||
|
qry=select(cls.message_id,cls.full_email_response,cls.message_subject,cls.message_from,cls.message_to,cls.message_sent_time,cls.message_read,cls.attachment)
|
||||||
|
if message_id:
|
||||||
|
qry=qry.where(cls.message_id==message_id)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
@classmethod
|
||||||
async def set_match_result(
|
async def set_match_result(
|
||||||
cls,
|
cls,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -183,15 +196,23 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_inbox_messages(
|
async def get_inbox_messages(
|
||||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None
|
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED
|
||||||
):
|
):
|
||||||
statement = select(cls).order_by(cls.message_received_time.desc())
|
statement = select(cls).order_by(cls.message_received_time.desc())
|
||||||
if search:
|
if search:
|
||||||
statement = statement.where(cls._search_filter(search))
|
statement = statement.where(cls._search_filter(search))
|
||||||
|
|
||||||
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
|
statement = statement.where(cls.application_status==application_status)
|
||||||
|
|
||||||
if skip:
|
if skip:
|
||||||
statement = statement.offset(skip)
|
statement = statement.offset(skip)
|
||||||
|
|
||||||
if top is not None:
|
if top is not None:
|
||||||
statement = statement.limit(top)
|
statement = statement.limit(top)
|
||||||
|
|
||||||
|
if isread==False:
|
||||||
|
statement = statement.where(cls.message_read==False)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
@ -205,9 +226,46 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_inbox_messages(cls, session: AsyncSession, search: str | None):
|
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED):
|
||||||
statement = select(func.count()).select_from(cls)
|
statement = select(func.count()).select_from(cls)
|
||||||
if search:
|
if search:
|
||||||
statement = statement.where(cls._search_filter(search))
|
statement = statement.where(cls._search_filter(search))
|
||||||
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
|
statement = statement.where(cls.application_status==application_status)
|
||||||
|
if isread==False:
|
||||||
|
statement = statement.where(cls.message_read==False)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def apply_read_status(cls, session: AsyncSession, changes) -> int:
|
||||||
|
"""[{id, isRead, ...}] -> bulk UPDATE message_read. Returns rows touched.
|
||||||
|
|
||||||
|
read is a ONE-WAY LATCH: only false -> true is applied, never the reverse.
|
||||||
|
mark_message_read writes the local column only — nothing pushes the state
|
||||||
|
back to Outlook — so upstream keeps reporting isRead=false and the
|
||||||
|
every-minute sync_read_status sweep would otherwise revert a mail the user
|
||||||
|
just opened. Cost of the latch: un-reading a mail in Outlook no longer
|
||||||
|
propagates here.
|
||||||
|
"""
|
||||||
|
if not changes:
|
||||||
|
return 0
|
||||||
|
read_ids=[c.get("id") for c in changes if c.get("id") and c.get("isRead")]
|
||||||
|
if not read_ids:
|
||||||
|
return 0
|
||||||
|
result=await session.execute(
|
||||||
|
update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def mark_message_read(cls, session: AsyncSession, record_id):
|
||||||
|
row=await cls.get_inbox_message_by_id(session,record_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.message_read=True
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,89 @@
|
||||||
"""Inbox helpers — attachment loading and resume text extraction."""
|
"""Inbox helpers — attachment loading, resume text extraction, read-status sync."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import Inbox_Messages
|
||||||
from job.candidate.views import FileRead
|
from job.candidate.views import FileRead
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
EMAIL_URL=os.getenv("EMAIL_URL")
|
||||||
|
EMAIL_API_TOKEN=os.getenv("EMAIL_API_TOKEN")
|
||||||
|
|
||||||
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
|
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_read_status_delta(folder, since=None, limit=1000, max_pages=10, token=None):
|
||||||
|
"""GET /sync/read-status -> the raw round dict."""
|
||||||
|
if not EMAIL_URL:
|
||||||
|
raise RuntimeError("EMAIL_URL must be set")
|
||||||
|
auth_token=token or EMAIL_API_TOKEN
|
||||||
|
if not auth_token:
|
||||||
|
raise RuntimeError("EMAIL_API_TOKEN must be set")
|
||||||
|
params={"folder":folder,"limit":limit,"max_pages":max_pages}
|
||||||
|
if since:
|
||||||
|
params["since"]=since
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response=await client.get(
|
||||||
|
f"{EMAIL_URL.rstrip('/')}/sync/read-status",
|
||||||
|
params=params,
|
||||||
|
headers={"Authorization":f"Bearer {auth_token}"},
|
||||||
|
)
|
||||||
|
if response.status_code>=400:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_message_read_status(message_id, token=None):
|
||||||
|
"""GET /sync/read-status/message/{id} -> record dict, or None on 404."""
|
||||||
|
if not EMAIL_URL:
|
||||||
|
raise RuntimeError("EMAIL_URL must be set")
|
||||||
|
auth_token=token or EMAIL_API_TOKEN
|
||||||
|
if not auth_token:
|
||||||
|
raise RuntimeError("EMAIL_API_TOKEN must be set")
|
||||||
|
encoded_id=quote(str(message_id),safe="")
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response=await client.get(
|
||||||
|
f"{EMAIL_URL.rstrip('/')}/sync/read-status/message/{encoded_id}",
|
||||||
|
headers={"Authorization":f"Bearer {auth_token}"},
|
||||||
|
)
|
||||||
|
if response.status_code==404:
|
||||||
|
return None
|
||||||
|
if response.status_code>=400:
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
response.text,
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
def resolve_attachment_path(path_str:str) -> Path:
|
def resolve_attachment_path(path_str:str) -> Path:
|
||||||
"""Prefer stored path; fall back to basename under decoded_attachments."""
|
"""Prefer stored path; fall back to basename under decoded_attachments.
|
||||||
path=Path(path_str.strip())
|
|
||||||
|
Stored paths may be Windows absolutes written by the host API. The Taskiq
|
||||||
|
worker runs in Linux, where ``Path(r"D:\\...\\file.pdf").name`` is the
|
||||||
|
whole string (backslash is not a separator), so normalize separators before
|
||||||
|
taking the basename for the mounted attachments dir.
|
||||||
|
"""
|
||||||
|
raw=path_str.strip()
|
||||||
|
path=Path(raw)
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return path
|
return path
|
||||||
fallback=_ATTACHMENTS_DIR/path.name
|
basename=Path(raw.replace("\\","/")).name
|
||||||
|
fallback=_ATTACHMENTS_DIR/basename
|
||||||
if fallback.is_file():
|
if fallback.is_file():
|
||||||
return fallback
|
return fallback
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,19 @@ from pathlib import Path
|
||||||
|
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import Inbox_Messages
|
||||||
|
|
||||||
|
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
|
||||||
|
_RESUME_STATUS = {
|
||||||
|
"processing": "Parsing",
|
||||||
|
"matched": "Parsed",
|
||||||
|
"no_text": "Failed",
|
||||||
|
"failed": "Failed",
|
||||||
|
"dlq": "Failed",
|
||||||
|
"skipped": "Pending",
|
||||||
|
}
|
||||||
|
|
||||||
def serialize_message(message: Inbox_Messages) -> dict:
|
|
||||||
"""inbox_messages row -> the shape the #inbox Email tab renders."""
|
def _sender_name(message: Inbox_Messages) -> str:
|
||||||
|
"""Graph's display name when the payload carries one, else the raw address."""
|
||||||
sender_name = message.message_from
|
sender_name = message.message_from
|
||||||
full = message.full_email_response
|
full = message.full_email_response
|
||||||
if isinstance(full, dict):
|
if isinstance(full, dict):
|
||||||
|
|
@ -15,16 +25,26 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
||||||
name = email_address.get("name")
|
name = email_address.get("name")
|
||||||
if name:
|
if name:
|
||||||
sender_name = name
|
sender_name = name
|
||||||
|
return sender_name
|
||||||
|
|
||||||
attachment_name = None
|
|
||||||
|
def _attachment_name(message: Inbox_Messages) -> str | None:
|
||||||
if message.file_name:
|
if message.file_name:
|
||||||
attachment_name = message.file_name.split(",")[0].strip() or None
|
return message.file_name.split(",")[0].strip() or None
|
||||||
elif message.file_path:
|
if message.file_path:
|
||||||
attachment_name = Path(message.file_path.split(",")[0].strip()).name or None
|
return Path(message.file_path.split(",")[0].strip()).name or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_message(message: Inbox_Messages) -> dict:
|
||||||
|
"""inbox_messages row -> the shape the #inbox Email tab renders."""
|
||||||
|
sender_name = _sender_name(message)
|
||||||
|
attachment_name = _attachment_name(message)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(message.id),
|
"id": str(message.id),
|
||||||
"message_id": str(message.message_id) if message.message_id else None,
|
"message_id": str(message.message_id) if message.message_id else None,
|
||||||
|
"full_email_response": message.full_email_response,
|
||||||
"sender_name": sender_name,
|
"sender_name": sender_name,
|
||||||
"fromEmail": message.message_from,
|
"fromEmail": message.message_from,
|
||||||
"subject": message.message_subject,
|
"subject": message.message_subject,
|
||||||
|
|
@ -47,3 +67,37 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
||||||
"match_error": message.match_error,
|
"match_error": message.match_error,
|
||||||
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_application(message: Inbox_Messages) -> dict:
|
||||||
|
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
|
||||||
|
|
||||||
|
`position` is the mail subject and `source` is the To address, which is where
|
||||||
|
the board tag (Rozee, Mustakbil, Employee Referral, ...) lands.
|
||||||
|
|
||||||
|
The tab also wants ats_score, phone, experience, recruiter, duplicate and a
|
||||||
|
processing state beyond read/unread. inbox_messages has no columns for any of
|
||||||
|
those, so they come back null instead of invented — see the note in
|
||||||
|
inbox/file_decoder.py. `processing` is derived from message_read alone, so it
|
||||||
|
is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": str(message.id),
|
||||||
|
"name": _sender_name(message),
|
||||||
|
"email": message.message_from,
|
||||||
|
"position": message.message_subject,
|
||||||
|
"source": message.message_to,
|
||||||
|
"received": message.message_received_time,
|
||||||
|
"unread": not message.message_read,
|
||||||
|
"processing": "Read" if message.message_read else "Unread",
|
||||||
|
"application_status": message.application_status,
|
||||||
|
"resume_status": _RESUME_STATUS.get(message.match_status, "Pending"),
|
||||||
|
"attachment": _attachment_name(message),
|
||||||
|
"has_attachment": message.attachment,
|
||||||
|
"resume_text": message.resume_text,
|
||||||
|
"ats_score": None,
|
||||||
|
"phone": None,
|
||||||
|
"experience": None,
|
||||||
|
"recruiter": None,
|
||||||
|
"duplicate": None,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Inbox Taskiq tasks — Outlook read-status delta sweep."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis.asyncio as redis
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from db_setup import session_scope
|
||||||
|
from inbox.models import Inbox_Messages
|
||||||
|
from inbox.plugins import fetch_read_status_delta
|
||||||
|
from taskiq_management.broker_setup import broker
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logger=logging.getLogger("inbox.sync")
|
||||||
|
|
||||||
|
EMAIL_SYNC_FOLDER=os.getenv("EMAIL_SYNC_FOLDER","inbox")
|
||||||
|
EMAIL_SYNC_SINCE=os.getenv("EMAIL_SYNC_SINCE") or None
|
||||||
|
EMAIL_SYNC_CRON=os.getenv("EMAIL_SYNC_CRON","* * * * *")
|
||||||
|
REDIS_URL=os.getenv("REDIS_URL","redis://localhost:6379/0")
|
||||||
|
|
||||||
|
_LOCK_KEY="inbox:sync_read_status:lock"
|
||||||
|
_LOCK_TTL=300
|
||||||
|
_MAX_ROUNDS=10
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task(task_name="inbox.sync_read_status",schedule=[{"cron":EMAIL_SYNC_CRON}])
|
||||||
|
async def sync_read_status() -> dict:
|
||||||
|
client=redis.from_url(REDIS_URL,decode_responses=True)
|
||||||
|
try:
|
||||||
|
acquired=await client.set(_LOCK_KEY,"1",nx=True,ex=_LOCK_TTL)
|
||||||
|
if not acquired:
|
||||||
|
logger.info("sync_read_status skipped — lock held")
|
||||||
|
return {"skipped":"locked"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
rounds=0
|
||||||
|
applied_total=0
|
||||||
|
removed_total=0
|
||||||
|
since=EMAIL_SYNC_SINCE
|
||||||
|
|
||||||
|
while rounds<_MAX_ROUNDS:
|
||||||
|
rounds+=1
|
||||||
|
try:
|
||||||
|
round_data=await fetch_read_status_delta(
|
||||||
|
EMAIL_SYNC_FOLDER,
|
||||||
|
since=since if rounds==1 else None,
|
||||||
|
limit=1000,
|
||||||
|
max_pages=10,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code==401:
|
||||||
|
logger.warning("sync_read_status 401 — device-code sign-in required")
|
||||||
|
return {"error":"unauthorized","status_code":401}
|
||||||
|
raise
|
||||||
|
|
||||||
|
changes=round_data.get("value") or []
|
||||||
|
removed=round_data.get("removed") or []
|
||||||
|
removed_total+=len(removed)
|
||||||
|
if removed:
|
||||||
|
logger.info("sync_read_status removed=%s",len(removed))
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
applied=await Inbox_Messages.apply_read_status(session,changes)
|
||||||
|
applied_total+=applied
|
||||||
|
|
||||||
|
if round_data.get("complete",True):
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rounds":rounds,
|
||||||
|
"applied":applied_total,
|
||||||
|
"removed":removed_total,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await client.delete(_LOCK_KEY)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import logging
|
import logging
|
||||||
import httpx,os
|
import httpx,os
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from inbox.enums import Candidate_application_Status
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import Inbox_Messages
|
||||||
from inbox.file_decoder import decode_attachment
|
from inbox.file_decoder import decode_attachment
|
||||||
from inbox.serializers import serialize_message
|
from inbox.serializers import serialize_application, serialize_message
|
||||||
from inbox.plugins import load_message_files
|
from inbox.plugins import (
|
||||||
|
EMAIL_API_TOKEN,
|
||||||
|
fetch_message_read_status,
|
||||||
|
load_message_files,
|
||||||
|
)
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -17,9 +22,19 @@ class Email:
|
||||||
def __init__(self,session:AsyncSession,token=None):
|
def __init__(self,session:AsyncSession,token=None):
|
||||||
self.session=session
|
self.session=session
|
||||||
self.get_url=os.getenv("EMAIL_URL")
|
self.get_url=os.getenv("EMAIL_URL")
|
||||||
self.token=token
|
self.token=token or EMAIL_API_TOKEN
|
||||||
self.pending_match_ids:list[str]=[]
|
self.pending_match_ids:list[str]=[]
|
||||||
|
|
||||||
|
# async def get_all_applications(self,app_id=None):
|
||||||
|
# try:
|
||||||
|
# if app_id:
|
||||||
|
# application_lst=await Inbox_Messages.get_all_applications(self.session,message_id=app_id)
|
||||||
|
# else:
|
||||||
|
# application_lst=await Inbox_Messages.get_all_applications(self.session)
|
||||||
|
# return application_lst
|
||||||
|
# except Exception as e:
|
||||||
|
# raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
async def service_email(self,top,skip):
|
async def service_email(self,top,skip):
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
|
|
@ -77,6 +92,21 @@ class Email:
|
||||||
item["files"]=files
|
item["files"]=files
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
|
||||||
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status)
|
||||||
|
elif isread==False:
|
||||||
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread)
|
||||||
|
else:
|
||||||
|
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search)
|
||||||
|
return [serialize_application(m) for m in messages]
|
||||||
|
|
||||||
|
async def get_application_by_id(self,record_id):
|
||||||
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404,detail="Application not found")
|
||||||
|
return serialize_application(message)
|
||||||
|
|
||||||
async def queue_rematch(self,record_id):
|
async def queue_rematch(self,record_id):
|
||||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
if not message:
|
if not message:
|
||||||
|
|
@ -98,5 +128,34 @@ class Email:
|
||||||
task_ids.append(task.task_id)
|
task_ids.append(task.task_id)
|
||||||
return task_ids
|
return task_ids
|
||||||
|
|
||||||
async def count_inbox_messages(self,search=None):
|
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
|
||||||
return await Inbox_Messages.count_inbox_messages(self.session,search)
|
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
|
||||||
|
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status)
|
||||||
|
elif isread==False:
|
||||||
|
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False)
|
||||||
|
else:
|
||||||
|
return await Inbox_Messages.count_inbox_messages(self.session,search)
|
||||||
|
|
||||||
|
async def mark_read(self,record_id):
|
||||||
|
message=await Inbox_Messages.mark_message_read(self.session,record_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
return serialize_message(message)
|
||||||
|
|
||||||
|
async def refresh_read_status(self,record_id):
|
||||||
|
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
if not message.message_id:
|
||||||
|
raise HTTPException(status_code=400,detail="Message has no upstream id")
|
||||||
|
try:
|
||||||
|
status=await fetch_message_read_status(message.message_id,token=self.token)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise HTTPException(status_code=e.response.status_code,detail=e.response.text)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
if status is None:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found upstream")
|
||||||
|
await Inbox_Messages.apply_read_status(self.session,[status])
|
||||||
|
refreshed=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||||
|
return serialize_message(refreshed)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from fastapi import APIRouter,Depends
|
from fastapi import APIRouter,Depends,Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from db_setup import get_session
|
from db_setup import get_session
|
||||||
|
|
@ -49,6 +49,22 @@ async def cv_upload(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/candidate/inbox-match")
|
||||||
|
async def candidate_inbox_match(
|
||||||
|
inbox_message_id: str = Query(...),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=FileRead(session=session)
|
||||||
|
data=await service.match_inbox_cv(inbox_message_id)
|
||||||
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/job/post-job")
|
@router.post("/job/post-job")
|
||||||
async def post_job(
|
async def post_job(
|
||||||
payload: JobPostCreate,
|
payload: JobPostCreate,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import os,logging,io
|
import os,logging,io
|
||||||
|
from datetime import datetime,timezone
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from inbox.models import Inbox_Messages
|
||||||
from job.candidate.plugins import normalize_spaced_text
|
from job.candidate.plugins import normalize_spaced_text
|
||||||
|
|
||||||
class FileRead:
|
class FileRead:
|
||||||
|
|
@ -26,7 +28,41 @@ class FileRead:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
async def match_inbox_cv(self,inbox_message_id):
|
||||||
|
from inbox.plugins import resolve_attachment_path
|
||||||
|
from inbox.tasks import match_inbox_message
|
||||||
|
|
||||||
|
row=await Inbox_Messages.get_inbox_message_by_id(self.session,inbox_message_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,detail="Message not found")
|
||||||
|
if not row.attachment or not row.file_path:
|
||||||
|
raise HTTPException(status_code=400,detail="your file isnt in the system")
|
||||||
|
|
||||||
|
found=None
|
||||||
|
for path_str in (p.strip() for p in row.file_path.split(",") if p.strip()):
|
||||||
|
path=resolve_attachment_path(path_str)
|
||||||
|
if path.is_file():
|
||||||
|
found=path
|
||||||
|
break
|
||||||
|
if found is None:
|
||||||
|
raise HTTPException(status_code=400,detail="your file isnt in the system")
|
||||||
|
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat()
|
||||||
|
task=await match_inbox_message.kicker().with_labels(
|
||||||
|
created_at=created_at,
|
||||||
|
correlation_id=str(row.id),
|
||||||
|
queue="inbox",
|
||||||
|
).kiq(str(row.id),force=True)
|
||||||
|
|
||||||
|
file_name=(row.file_name or "").split(",")[0].strip() or found.name
|
||||||
|
return {
|
||||||
|
"queued":True,
|
||||||
|
"inbox_message_id":str(row.id),
|
||||||
|
"file_name":file_name,
|
||||||
|
"task_id":task.task_id,
|
||||||
|
}
|
||||||
# async def get_intention(self,input):
|
# async def get_intention(self,input):
|
||||||
# try:
|
# try:
|
||||||
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
|
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
|
||||||
# get_file=
|
# get_file=
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Taskiq broker — Redis Streams + smart retry + DLQ.
|
"""Taskiq broker — Redis Streams + smart retry + DLQ.
|
||||||
|
|
||||||
Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks taskiq_management.tasks
|
Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks taskiq_management.tasks
|
||||||
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler
|
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from taskiq import TaskiqScheduler
|
from taskiq import TaskiqScheduler
|
||||||
from taskiq.middlewares import SmartRetryMiddleware
|
from taskiq.middlewares import SmartRetryMiddleware
|
||||||
|
from taskiq.schedule_sources import LabelScheduleSource
|
||||||
from taskiq_redis import (
|
from taskiq_redis import (
|
||||||
ListRedisScheduleSource,
|
ListRedisScheduleSource,
|
||||||
RedisAsyncResultBackend,
|
RedisAsyncResultBackend,
|
||||||
|
|
@ -52,4 +53,7 @@ broker=(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler=TaskiqScheduler(broker=broker,sources=[schedule_source])
|
scheduler=TaskiqScheduler(
|
||||||
|
broker=broker,
|
||||||
|
sources=[schedule_source,LabelScheduleSource(broker)],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ services:
|
||||||
"worker",
|
"worker",
|
||||||
"taskiq_management.broker_setup:broker",
|
"taskiq_management.broker_setup:broker",
|
||||||
"inbox.tasks",
|
"inbox.tasks",
|
||||||
|
"inbox.sync_tasks",
|
||||||
"taskiq_management.tasks",
|
"taskiq_management.tasks",
|
||||||
"--workers",
|
"--workers",
|
||||||
"1",
|
"1",
|
||||||
|
|
@ -48,7 +49,7 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
container_name: hrms-taskiq-scheduler
|
container_name: hrms-taskiq-scheduler
|
||||||
command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler"]
|
command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks"]
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,41 @@ export function listMessages() {
|
||||||
return request('/inbox/fetch')
|
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.
|
||||||
|
*/
|
||||||
|
export function listApplications({ search, top, skip, recordId, isread, applicationStatus } = {}) {
|
||||||
|
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.
|
||||||
|
params: { search, top, skip, record_id: recordId, isread, application_status: applicationStatus },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } })
|
||||||
|
}
|
||||||
|
|
||||||
/** Triggers the Graph proxy to pull new mail and persist it. */
|
/** Triggers the Graph proxy to pull new mail and persist it. */
|
||||||
export function syncMailbox({ token, top, skip } = {}) {
|
export function syncMailbox({ token, top, skip } = {}) {
|
||||||
return request('/email/fetch', { params: { token, top, skip } })
|
return request('/email/fetch', { params: { token, top, skip } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marks one persisted inbox row read (local DB only). */
|
||||||
|
export function markRead(recordId) {
|
||||||
|
return request(`/inbox/${recordId}/read`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export const qk = {
|
||||||
mailbox: {
|
mailbox: {
|
||||||
all: () => ['mailbox'],
|
all: () => ['mailbox'],
|
||||||
messages: () => ['mailbox', 'messages'],
|
messages: () => ['mailbox', 'messages'],
|
||||||
|
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||||
|
message: (id) => ['mailbox', 'message', id],
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- seed-backed buckets ---
|
// --- seed-backed buckets ---
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
import { Tabs } from '../ui/Tabs'
|
import { Tabs } from '../ui/Tabs'
|
||||||
|
|
@ -23,30 +23,60 @@ import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as inboxApi from '../api/inbox'
|
import * as inboxApi from '../api/inbox'
|
||||||
import {
|
import {
|
||||||
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
|
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
|
||||||
initials as initialsOf, int, locations, pick, relTime, skillsPool, TODAY,
|
initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta,
|
||||||
|
TODAY,
|
||||||
} from '../data/seed'
|
} from '../data/seed'
|
||||||
|
|
||||||
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
|
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side filters for the tabs that /inbox/all-applications can narrow.
|
||||||
|
* Unfiltered tabs (and countsQuery) pass `{}` so the backend defaults apply —
|
||||||
|
* isread=true and application_status=CLOSED both mean "no filter".
|
||||||
|
*/
|
||||||
|
const TAB_FILTERS = {
|
||||||
|
Unread: { isread: false },
|
||||||
|
Processed: { applicationStatus: 'PROCESS' },
|
||||||
|
Rejected: { applicationStatus: 'REJECTED' },
|
||||||
|
}
|
||||||
|
|
||||||
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
|
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
|
||||||
const NOW = new Date('2026-07-09T20:00')
|
const NOW = new Date('2026-07-09T20:00')
|
||||||
|
|
||||||
function resumeText(i) {
|
/**
|
||||||
return `${i.name.toUpperCase()}
|
* The seed candidate record importEmail() writes needs a number. The agent
|
||||||
${i.email} · ${i.phone}
|
* returns a verdict, not a score, so there is nothing on the wire to use —
|
||||||
${'—'.repeat(30)}
|
* named here so the fabricated value is visible at its point of use instead of
|
||||||
PROFESSIONAL SUMMARY
|
* arriving disguised as a server field on every message.
|
||||||
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
|
*/
|
||||||
|
const SEED_ATS_SCORE = 70
|
||||||
|
|
||||||
EXPERIENCE
|
/**
|
||||||
• ${pick(companies)} — Senior role (2021–Present)
|
* message_received_time / message_sent_time are plain string columns
|
||||||
• ${pick(companies)} — Associate (2018–2021)
|
* (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields
|
||||||
|
* an Invalid Date that every fmt* helper renders as the literal "Invalid Date",
|
||||||
|
* so return null instead and let the call sites decide what to show.
|
||||||
|
*/
|
||||||
|
function parseDate(value) {
|
||||||
|
if (!value) return null
|
||||||
|
const d = new Date(value)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
EDUCATION
|
/**
|
||||||
• Bachelor's Degree, Computer Science
|
* `source` arrives as the raw To address, because that is where the board tag
|
||||||
|
* lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
|
||||||
SKILLS
|
* everything but letters from both sides so "Employee Referral" still matches
|
||||||
• ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
|
* "employee-referral@", and keep the brand colour SourceChip paints from.
|
||||||
|
* Nothing matches -> show the first recipient verbatim rather than guess.
|
||||||
|
*/
|
||||||
|
function sourceFrom(messageTo) {
|
||||||
|
const raw = (messageTo || '').trim()
|
||||||
|
if (!raw) return { source: 'Unknown', sourceMeta: null }
|
||||||
|
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
|
||||||
|
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
|
||||||
|
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
|
||||||
|
return { source: raw.split(',')[0].trim(), sourceMeta: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceChip({ item }) {
|
function SourceChip({ item }) {
|
||||||
|
|
@ -60,10 +90,155 @@ function SourceChip({ item }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graph delivers the body as text/html, so rendering it verbatim as text — which
|
||||||
|
* is what keeps it XSS-safe — prints the raw markup at the user.
|
||||||
|
*
|
||||||
|
* DOMParser builds a DETACHED document: it is never adopted into the live DOM, so
|
||||||
|
* scripts do not run and <img onerror> never fires. Reading textContent off it is
|
||||||
|
* therefore both safe and readable, and needs no dangerouslySetInnerHTML.
|
||||||
|
*/
|
||||||
|
function htmlToText(value) {
|
||||||
|
const raw = (value || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (!/<[a-z!/]/i.test(raw)) return raw // already plain text
|
||||||
|
// textContent ignores block boundaries, so <p>a</p><p>b</p> would collapse to
|
||||||
|
// "ab". Turn breaks and closing block tags into newlines BEFORE parsing.
|
||||||
|
const withBreaks = raw
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
|
||||||
|
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
|
||||||
|
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
|
||||||
|
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
|
||||||
|
const RESUME_STATUS = {
|
||||||
|
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||||||
|
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /inbox/fetch?record_id=<pk> -> the detail behind one application row.
|
||||||
|
*
|
||||||
|
* Returns serialize_message, a different shape from serialize_application, so it
|
||||||
|
* is remapped onto the row shape here and OVERLAID on the list row rather than
|
||||||
|
* replacing it: serialize_message carries the body and the real decoded
|
||||||
|
* attachments, but omits resume_text, so the list row keeps supplying that.
|
||||||
|
* suggested_job_post_ids is dropped, same as everywhere else on this page.
|
||||||
|
*/
|
||||||
|
async function fetchMessageDetail(recordId) {
|
||||||
|
const res = await inboxApi.getMessage(recordId)
|
||||||
|
const row = res?.data
|
||||||
|
if (!row) return null
|
||||||
|
const name = row.sender_name || row.fromEmail || 'Unknown'
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name,
|
||||||
|
initials: initialsOf(name),
|
||||||
|
color: avatarColor(name),
|
||||||
|
email: row.fromEmail || '',
|
||||||
|
position: row.subject || '(no subject)',
|
||||||
|
...sourceFrom(row.message_to),
|
||||||
|
received: parseDate(row.when) ?? parseDate(row.message_sent_time),
|
||||||
|
unread: Boolean(row.unread),
|
||||||
|
processing: row.unread ? 'Unread' : 'Read',
|
||||||
|
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
||||||
|
attachment: row.attachment_name,
|
||||||
|
hasAttachment: Boolean(row.attachment),
|
||||||
|
body: htmlToText(row.body),
|
||||||
|
cc: row.message_cc || '',
|
||||||
|
bcc: row.message_bcc || '',
|
||||||
|
sentAt: parseDate(row.message_sent_time),
|
||||||
|
files: Array.isArray(row.files) ? row.files : [],
|
||||||
|
matchStatus: row.match_status || null,
|
||||||
|
matchSummary: row.match_summary || '',
|
||||||
|
matchReasoning: row.match_reasoning || '',
|
||||||
|
matchError: row.match_error || '',
|
||||||
|
matchedAt: parseDate(row.matched_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /inbox/all-applications -> the shape the application tabs render.
|
||||||
|
*
|
||||||
|
* READ-ONLY: inbox_messages has no columns for duplicates, recruiter, phone,
|
||||||
|
* experience or an ATS score, so those arrive null and every mutating action
|
||||||
|
* on these tabs is disabled until the endpoints exist. `processing` is derived
|
||||||
|
* from message_read alone (Read/Unread). Processed / Rejected tabs filter on
|
||||||
|
* `application_status` (PROCESS / REJECTED); Imported / Duplicates stay empty
|
||||||
|
* with no backing columns.
|
||||||
|
*/
|
||||||
|
async function fetchApplications(params) {
|
||||||
|
const res = await inboxApi.listApplications(params)
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.map((row) => {
|
||||||
|
const name = row.name || row.email || 'Unknown'
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name,
|
||||||
|
initials: initialsOf(name),
|
||||||
|
color: avatarColor(name),
|
||||||
|
email: row.email || '',
|
||||||
|
position: row.position || '(no subject)',
|
||||||
|
...sourceFrom(row.source),
|
||||||
|
received: parseDate(row.received),
|
||||||
|
unread: Boolean(row.unread),
|
||||||
|
processing: row.processing || 'Unread',
|
||||||
|
applicationStatus: row.application_status || null,
|
||||||
|
resumeStatus: row.resume_status || 'Pending',
|
||||||
|
attachment: row.attachment,
|
||||||
|
hasAttachment: Boolean(row.has_attachment),
|
||||||
|
resumeText: row.resume_text || '',
|
||||||
|
atsScore: row.ats_score,
|
||||||
|
phone: row.phone,
|
||||||
|
experience: row.experience,
|
||||||
|
recruiter: row.recruiter,
|
||||||
|
duplicate: Boolean(row.duplicate),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /inbox/{record_id}/read — flips message_read false -> true for one row.
|
||||||
|
*
|
||||||
|
* Optimistic, so the row un-bolds on click instead of after the round trip, and
|
||||||
|
* rolls back if the server rejects. Both mailbox caches hold {id, unread} rows,
|
||||||
|
* so one setQueriesData over qk.mailbox.all() covers the Email tab and the
|
||||||
|
* application tabs at once; `processing` is derived from the same column, so it
|
||||||
|
* moves with it.
|
||||||
|
*
|
||||||
|
* NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW,
|
||||||
|
* so a view-only user gets a 403 here and the row snaps back to unread.
|
||||||
|
*/
|
||||||
|
function useMarkRead(toast) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (recordId) => inboxApi.markRead(recordId),
|
||||||
|
onMutate: async (recordId) => {
|
||||||
|
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
|
||||||
|
const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
|
||||||
|
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (rows) => (
|
||||||
|
Array.isArray(rows)
|
||||||
|
? rows.map((r) => (r.id === recordId
|
||||||
|
? { ...r, unread: false, processing: r.processing === 'Unread' ? 'Read' : r.processing }
|
||||||
|
: r))
|
||||||
|
: rows
|
||||||
|
))
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (err, _recordId, ctx) => {
|
||||||
|
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
|
||||||
|
toast(friendlyAuthError(err, 'Could not mark as read.'), 'error')
|
||||||
|
},
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function Inbox() {
|
export default function Inbox() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
|
const qc = useQueryClient()
|
||||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||||
const updateInbox = useSeedMutation('inbox')
|
const updateInbox = useSeedMutation('inbox')
|
||||||
|
|
@ -76,6 +251,32 @@ export default function Inbox() {
|
||||||
const [assigning, setAssigning] = useState(null)
|
const [assigning, setAssigning] = useState(null)
|
||||||
const [noting, setNoting] = useState(null)
|
const [noting, setNoting] = useState(null)
|
||||||
|
|
||||||
|
// Tabs with a server-side filter pass their params; everything else (and
|
||||||
|
// countsQuery) passes `{}` so the backend defaults mean "no filter".
|
||||||
|
const tabFilter = TAB_FILTERS[tab] ?? {}
|
||||||
|
|
||||||
|
const applicationsQuery = useQuery({
|
||||||
|
queryKey: qk.mailbox.applications(tabFilter),
|
||||||
|
queryFn: () => fetchApplications(tabFilter),
|
||||||
|
enabled: tab !== 'Email',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab badges need whole-table counts, which a server-filtered response
|
||||||
|
* cannot give — and there is no counts endpoint. So the unfiltered set stays
|
||||||
|
* loaded for them. On every tab without a TAB_FILTERS entry this resolves to
|
||||||
|
* the SAME query key as the list above, so React Query serves both from one
|
||||||
|
* request.
|
||||||
|
*/
|
||||||
|
const countsQuery = useQuery({
|
||||||
|
queryKey: qk.mailbox.applications({}),
|
||||||
|
queryFn: () => fetchApplications({}),
|
||||||
|
enabled: tab !== 'Email',
|
||||||
|
})
|
||||||
|
|
||||||
|
const inbox = applicationsQuery.data ?? []
|
||||||
|
const allApplications = countsQuery.data ?? []
|
||||||
|
|
||||||
const emailsQuery = useQuery({
|
const emailsQuery = useQuery({
|
||||||
queryKey: qk.mailbox.messages(),
|
queryKey: qk.mailbox.messages(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|
@ -87,11 +288,18 @@ export default function Inbox() {
|
||||||
fromEmail: row.fromEmail || '',
|
fromEmail: row.fromEmail || '',
|
||||||
subject: row.subject || '',
|
subject: row.subject || '',
|
||||||
body: row.body || '',
|
body: row.body || '',
|
||||||
when: row.when ? new Date(row.when) : new Date(),
|
when: parseDate(row.when) ?? parseDate(row.message_sent_time),
|
||||||
unread: Boolean(row.unread),
|
unread: Boolean(row.unread),
|
||||||
attachment: row.attachment_name || 'Resume.pdf',
|
attachment: row.attachment_name || 'Resume.pdf',
|
||||||
attachmentSize: '—',
|
attachmentSize: '—',
|
||||||
atsScore: 70,
|
// The agent's verdict, straight off backend/inbox/serializers.py:44-48.
|
||||||
|
// suggested_job_post_ids is deliberately NOT carried: job posts stay
|
||||||
|
// dark to the inbox.
|
||||||
|
matchStatus: row.match_status || null,
|
||||||
|
matchSummary: row.match_summary || '',
|
||||||
|
matchReasoning: row.match_reasoning || '',
|
||||||
|
matchError: row.match_error || '',
|
||||||
|
matchedAt: parseDate(row.matched_at),
|
||||||
imported: false,
|
imported: false,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
@ -99,34 +307,57 @@ export default function Inbox() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const counts = useMemo(
|
const counts = useMemo(
|
||||||
|
// Counted off the UNFILTERED set — `inbox` is server-filtered on Unread /
|
||||||
|
// Processed / Rejected, so counting it there would report that tab's total
|
||||||
|
// for every badge.
|
||||||
() => ({
|
() => ({
|
||||||
'All Applications': inbox.length,
|
'All Applications': allApplications.length,
|
||||||
Unread: inbox.filter((i) => i.processing === 'Unread').length,
|
Unread: allApplications.filter((i) => i.processing === 'Unread').length,
|
||||||
Imported: inbox.filter((i) => i.processing === 'Imported').length,
|
Imported: allApplications.filter((i) => i.processing === 'Imported').length,
|
||||||
Processed: inbox.filter((i) => i.processing === 'Processed').length,
|
Processed: allApplications.filter((i) => i.applicationStatus === 'PROCESS').length,
|
||||||
Rejected: inbox.filter((i) => i.processing === 'Rejected').length,
|
Rejected: allApplications.filter((i) => i.applicationStatus === 'REJECTED').length,
|
||||||
Duplicates: inbox.filter((i) => i.duplicate).length,
|
Duplicates: allApplications.filter((i) => i.duplicate).length,
|
||||||
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
|
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
|
||||||
}),
|
}),
|
||||||
[inbox, emailsQuery.data],
|
[allApplications, emailsQuery.data],
|
||||||
)
|
)
|
||||||
|
|
||||||
const list = useMemo(() => {
|
const list = useMemo(() => {
|
||||||
let l = inbox
|
let l = inbox
|
||||||
|
// Unread / Processed / Rejected are already filtered server-side; re-applying
|
||||||
|
// client-side keeps the optimistic mark-read drop-off for Unread, and keeps
|
||||||
|
// Processed/Rejected coherent if a stale cache briefly holds mixed rows.
|
||||||
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
|
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
|
||||||
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
|
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
|
||||||
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed')
|
else if (tab === 'Processed') l = l.filter((i) => i.applicationStatus === 'PROCESS')
|
||||||
else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
|
else if (tab === 'Rejected') l = l.filter((i) => i.applicationStatus === 'REJECTED')
|
||||||
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
|
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
|
||||||
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
|
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
|
||||||
return l
|
return l
|
||||||
}, [inbox, tab, q])
|
}, [inbox, tab, q])
|
||||||
|
|
||||||
const selected = inbox.find((i) => i.id === selectedId)
|
// Clicking a row fetches that one record from /inbox/fetch. The list row is
|
||||||
|
// kept as the base and the detail is overlaid, so the pane paints instantly
|
||||||
|
// from cached list data and fills in body/attachments when the fetch lands.
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: qk.mailbox.message(selectedId),
|
||||||
|
queryFn: () => fetchMessageDetail(selectedId),
|
||||||
|
enabled: tab !== 'Email' && Boolean(selectedId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedRow = inbox.find((i) => i.id === selectedId)
|
||||||
|
const selected = selectedRow || detailQuery.data
|
||||||
|
? { ...selectedRow, ...(detailQuery.data ?? {}) }
|
||||||
|
: null
|
||||||
|
|
||||||
|
// The one mutation these tabs CAN persist — everything else on them is
|
||||||
|
// disabled until the endpoints exist.
|
||||||
|
const markRead = useMarkRead(toast)
|
||||||
|
|
||||||
function select(id) {
|
function select(id) {
|
||||||
setSelectedId(id)
|
setSelectedId(id)
|
||||||
updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i)))
|
const item = inbox.find((i) => i.id === id)
|
||||||
|
if (item?.unread) markRead.mutate(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCandidate(item, job, cs) {
|
function makeCandidate(item, job, cs) {
|
||||||
|
|
@ -223,7 +454,15 @@ export default function Inbox() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{list.length === 0 ? (
|
{applicationsQuery.isPending && (
|
||||||
|
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
|
||||||
|
)}
|
||||||
|
{applicationsQuery.isError && (
|
||||||
|
<EmptyState icon="inbox" title="Couldn’t load applications">
|
||||||
|
{friendlyAuthError(applicationsQuery.error, 'Request failed')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
{applicationsQuery.isSuccess && list.length === 0 ? (
|
||||||
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
|
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
list.map((i) => (
|
list.map((i) => (
|
||||||
|
|
@ -241,11 +480,23 @@ export default function Inbox() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ii-pos">{i.position}</div>
|
<div className="ii-pos">{i.position}</div>
|
||||||
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
|
<div className="ii-meta">
|
||||||
|
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
|
||||||
|
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||||
|
<Badge>{i.applicationStatus}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div>
|
<div className="ii-time">
|
||||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
{i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'}
|
||||||
|
</div>
|
||||||
|
{/* No ATS score exists server-side — the agent returns a
|
||||||
|
verdict, not a number. The chip stays off rather than
|
||||||
|
rendering a placeholder that reads as a real score. */}
|
||||||
|
{i.atsScore != null && (
|
||||||
|
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -260,9 +511,16 @@ export default function Inbox() {
|
||||||
Choose an item from the list to view details and take action.
|
Choose an item from the list to view details and take action.
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
|
) : detailQuery.isError ? (
|
||||||
|
<div style={{ padding: '100px 20px' }}>
|
||||||
|
<EmptyState icon="inbox" title="Couldn’t load this application">
|
||||||
|
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ApplicationDetail
|
<ApplicationDetail
|
||||||
item={selected}
|
item={selected}
|
||||||
|
loading={detailQuery.isPending}
|
||||||
onPreview={() => setPreviewing(selected)}
|
onPreview={() => setPreviewing(selected)}
|
||||||
onImport={() => importItem(selected)}
|
onImport={() => importItem(selected)}
|
||||||
onParse={() => parseResume(selected)}
|
onParse={() => parseResume(selected)}
|
||||||
|
|
@ -289,13 +547,17 @@ export default function Inbox() {
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
|
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
|
||||||
|
disabled
|
||||||
|
title="Needs a backend endpoint — not implemented yet"
|
||||||
>
|
>
|
||||||
<Icon name="user-plus" /> Import Candidate
|
<Icon name="user-plus" /> Import Candidate
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>{resumeText(previewing)}</pre>
|
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>
|
||||||
|
{previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
|
||||||
|
</pre>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -336,9 +598,17 @@ export default function Inbox() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
|
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */
|
||||||
|
function orDash(value, suffix = '') {
|
||||||
|
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
|
||||||
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||||||
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||||||
|
// Every action below writes to a table column or an endpoint that does not
|
||||||
|
// exist yet, so they are disabled rather than silently dropping the click.
|
||||||
|
const noBackend = 'Needs a backend endpoint — not implemented yet'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
|
|
@ -349,48 +619,103 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
|
||||||
<div className="ph-role">{i.position}</div>
|
<div className="ph-role">{i.position}</div>
|
||||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||||
|
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||||
|
<><Badge>{i.applicationStatus}</Badge>{' '}</>
|
||||||
|
)}
|
||||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||||||
{i.resumeStatus}
|
{i.resumeStatus}
|
||||||
</Badge>
|
</Badge>{' '}
|
||||||
|
{loading && <span className="cell-sub">Loading details…</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center' }}>
|
{i.atsScore != null && (
|
||||||
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
|
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
|
||||||
|
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||||
<div className="info-item"><div className="il">Email</div><div className="iv">{i.email}</div></div>
|
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{i.phone}</div></div>
|
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{i.experience} years</div></div>
|
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
|
||||||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{i.recruiter}</div></div>
|
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
|
||||||
<div className="info-item"><div className="il">Received</div><div className="iv">{fmtDate(i.received)}</div></div>
|
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<div className="il">Match</div>
|
<div className="il">Received</div>
|
||||||
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
|
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Only present once GET /inbox/fetch?record_id= has resolved — the list
|
||||||
|
endpoint carries none of these. */}
|
||||||
|
{i.sentAt && (
|
||||||
|
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
|
||||||
|
)}
|
||||||
|
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
|
||||||
|
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
|
||||||
|
{i.atsScore != null && (
|
||||||
|
<div className="info-item">
|
||||||
|
<div className="il">Match</div>
|
||||||
|
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
{/* Body arrives only from GET /inbox/fetch?record_id= — the list endpoint
|
||||||
<div className="card-body">
|
does not carry it. Already run through htmlToText, and still rendered as
|
||||||
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
TEXT: inbound mail is attacker-supplied. A body that is only an empty
|
||||||
<div className="fw-600"><Icon name="paperclip" /> {i.attachment}</div>
|
HTML skeleton flattens to '' and the block is skipped entirely. */}
|
||||||
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
{!loading && (
|
||||||
</div>
|
<div className="email-preview" style={{ marginBottom: 20 }}>
|
||||||
<pre className="resume-thumb">{resumeText(i)}</pre>
|
{i.body || <span className="text-muted">This email has no message body.</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{i.hasAttachment && (
|
||||||
|
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div className="fw-600">
|
||||||
|
<Icon name="paperclip" /> {orDash(i.attachment)}
|
||||||
|
{i.files?.[0]?.size != null && (
|
||||||
|
<span className="cell-sub"> · {Math.round(i.files[0].size / 1024)} KB</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||||||
|
</div>
|
||||||
|
{/* The real extracted PDF text (inbox_messages.resume_text), written
|
||||||
|
by the matching task. Empty until that task has run. */}
|
||||||
|
<pre className="resume-thumb">
|
||||||
|
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
|
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
|
||||||
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button>
|
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
|
||||||
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
|
<Icon name="user-plus" /> Import Candidate
|
||||||
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
|
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
|
||||||
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
|
<Icon name="sparkles" /> Parse Resume
|
||||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
|
||||||
|
<Icon name="users" /> Assign Recruiter
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
|
||||||
|
<Icon name="layers" /> Move to Pipeline
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onNote} disabled title={noBackend}>
|
||||||
|
<Icon name="edit" /> Add Note
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ color: 'var(--danger)' }}
|
||||||
|
onClick={onReject}
|
||||||
|
disabled
|
||||||
|
title={noBackend}
|
||||||
|
>
|
||||||
<Icon name="x" /> Reject
|
<Icon name="x" /> Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -436,13 +761,20 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
const selected = emails.find((e) => e.id === selectedId)
|
const selected = emails.find((e) => e.id === selectedId)
|
||||||
const unread = emails.filter((e) => e.unread).length
|
const unread = emails.filter((e) => e.unread).length
|
||||||
|
|
||||||
async function sync() {
|
const markRead = useMarkRead(toast)
|
||||||
toast('Fetching from Outlook…', 'info')
|
|
||||||
const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() })
|
// Refetching the list alone only re-reads rows already in our DB. GET
|
||||||
if (query.isError) toast('Sync failed', 'error')
|
// /email/fetch is the Graph proxy pull that inserts new mail and enqueues the
|
||||||
else toast('Mailbox synced', 'success')
|
// matching agent, so it has to run FIRST — then the list is invalidated to
|
||||||
return res
|
// pick up whatever it wrote.
|
||||||
}
|
const sync = useMutation({
|
||||||
|
mutationFn: () => inboxApi.syncMailbox(),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||||
|
toast('Mailbox synced', 'success')
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
function importEmail(e) {
|
function importEmail(e) {
|
||||||
const job = jobs[0]
|
const job = jobs[0]
|
||||||
|
|
@ -455,12 +787,12 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
jobId: job.id, jobTitle: job.title, department: job.department,
|
jobId: job.id, jobTitle: job.title, department: job.department,
|
||||||
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
|
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
|
||||||
location: pick(locations), stage: 'Applied', status: 'Applied',
|
location: pick(locations), stage: 'Applied', status: 'Applied',
|
||||||
aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
|
aiScore: SEED_ATS_SCORE, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
|
||||||
applied: new Date(TODAY), education: "Bachelor's Degree",
|
applied: new Date(TODAY), education: "Bachelor's Degree",
|
||||||
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
|
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
|
||||||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||||||
recommendation: 'Potential Match',
|
recommendation: 'Potential Match',
|
||||||
subScores: { skills: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, location: 100, salary: 90 },
|
subScores: { skills: SEED_ATS_SCORE, experience: 80, education: 80, keywords: SEED_ATS_SCORE, location: 100, salary: 90 },
|
||||||
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
||||||
favorite: false, interviewStatus: 'Not Scheduled',
|
favorite: false, interviewStatus: 'Not Scheduled',
|
||||||
},
|
},
|
||||||
|
|
@ -472,6 +804,11 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
|
|
||||||
const isImported = (e) => imported.has(e.id)
|
const isImported = (e) => imported.has(e.id)
|
||||||
|
|
||||||
|
function selectEmail(e) {
|
||||||
|
setSelectedId(e.id)
|
||||||
|
if (e.unread) markRead.mutate(e.id)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
|
@ -479,8 +816,13 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
<span className="text-muted text-sm">
|
<span className="text-muted text-sm">
|
||||||
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
|
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={sync}>
|
<button
|
||||||
<Icon name="refresh" /> Sync Mailbox
|
className="btn btn-secondary btn-sm"
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
disabled={sync.isPending}
|
||||||
|
onClick={() => { toast('Fetching from Outlook…', 'info'); sync.mutate() }}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync Mailbox'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -498,8 +840,8 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
{query.isSuccess && emails.map((e) => (
|
{query.isSuccess && emails.map((e) => (
|
||||||
<div
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className={`inbox-item${e.unread && selectedId !== e.id ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
|
className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
|
||||||
onClick={() => setSelectedId(e.id)}
|
onClick={() => selectEmail(e)}
|
||||||
>
|
>
|
||||||
<Avatar name={e.from} />
|
<Avatar name={e.from} />
|
||||||
<div className="ii-main">
|
<div className="ii-main">
|
||||||
|
|
@ -512,7 +854,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
{isImported(e) && <Badge className="b-green">Imported</Badge>}
|
{isImported(e) && <Badge className="b-green">Imported</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ii-time">{fmtShort(e.when)}</div>
|
<div className="ii-time">{e.when ? fmtShort(e.when) : '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -534,7 +876,9 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
<Avatar name={selected.from} />
|
<Avatar name={selected.from} />
|
||||||
<div>
|
<div>
|
||||||
<div className="fw-600">{selected.from}</div>
|
<div className="fw-600">{selected.from}</div>
|
||||||
<div className="cell-sub">{selected.fromEmail} · {fmtDate(selected.when)}</div>
|
<div className="cell-sub">
|
||||||
|
{selected.fromEmail} · {selected.when ? fmtDate(selected.when) : 'Date unavailable'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -551,7 +895,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||||
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
|
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<ScoreChip score={selected.atsScore} />
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
|
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
|
||||||
<Icon name="eye" /> Preview
|
<Icon name="eye" /> Preview
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue