Merge pull request 'Is_READ_UPDATE' (#5) from Is_READ_UPDATE into main
Reviewed-on: #5pull/6/head^2
commit
6c3156a43e
|
|
@ -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_NAME=
|
||||
EMAIL_URL=
|
||||
EMAIL_API_TOKEN=
|
||||
EMAIL_SYNC_FOLDER=inbox
|
||||
EMAIL_SYNC_SINCE=
|
||||
EMAIL_SYNC_CRON=* * * * *
|
||||
|
||||
JWT_SECRET_KEY=
|
||||
JWT_ALGORITHM=HS256
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter,Depends, Query
|
|||
from fastapi.responses import JSONResponse
|
||||
from fastapi import HTTPException
|
||||
from db_setup import get_session
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from inbox.views import Email
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
|
|
@ -14,13 +15,13 @@ router = APIRouter()
|
|||
async def fetch_email(
|
||||
top:int=Query(100),
|
||||
skip:int=Query(0,ge=0),
|
||||
token=Query(...),
|
||||
token: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401,detail="Unauthorized")
|
||||
service=Email(session=session,token=token)
|
||||
if not service.token:
|
||||
raise HTTPException(status_code=401,detail="Unauthorized")
|
||||
data=await service.service_email(top,skip)
|
||||
value=data.get("value")
|
||||
items_lst=[]
|
||||
|
|
@ -78,3 +79,70 @@ async def rematch_inbox(
|
|||
raise
|
||||
except Exception as 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
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Column, DateTime, func, or_
|
||||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from sqlalchemy import Column, DateTime, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
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
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
full_email_response: dict[str, Any] | None = Field(
|
||||
default=None, sa_column=Column(JSONB)
|
||||
)
|
||||
application_status: Candidate_application_Status = Field(default=Candidate_application_Status.CLOSED)
|
||||
message_subject: str
|
||||
message_body: str
|
||||
message_sent_time: str
|
||||
|
|
@ -82,6 +84,17 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return email_data.get("bodyPreview") or ""
|
||||
|
||||
@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(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
|
|
@ -183,15 +196,23 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
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())
|
||||
if 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:
|
||||
statement = statement.offset(skip)
|
||||
|
||||
if top is not None:
|
||||
statement = statement.limit(top)
|
||||
|
||||
if isread==False:
|
||||
statement = statement.where(cls.message_read==False)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
|
|
@ -205,9 +226,46 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return result.scalars().first()
|
||||
|
||||
@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)
|
||||
if 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)
|
||||
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
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from inbox.models import Inbox_Messages
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
"""Prefer stored path; fall back to basename under decoded_attachments."""
|
||||
path=Path(path_str.strip())
|
||||
"""Prefer stored path; fall back to basename under decoded_attachments.
|
||||
|
||||
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():
|
||||
return path
|
||||
fallback=_ATTACHMENTS_DIR/path.name
|
||||
basename=Path(raw.replace("\\","/")).name
|
||||
fallback=_ATTACHMENTS_DIR/basename
|
||||
if fallback.is_file():
|
||||
return fallback
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -2,9 +2,19 @@ from pathlib import Path
|
|||
|
||||
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
|
||||
full = message.full_email_response
|
||||
if isinstance(full, dict):
|
||||
|
|
@ -15,16 +25,26 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
|||
name = email_address.get("name")
|
||||
if name:
|
||||
sender_name = name
|
||||
return sender_name
|
||||
|
||||
attachment_name = None
|
||||
|
||||
def _attachment_name(message: Inbox_Messages) -> str | None:
|
||||
if message.file_name:
|
||||
attachment_name = message.file_name.split(",")[0].strip() or None
|
||||
elif message.file_path:
|
||||
attachment_name = Path(message.file_path.split(",")[0].strip()).name or None
|
||||
return message.file_name.split(",")[0].strip() or None
|
||||
if message.file_path:
|
||||
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 {
|
||||
"id": str(message.id),
|
||||
"message_id": str(message.message_id) if message.message_id else None,
|
||||
"full_email_response": message.full_email_response,
|
||||
"sender_name": sender_name,
|
||||
"fromEmail": message.message_from,
|
||||
"subject": message.message_subject,
|
||||
|
|
@ -47,3 +67,37 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
|||
"match_error": message.match_error,
|
||||
"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 httpx,os
|
||||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from inbox.models import Inbox_Messages
|
||||
from inbox.file_decoder import decode_attachment
|
||||
from inbox.serializers import serialize_message
|
||||
from inbox.plugins import load_message_files
|
||||
from inbox.serializers import serialize_application, serialize_message
|
||||
from inbox.plugins import (
|
||||
EMAIL_API_TOKEN,
|
||||
fetch_message_read_status,
|
||||
load_message_files,
|
||||
)
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -17,9 +22,19 @@ class Email:
|
|||
def __init__(self,session:AsyncSession,token=None):
|
||||
self.session=session
|
||||
self.get_url=os.getenv("EMAIL_URL")
|
||||
self.token=token
|
||||
self.token=token or EMAIL_API_TOKEN
|
||||
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 with httpx.AsyncClient() as client:
|
||||
try:
|
||||
|
|
@ -77,6 +92,21 @@ class Email:
|
|||
item["files"]=files
|
||||
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):
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
if not message:
|
||||
|
|
@ -98,5 +128,34 @@ class Email:
|
|||
task_ids.append(task.task_id)
|
||||
return task_ids
|
||||
|
||||
async def count_inbox_messages(self,search=None):
|
||||
return await Inbox_Messages.count_inbox_messages(self.session,search)
|
||||
async def count_inbox_messages(self,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:
|
||||
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 import HTTPException
|
||||
from db_setup import get_session
|
||||
|
|
@ -49,6 +49,22 @@ async def cv_upload(
|
|||
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")
|
||||
async def post_job(
|
||||
payload: JobPostCreate,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import os,logging,io
|
||||
from datetime import datetime,timezone
|
||||
from fastapi import HTTPException
|
||||
from pypdf import PdfReader
|
||||
from sqlalchemy import select
|
||||
from inbox.models import Inbox_Messages
|
||||
from job.candidate.plugins import normalize_spaced_text
|
||||
|
||||
class FileRead:
|
||||
|
|
@ -26,7 +28,41 @@ class FileRead:
|
|||
raise
|
||||
except Exception as 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):
|
||||
# try:
|
||||
# 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.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import os
|
|||
from dotenv import load_dotenv
|
||||
from taskiq import TaskiqScheduler
|
||||
from taskiq.middlewares import SmartRetryMiddleware
|
||||
from taskiq.schedule_sources import LabelScheduleSource
|
||||
from taskiq_redis import (
|
||||
ListRedisScheduleSource,
|
||||
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",
|
||||
"taskiq_management.broker_setup:broker",
|
||||
"inbox.tasks",
|
||||
"inbox.sync_tasks",
|
||||
"taskiq_management.tasks",
|
||||
"--workers",
|
||||
"1",
|
||||
|
|
@ -48,7 +49,7 @@ services:
|
|||
build:
|
||||
context: ./backend
|
||||
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:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,41 @@ export function listMessages() {
|
|||
return request('/inbox/fetch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted applications — the shape the All Applications tab renders.
|
||||
*
|
||||
* Unlike /inbox/fetch this one IS permissioned server-side
|
||||
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
|
||||
*/
|
||||
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. */
|
||||
export function syncMailbox({ 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: {
|
||||
all: () => ['mailbox'],
|
||||
messages: () => ['mailbox', 'messages'],
|
||||
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||
message: (id) => ['mailbox', 'message', id],
|
||||
},
|
||||
|
||||
// --- seed-backed buckets ---
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
import { useMemo, useState } from 'react'
|
||||
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 { Tabs } from '../ui/Tabs'
|
||||
|
|
@ -23,30 +23,60 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as inboxApi from '../api/inbox'
|
||||
import {
|
||||
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'
|
||||
|
||||
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. */
|
||||
const NOW = new Date('2026-07-09T20:00')
|
||||
|
||||
function resumeText(i) {
|
||||
return `${i.name.toUpperCase()}
|
||||
${i.email} · ${i.phone}
|
||||
${'—'.repeat(30)}
|
||||
PROFESSIONAL SUMMARY
|
||||
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
|
||||
/**
|
||||
* The seed candidate record importEmail() writes needs a number. The agent
|
||||
* returns a verdict, not a score, so there is nothing on the wire to use —
|
||||
* named here so the fabricated value is visible at its point of use instead of
|
||||
* arriving disguised as a server field on every message.
|
||||
*/
|
||||
const SEED_ATS_SCORE = 70
|
||||
|
||||
EXPERIENCE
|
||||
• ${pick(companies)} — Senior role (2021–Present)
|
||||
• ${pick(companies)} — Associate (2018–2021)
|
||||
/**
|
||||
* message_received_time / message_sent_time are plain string columns
|
||||
* (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
|
||||
|
||||
SKILLS
|
||||
• ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
|
||||
/**
|
||||
* `source` arrives as the raw To address, because that is where the board tag
|
||||
* lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
|
||||
* everything but letters from both sides so "Employee Referral" still matches
|
||||
* "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 }) {
|
||||
|
|
@ -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() {
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
|
||||
const qc = useQueryClient()
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const updateInbox = useSeedMutation('inbox')
|
||||
|
|
@ -76,6 +251,32 @@ export default function Inbox() {
|
|||
const [assigning, setAssigning] = 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({
|
||||
queryKey: qk.mailbox.messages(),
|
||||
queryFn: async () => {
|
||||
|
|
@ -87,11 +288,18 @@ export default function Inbox() {
|
|||
fromEmail: row.fromEmail || '',
|
||||
subject: row.subject || '',
|
||||
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),
|
||||
attachment: row.attachment_name || 'Resume.pdf',
|
||||
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,
|
||||
}))
|
||||
},
|
||||
|
|
@ -99,34 +307,57 @@ export default function Inbox() {
|
|||
})
|
||||
|
||||
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,
|
||||
Unread: inbox.filter((i) => i.processing === 'Unread').length,
|
||||
Imported: inbox.filter((i) => i.processing === 'Imported').length,
|
||||
Processed: inbox.filter((i) => i.processing === 'Processed').length,
|
||||
Rejected: inbox.filter((i) => i.processing === 'Rejected').length,
|
||||
Duplicates: inbox.filter((i) => i.duplicate).length,
|
||||
'All Applications': allApplications.length,
|
||||
Unread: allApplications.filter((i) => i.processing === 'Unread').length,
|
||||
Imported: allApplications.filter((i) => i.processing === 'Imported').length,
|
||||
Processed: allApplications.filter((i) => i.applicationStatus === 'PROCESS').length,
|
||||
Rejected: allApplications.filter((i) => i.applicationStatus === 'REJECTED').length,
|
||||
Duplicates: allApplications.filter((i) => i.duplicate).length,
|
||||
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
|
||||
}),
|
||||
[inbox, emailsQuery.data],
|
||||
[allApplications, emailsQuery.data],
|
||||
)
|
||||
|
||||
const list = useMemo(() => {
|
||||
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')
|
||||
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 === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
|
||||
else if (tab === 'Processed') l = l.filter((i) => i.applicationStatus === 'PROCESS')
|
||||
else if (tab === 'Rejected') l = l.filter((i) => i.applicationStatus === 'REJECTED')
|
||||
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()))
|
||||
return l
|
||||
}, [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) {
|
||||
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) {
|
||||
|
|
@ -223,7 +454,15 @@ export default function Inbox() {
|
|||
</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>
|
||||
) : (
|
||||
list.map((i) => (
|
||||
|
|
@ -241,11 +480,23 @@ export default function Inbox() {
|
|||
)}
|
||||
</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 style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div>
|
||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||
<div className="ii-time">
|
||||
{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>
|
||||
))
|
||||
|
|
@ -260,9 +511,16 @@ export default function Inbox() {
|
|||
Choose an item from the list to view details and take action.
|
||||
</EmptyState>
|
||||
</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
|
||||
item={selected}
|
||||
loading={detailQuery.isPending}
|
||||
onPreview={() => setPreviewing(selected)}
|
||||
onImport={() => importItem(selected)}
|
||||
onParse={() => parseResume(selected)}
|
||||
|
|
@ -289,13 +547,17 @@ export default function Inbox() {
|
|||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
|
||||
disabled
|
||||
title="Needs a backend endpoint — not implemented yet"
|
||||
>
|
||||
<Icon name="user-plus" /> Import Candidate
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -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 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 (
|
||||
<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-tags" style={{ marginTop: 8 }}>
|
||||
<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'}>
|
||||
{i.resumeStatus}
|
||||
</Badge>
|
||||
</Badge>{' '}
|
||||
{loading && <span className="cell-sub">Loading details…</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<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>
|
||||
{i.atsScore != null && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<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 className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">Phone</div><div className="iv">{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">Assigned Recruiter</div><div className="iv">{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="il">Email</div><div className="iv">{orDash(i.email)}</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">{orDash(i.experience, ' years')}</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">Match</div>
|
||||
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
|
||||
<div className="il">Received</div>
|
||||
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</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 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" /> {i.attachment}</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||||
</div>
|
||||
<pre className="resume-thumb">{resumeText(i)}</pre>
|
||||
{/* Body arrives only from GET /inbox/fetch?record_id= — the list endpoint
|
||||
does not carry it. Already run through htmlToText, and still rendered as
|
||||
TEXT: inbound mail is attacker-supplied. A body that is only an empty
|
||||
HTML skeleton flattens to '' and the block is skipped entirely. */}
|
||||
{!loading && (
|
||||
<div className="email-preview" style={{ marginBottom: 20 }}>
|
||||
{i.body || <span className="text-muted">This email has no message body.</span>}
|
||||
</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' }}>
|
||||
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button>
|
||||
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
|
||||
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
|
||||
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
|
||||
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
|
||||
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
|
||||
<Icon name="user-plus" /> Import Candidate
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
|
||||
<Icon name="sparkles" /> Parse Resume
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -436,13 +761,20 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
const selected = emails.find((e) => e.id === selectedId)
|
||||
const unread = emails.filter((e) => e.unread).length
|
||||
|
||||
async function sync() {
|
||||
toast('Fetching from Outlook…', 'info')
|
||||
const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() })
|
||||
if (query.isError) toast('Sync failed', 'error')
|
||||
else toast('Mailbox synced', 'success')
|
||||
return res
|
||||
}
|
||||
const markRead = useMarkRead(toast)
|
||||
|
||||
// Refetching the list alone only re-reads rows already in our DB. GET
|
||||
// /email/fetch is the Graph proxy pull that inserts new mail and enqueues the
|
||||
// matching agent, so it has to run FIRST — then the list is invalidated to
|
||||
// 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) {
|
||||
const job = jobs[0]
|
||||
|
|
@ -455,12 +787,12 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
jobId: job.id, jobTitle: job.title, department: job.department,
|
||||
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
|
||||
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",
|
||||
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
|
||||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||||
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: [],
|
||||
favorite: false, interviewStatus: 'Not Scheduled',
|
||||
},
|
||||
|
|
@ -472,6 +804,11 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
|
||||
const isImported = (e) => imported.has(e.id)
|
||||
|
||||
function selectEmail(e) {
|
||||
setSelectedId(e.id)
|
||||
if (e.unread) markRead.mutate(e.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
|
||||
</span>
|
||||
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={sync}>
|
||||
<Icon name="refresh" /> Sync Mailbox
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -498,8 +840,8 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
{query.isSuccess && emails.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className={`inbox-item${e.unread && selectedId !== e.id ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
|
||||
onClick={() => setSelectedId(e.id)}
|
||||
className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
|
||||
onClick={() => selectEmail(e)}
|
||||
>
|
||||
<Avatar name={e.from} />
|
||||
<div className="ii-main">
|
||||
|
|
@ -512,7 +854,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
{isImported(e) && <Badge className="b-green">Imported</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ii-time">{fmtShort(e.when)}</div>
|
||||
<div className="ii-time">{e.when ? fmtShort(e.when) : '—'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -534,7 +876,9 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
<Avatar name={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>
|
||||
|
||||
|
|
@ -551,7 +895,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<ScoreChip score={selected.atsScore} />
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
|
||||
<Icon name="eye" /> Preview
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue