feat(bot,wizard): smart message filtering, encrypted standalone wizard, and file persistence

This commit is contained in:
2026-09-30 00:49:04 +03:30
parent 0a7db5ebe9
commit 07643dbdf5
13 changed files with 750 additions and 56 deletions
+13
View File
@@ -135,3 +135,16 @@ class ErpClient:
return (await self._request( return (await self._request(
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id "PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
)).get("data") or {} )).get("data") or {}
async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes,
mime_type: str = "application/pdf", actor_id: str | None = None) -> dict:
url = f"{self._base}/quotes/{quote_id}/{kind}"
files = {"file": (file_name, content, mime_type)}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None))
if resp.status_code in (200, 201):
return resp.json()
except Exception as e:
logger.warning("Failed to upload quote attachment to ERP: %s", e)
return {}
+7
View File
@@ -31,6 +31,13 @@ class Settings(BaseSettings):
# Frontend URL (for opening Wizard from Bale bot) # Frontend URL (for opening Wizard from Bale bot)
FRONTEND_URL: str = "http://localhost:8081" FRONTEND_URL: str = "http://localhost:8081"
# Security token for standalone wizard review URL
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
DRAFT_TOKEN_EXPIRE_SECONDS: int = 7200 # 2 hours
# Persistent storage directory for draft attachments (proformas)
ATTACHMENTS_DIR: str = "data/attachments/proformas"
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter. # OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1 AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
AI_API_KEY: str = "" AI_API_KEY: str = ""
+123 -24
View File
@@ -14,6 +14,7 @@ from app import drafts as draft_store
from app.config import settings from app.config import settings
from app.nlu import understand from app.nlu import understand
from app.runtime import BotContext, LinkedUser from app.runtime import BotContext, LinkedUser
from app.storage import save_proforma_file
from app.stt import transcribe, transcribe_multi from app.stt import transcribe, transcribe_multi
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
@@ -38,35 +39,15 @@ async def handle_update(ctx: BotContext, update: dict) -> None:
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
text = (message.get("text") or "").strip() text = (message.get("text") or message.get("caption") or "").strip()
source = "text" source = "text"
voice_transcripts: list[dict] | None = None voice_transcripts: list[dict] | None = None
message_id = message.get("message_id")
if text.startswith("/"): if text.startswith("/"):
await commands.handle_command(ctx, chat_id, text) await commands.handle_command(ctx, chat_id, text)
return return
if not text and (message.get("voice") or message.get("audio") or message.get("document")):
source = "voice"
await ctx.bale.send_chat_action(chat_id, "typing")
file_id = (message.get("voice") or message.get("audio") or message.get("document"))["file_id"]
try:
audio = await ctx.bale.download_file(file_id)
voice_transcripts = await transcribe_multi(
settings,
audio,
mime_type=(message.get("voice") or {}).get("mime_type"),
filename=(message.get("voice") or {}).get("file_name"),
)
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)))
return
if not voice_transcripts:
await ctx.send(chat_id, VOICE_EMPTY)
return
text = voice_transcripts[0]["text"]
# Pending multi-step interactions take the message first. # Pending multi-step interactions take the message first.
flow = ctx.pending.get(chat_id) flow = ctx.pending.get(chat_id)
if flow: if flow:
@@ -79,11 +60,43 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
if user is None: if user is None:
return # gate either started linking or is mid-linking return # gate either started linking or is mid-linking
message_id = message.get("message_id")
reply_to = message.get("reply_to_message") reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None reply_to_msg_id = reply_to.get("message_id") if reply_to else None
target_draft = None def _is_audio(msg: dict) -> bool:
if msg.get("voice") or msg.get("audio"):
return True
doc = msg.get("document")
if doc and (doc.get("mime_type") or "").lower().startswith("audio/"):
return True
return False
async def _extract_voice() -> list[dict] | None:
nonlocal source
source = "voice"
await ctx.bale.send_chat_action(chat_id, "typing")
audio_media = message.get("voice") or message.get("audio") or message.get("document")
file_id = audio_media["file_id"]
try:
audio_bytes = await ctx.bale.download_file(file_id)
transcripts = await transcribe_multi(
settings,
audio_bytes,
mime_type=audio_media.get("mime_type"),
filename=audio_media.get("file_name"),
)
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)), reply_to=message_id)
return None
if not transcripts:
await ctx.send(chat_id, VOICE_EMPTY, reply_to=message_id)
return None
return transcripts
# =========================================================================
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
# =========================================================================
if reply_to_msg_id is not None: if reply_to_msg_id is not None:
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id) target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
if target_draft is None: if target_draft is None:
@@ -94,6 +107,92 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
) )
return return
# Check for proforma attachment (document or photo)
proforma_attached = False
saved_meta = None
doc = message.get("document")
photos = message.get("photo")
if doc and not (doc.get("mime_type") or "").lower().startswith("audio/"):
doc_file_id = doc["file_id"]
doc_file_name = doc.get("file_name") or f"proforma_{message_id}.pdf"
doc_mime = doc.get("mime_type") or "application/pdf"
elif photos:
largest = photos[-1] if isinstance(photos, list) else photos
doc_file_id = largest["file_id"]
doc_file_name = f"proforma_{message_id}.jpg"
doc_mime = "image/jpeg"
else:
doc_file_id = None
doc_file_name = None
doc_mime = None
if doc_file_id:
await ctx.bale.send_chat_action(chat_id, "upload_document")
try:
content = await ctx.bale.download_file(doc_file_id)
saved_meta = await save_proforma_file(
target_draft.id,
doc_file_name,
content,
mime_type=doc_mime,
storage_dir=settings.ATTACHMENTS_DIR,
)
payload = dict(target_draft.payload or {})
payload["proforma_file"] = saved_meta["file_name"]
payload["proforma"] = saved_meta
target_draft.payload = payload
await draft_store.save_payload(target_draft, mark_bale_synced=True)
proforma_attached = True
except Exception as exc:
logger.exception("Failed to persist proforma")
await ctx.send(chat_id, f"❌ خطا در ذخیره فایل پیش‌فاکتور: {esc(str(exc)[:100])}", reply_to=message_id)
return
if _is_audio(message):
voice_transcripts = await _extract_voice()
if not voice_transcripts:
return
text = voice_transcripts[0]["text"]
if proforma_attached and not text and not voice_transcripts:
await quote.render(ctx, chat_id, target_draft, fresh=False)
await ctx.send(
chat_id,
f"📎 پیش‌فاکتور «{saved_meta['file_name']}» با موفقیت ذخیره و به استعلام ضمیمه شد ✅",
reply_to=message_id,
)
return
if not proforma_attached and not text and not voice_transcripts:
await ctx.send(
chat_id,
"⚠️ برای ویرایش پیش‌نویس، لطفاً تغییرات را به صورت متن یا پیام صوتی (ویس) ارسال کنید، یا فایل پیش‌فاکتور را بفرستید.",
reply_to=message_id,
)
return
# =========================================================================
# BRANCH 2: NEW QUOTE MODE — Messages without reply
# =========================================================================
else:
target_draft = None
if _is_audio(message):
voice_transcripts = await _extract_voice()
if not voice_transcripts:
return
text = voice_transcripts[0]["text"]
elif text:
source = "text"
else:
await ctx.send(
chat_id,
"⚠️ برای ثبت استعلام خرید جدید، لطفاً اقلام و مشخصات را به صورت پیام متنی یا پیام صوتی (ویس) ارسال فرمایید.",
reply_to=message_id,
)
return
draft_summary = _draft_summary(target_draft) if target_draft else None draft_summary = _draft_summary(target_draft) if target_draft else None
input_payload = voice_transcripts if voice_transcripts else text input_payload = voice_transcripts if voice_transcripts else text
result = await understand(settings, input_payload, draft_summary) result = await understand(settings, input_payload, draft_summary)
+30 -2
View File
@@ -198,9 +198,17 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
reply_to: int | None = None) -> None: reply_to: int | None = None) -> None:
from app.config import settings from app.config import settings
from app.security import create_draft_token
payload = draft.payload payload = draft.payload
missing = compute_quote_missing(payload) missing = compute_quote_missing(payload)
wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}" token = create_draft_token(
draft.id,
chat_id=chat_id,
secret=settings.DRAFT_TOKEN_SECRET,
expires_in=settings.DRAFT_TOKEN_EXPIRE_SECONDS,
)
wizard_url = f"{settings.FRONTEND_URL.rstrip('/')}/review?token={token}"
text = quote_review_text(payload, missing, status_note=note) text = quote_review_text(payload, missing, status_note=note)
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}" text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url) keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
@@ -302,9 +310,29 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
ctx.pending.pop(chat_id, None) ctx.pending.pop(chat_id, None)
ctx.resolver.invalidate() ctx.resolver.invalidate()
quote_id = (created or {}).get("id") or ""
# If a proforma file was attached, persist it to the created ERP quote
proforma_info = payload.get("proforma")
if quote_id and proforma_info and proforma_info.get("saved_path"):
try:
from pathlib import Path
p_path = Path(proforma_info["saved_path"])
if p_path.exists():
file_bytes = p_path.read_bytes()
await ctx.api.upload_quote_file(
quote_id,
"proforma",
proforma_info.get("file_name") or "proforma.pdf",
file_bytes,
mime_type=proforma_info.get("mime_type") or "application/pdf",
actor_id=user.actor_id,
)
logger.info("Successfully uploaded proforma to quote %s", quote_id)
except Exception as e:
logger.warning("Could not upload draft proforma to quote %s: %s", quote_id, e)
if draft.review_message_id: if draft.review_message_id:
try: try:
quote_id = (created or {}).get("id") or ""
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}" msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None) await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id) await draft_store.set_bale_updated_at(draft.id)
+72
View File
@@ -0,0 +1,72 @@
"""Security and token generation for standalone draft review."""
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from uuid import UUID
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(s: str) -> bytes:
padding = 4 - (len(s) % 4)
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s.encode("ascii"))
def create_draft_token(
draft_id: str | UUID,
chat_id: int | None = None,
user_id: str | None = None,
secret: str = "procurement-draft-secret-change-in-prod-v7",
expires_in: int = 7200,
) -> str:
"""Generate an HMAC-SHA256 signed token for secure draft access."""
now = int(time.time())
payload = {
"draft_id": str(draft_id),
"chat_id": chat_id,
"user_id": str(user_id) if user_id else None,
"iat": now,
"exp": now + expires_in,
}
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
sig_b64 = _b64url_encode(sig)
return f"{payload_b64}.{sig_b64}"
def verify_draft_token(
token: str,
secret: str = "procurement-draft-secret-change-in-prod-v7",
) -> dict[str, Any] | None:
"""Verify signature and expiration of a draft token. Return payload or None."""
if not token or "." not in token:
return None
try:
payload_b64, sig_b64 = token.split(".", 1)
expected_sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
actual_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(expected_sig, actual_sig):
return None
payload_bytes = _b64url_decode(payload_b64)
payload = json.loads(payload_bytes.decode("utf-8"))
# Check expiration
exp = payload.get("exp")
if exp is not None and int(exp) < int(time.time()):
return None
return payload
except Exception:
return None
+53
View File
@@ -0,0 +1,53 @@
"""Persistent storage for draft attachments (proforma files)."""
from datetime import datetime, timezone
import logging
import os
from pathlib import Path
import re
from uuid import UUID, uuid4
logger = logging.getLogger("bot.storage")
def sanitize_filename(filename: str) -> str:
"""Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions."""
raw = (filename or "proforma.pdf").strip()
name = Path(raw).name
# Remove control characters and path traversal
clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name)
clean = clean.strip(". ")
return clean or f"proforma_{uuid4().hex[:8]}.pdf"
async def save_proforma_file(
draft_id: str | UUID,
file_name: str,
content: bytes,
mime_type: str | None = None,
storage_dir: str = "data/attachments/proformas",
) -> dict:
"""Save proforma bytes permanently to disk.
Returns dict metadata for persisting in draft payload.
"""
clean_name = sanitize_filename(file_name)
target_dir = Path(storage_dir) / str(draft_id)
target_dir.mkdir(parents=True, exist_ok=True)
unique_prefix = uuid4().hex[:8]
saved_filename = f"{unique_prefix}_{clean_name}"
target_path = target_dir / saved_filename
# Write bytes to disk
target_path.write_bytes(content)
logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path)
return {
"file_name": clean_name,
"saved_filename": saved_filename,
"saved_path": str(target_path.resolve()),
"relative_path": str(target_path.as_posix()),
"size_bytes": len(content),
"mime_type": mime_type or "application/octet-stream",
"saved_at": datetime.now(timezone.utc).isoformat(),
}
+3
View File
@@ -40,6 +40,9 @@ class Settings(BaseSettings):
MINIO_BUCKET: str = "procurement-files" MINIO_BUCKET: str = "procurement-files"
MINIO_SECURE: bool = False MINIO_SECURE: bool = False
# Security token for standalone wizard review URL (must match bot)
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
# Background auto-expiry of offered quotes whose expiry_date has passed. # Background auto-expiry of offered quotes whose expiry_date has passed.
AUTO_EXPIRE_ENABLED: bool = True AUTO_EXPIRE_ENABLED: bool = True
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600 AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
+89 -1
View File
@@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
from app.models.draft import BotDraft from app.models.draft import BotDraft
from app.utils.errors import NotFoundError from app.utils.draft_token import verify_draft_token
from app.utils.errors import NotFoundError, UnauthorizedError
router = APIRouter(prefix="/drafts", tags=["drafts"]) router = APIRouter(prefix="/drafts", tags=["drafts"])
@@ -38,6 +39,93 @@ def _serialize_draft(draft: BotDraft) -> dict:
} }
def _verify_token_or_raise(token: str) -> dict:
token_payload = verify_draft_token(token)
if not token_payload or not token_payload.get("draft_id"):
raise UnauthorizedError("لینک بازبینی نامعتبر یا منقضی شده است")
return token_payload
@router.get("/verify")
async def verify_and_get_draft(token: str, db: AsyncSession = Depends(get_db)):
"""Public endpoint for token-based draft review without ERP login."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
return {"data": _serialize_draft(draft), "token_payload": token_payload}
@router.patch("/by-token")
async def update_draft_by_token(token: str, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
"""Update draft payload using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
if body.payload is not None:
merged = dict(draft.payload or {})
merged.update(body.payload)
draft.payload = merged
if body.status is not None:
draft.status = body.status
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/by-token/commit")
async def commit_draft_by_token(token: str, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
"""Commit draft using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "committed"
payload = dict(draft.payload or {})
if body.quote_id:
payload["committed_quote_id"] = body.quote_id
draft.payload = payload
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/by-token/cancel")
async def cancel_draft_by_token(token: str, db: AsyncSession = Depends(get_db)):
"""Cancel draft using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "cancelled"
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.get("/{draft_id}") @router.get("/{draft_id}")
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)): async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id) stmt = select(BotDraft).where(BotDraft.id == draft_id)
+81
View File
@@ -0,0 +1,81 @@
"""HMAC-SHA256 token verification for standalone draft review."""
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from uuid import UUID
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(s: str) -> bytes:
padding = 4 - (len(s) % 4)
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s.encode("ascii"))
def _get_secret(secret: str | None = None) -> str:
if secret:
return secret
try:
from app.config import settings
return settings.DRAFT_TOKEN_SECRET
except Exception:
return "procurement-draft-secret-change-in-prod-v7"
def create_draft_token(
draft_id: str | UUID,
chat_id: int | None = None,
user_id: str | None = None,
secret: str | None = None,
expires_in: int = 7200,
) -> str:
sec = _get_secret(secret)
now = int(time.time())
payload = {
"draft_id": str(draft_id),
"chat_id": chat_id,
"user_id": str(user_id) if user_id else None,
"iat": now,
"exp": now + expires_in,
}
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
sig_b64 = _b64url_encode(sig)
return f"{payload_b64}.{sig_b64}"
def verify_draft_token(
token: str,
secret: str | None = None,
) -> dict[str, Any] | None:
"""Verify signature and expiration of a draft token. Return payload or None."""
if not token or "." not in token:
return None
try:
sec = _get_secret(secret)
payload_b64, sig_b64 = token.split(".", 1)
expected_sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
actual_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(expected_sig, actual_sig):
return None
payload_bytes = _b64url_decode(payload_b64)
payload = json.loads(payload_bytes.decode("utf-8"))
exp = payload.get("exp")
if exp is not None and int(exp) < int(time.time()):
return None
return payload
except Exception:
return None
+1
View File
@@ -83,6 +83,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route path="/review" element={<WizardPage isStandalone />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route
path="/" path="/"
@@ -89,7 +89,7 @@ export function MobileTabBar() {
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />; if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
return null; return null;
} }
if (pathname === "/wizard") return null; if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage = const onOfficePage =
pathname === "/" || pathname === "/" ||
pathname === "/projects" || pathname === "/projects" ||
+32
View File
@@ -1053,6 +1053,38 @@ export const api = {
): Promise<void> => { ): Promise<void> => {
await postData(`/drafts/${id}/cancel`, {}); await postData(`/drafts/${id}/cancel`, {});
}, },
verifyToken: async (
token: string,
): Promise<{
id: string;
chat_id: number;
kind: string;
status: string;
payload: Record<string, any>;
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
source?: string;
created_at?: string;
updated_at?: string;
}> => {
return getData(`/drafts/verify?token=${encodeURIComponent(token)}`);
},
updateByToken: async (
token: string,
payload: Record<string, any>,
): Promise<void> => {
await patchData(`/drafts/by-token?token=${encodeURIComponent(token)}`, { payload });
},
commitByToken: async (
token: string,
quoteId?: string,
): Promise<void> => {
await postData(`/drafts/by-token/commit?token=${encodeURIComponent(token)}`, { quote_id: quoteId });
},
cancelByToken: async (
token: string,
): Promise<void> => {
await postData(`/drafts/by-token/cancel?token=${encodeURIComponent(token)}`, {});
},
}, },
suppliers: { suppliers: {
+245 -28
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { track } from "../../lib/analytics"; import { track } from "../../lib/analytics";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { ChevronRight, ChevronLeft, Check } from "lucide-react"; import { ChevronRight, ChevronLeft, Check, ShieldCheck, AlertCircle, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { LineItem, Quote } from "../../lib/types"; import type { LineItem, Quote } from "../../lib/types";
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api"; import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
@@ -39,11 +39,111 @@ import {
const TOTAL_STEPS = 5; const TOTAL_STEPS = 5;
// ویزارد ۵ مرحله‌ای افزودن پیشنهاد جدید // ---------- صفحه خروج امن (Safe Exit Screen) ----------
export function WizardPage() { function SafeExitScreen({
title,
subtitle,
}: {
title?: string;
subtitle?: string;
}) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 p-8 text-center shadow-xl">
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-emerald-100 dark:bg-emerald-950/60 text-emerald-600 dark:text-emerald-400">
<Check className="w-8 h-8 stroke-[2.5]" />
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
{title || "پیش‌نویس استعلام ذخیره گردید"}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
{subtitle ||
"اطلاعات با موفقیت ذخیره شد. اکنون می‌توانید این برگه را ببندید و برای ادامه کار به ربات بله بازگردید."}
</p>
<div className="space-y-3">
<Button
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 rounded-xl transition shadow-xs text-sm"
onClick={() => {
window.close();
}}
>
بستن این برگه
</Button>
<p className="text-xs text-slate-400 dark:text-slate-500">
(در صورت مسدود بودن بستن خودکار توسط مرورگر، لطفاً تب مرورگر را به صورت دستی ببندید)
</p>
</div>
</div>
</div>
);
}
// ---------- صفحه خطای توکن نامعتبر ----------
function TokenInvalidScreen({ message }: { message: string }) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-rose-200 dark:border-rose-900/50 p-8 text-center shadow-xl">
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-rose-100 dark:bg-rose-950/60 text-rose-600 dark:text-rose-400">
<AlertCircle className="w-8 h-8 stroke-[2.5]" />
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
لینک نامعتبر یا منقضی شده
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
{message ||
"اعتبار این لینک بازبینی به پایان رسیده است. لطفاً از طریق ربات بله، مجدداً دکمه بازبینی را دریافت فرمایید."}
</p>
<Button
variant="line"
className="w-full rounded-xl py-2 text-sm"
onClick={() => {
window.close();
}}
>
بستن برگه
</Button>
</div>
</div>
);
}
// ویزارد ۵ مرحله‌ای افزودن پیشنهاد جدید و بازبینی پیش‌نویس
export function WizardPage({ isStandalone = false }: { isStandalone?: boolean }) {
useStoreData(); useStoreData();
const navigate = useNavigate(); const navigate = useNavigate();
const [params] = useSearchParams(); const [params] = useSearchParams();
const token = params.get("token");
const isStandaloneMode = isStandalone || Boolean(token);
const [exitInfo, setExitInfo] = useState<{
show: boolean;
title?: string;
subtitle?: string;
}>({ show: false });
const [tokenError, setTokenError] = useState<string | null>(null);
// Setup History Trap when in standalone mode
useEffect(() => {
if (!isStandaloneMode) return;
try {
window.history.replaceState({ page: "review_root" }, "", window.location.href);
window.history.pushState({ page: "review_active" }, "", window.location.href);
} catch {}
const onPop = () => {
setExitInfo({
show: true,
title: "بازبینی به پایان رسید",
subtitle: "پیش‌نویس ذخیره گردید. اکنون می‌توانید این برگه را ببندید و به ربات بله بازگردید.",
});
try {
window.history.pushState(null, "", window.location.href);
} catch {}
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, [isStandaloneMode]);
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -214,16 +314,25 @@ export function WizardPage() {
} }
}, [params, suppliers, topics]); }, [params, suppliers, topics]);
// ---------- بارگذاری اولیه پیش‌نویس ربات بله (?draft=&draft_id=) ---------- // ---------- بارگذاری اولیه پیش‌نویس ربات بله (?token= یا ?draft=) ----------
useEffect(() => { useEffect(() => {
const draftId = params.get("draft") || params.get("draft_id"); const draftId = params.get("draft") || params.get("draft_id");
if (!draftId || draftFetchedRef.current) return; if ((!token && !draftId) || draftFetchedRef.current) return;
draftFetchedRef.current = true; draftFetchedRef.current = true;
void (async () => { void (async () => {
try { try {
const draft = await api.drafts.get(draftId); let draft: any = null;
if (!draft || !draft.payload) return; if (token) {
const res = await api.drafts.verifyToken(token);
draft = (res as any)?.data || res;
} else if (draftId) {
draft = await api.drafts.get(draftId);
}
if (!draft || !draft.payload) {
if (token) setTokenError("پیش‌نویس معتبری یافت نشد.");
return;
}
setLoadedDraftId(draft.id); setLoadedDraftId(draft.id);
lastRemoteUpdatedAtRef.current = draft.updated_at || null; lastRemoteUpdatedAtRef.current = draft.updated_at || null;
@@ -234,31 +343,57 @@ export function WizardPage() {
toast.success("پیش‌نویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", { toast.success("پیش‌نویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", {
duration: 6000, duration: 6000,
}); });
} catch (err) { } catch (err: any) {
toast.error(`خطا در فراخوانی پیش‌نویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`); if (token) {
setTokenError(err?.message || "لینک بازبینی نامعتبر یا منقضی شده است.");
} else {
toast.error(`خطا در فراخوانی پیش‌نویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
}
} }
})(); })();
}, [params, applyDraftPayloadToForm]); }, [params, token, applyDraftPayloadToForm]);
// ---------- پولینگ دوره‌ای برای دریافت تغییرات یا ثبت از سمت ربات بله ---------- // ---------- پولینگ دوره‌ای برای دریافت تغییرات یا ثبت از سمت ربات بله ----------
useEffect(() => { useEffect(() => {
const draftId = params.get("draft") || params.get("draft_id"); const draftId = params.get("draft") || params.get("draft_id");
if (!draftId) return; if (!token && !draftId) return;
const interval = setInterval(async () => { const interval = setInterval(async () => {
if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return; if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
try { try {
const draft = await api.drafts.get(draftId); let draft: any = null;
if (token) {
const res = await api.drafts.verifyToken(token);
draft = (res as any)?.data || res;
} else if (draftId) {
draft = await api.drafts.get(draftId);
}
if (!draft) return; if (!draft) return;
if (draft.status === "committed") { if (draft.status === "committed") {
isCommittedOrCancelledRef.current = true; isCommittedOrCancelledRef.current = true;
if (isStandaloneMode) {
setExitInfo({
show: true,
title: "این پیشنهاد ثبت نهایی شد ✅",
subtitle: "استعلام با موفقیت در سیستم ثبت گردید. می‌توانید این برگه را ببندید و به ربات بله بازگردید.",
});
return;
}
toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 }); toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 });
navigate("/"); navigate("/");
return; return;
} }
if (draft.status === "cancelled") { if (draft.status === "cancelled") {
isCommittedOrCancelledRef.current = true; isCommittedOrCancelledRef.current = true;
if (isStandaloneMode) {
setExitInfo({
show: true,
title: "پیش‌نویس لغو شد",
subtitle: "این پیش‌نویس از طریق ربات بله لغو گردید. می‌توانید این برگه را ببندید.",
});
return;
}
toast.error("این پیش‌نویس از طریق ربات بله لغو شد.", { duration: 6000 }); toast.error("این پیش‌نویس از طریق ربات بله لغو شد.", { duration: 6000 });
navigate("/"); navigate("/");
return; return;
@@ -279,12 +414,12 @@ export function WizardPage() {
}, 2000); }, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [params, navigate, applyDraftPayloadToForm]); }, [params, token, isStandaloneMode, navigate, applyDraftPayloadToForm]);
// ---------- همگام‌سازی خودکار تغییرات ویزارد به پیش‌نویس ربات (Debounced Auto-Save) ---------- // ---------- همگام‌سازی خودکار تغییرات ویزارد به پیش‌نویس ربات (Debounced Auto-Save) ----------
useEffect(() => { useEffect(() => {
const draftId = params.get("draft") || params.get("draft_id"); const draftId = params.get("draft") || params.get("draft_id");
if (!draftId || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return; if ((!token && !draftId) || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
isSelfUpdatingRef.current = true; isSelfUpdatingRef.current = true;
@@ -312,10 +447,19 @@ export function WizardPage() {
expiry_date: expiryDate || null, expiry_date: expiryDate || null,
}; };
await api.drafts.update(draftId, payloadToSave); if (token) {
const updated = await api.drafts.get(draftId); await api.drafts.updateByToken(token, payloadToSave);
if (updated?.updated_at) { const res = await api.drafts.verifyToken(token);
lastRemoteUpdatedAtRef.current = updated.updated_at; const updated = (res as any)?.data || res;
if (updated?.updated_at) {
lastRemoteUpdatedAtRef.current = updated.updated_at;
}
} else if (draftId) {
await api.drafts.update(draftId, payloadToSave);
const updated = await api.drafts.get(draftId);
if (updated?.updated_at) {
lastRemoteUpdatedAtRef.current = updated.updated_at;
}
} }
} catch (err) { } catch (err) {
console.warn("Auto-save to draft failed:", err); console.warn("Auto-save to draft failed:", err);
@@ -327,7 +471,7 @@ export function WizardPage() {
}, 800); }, 800);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params]); }, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params, token]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- // ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
const completedSteps = useMemo(() => { const completedSteps = useMemo(() => {
@@ -607,8 +751,16 @@ export function WizardPage() {
try { try {
const created = await createQuote.mutateAsync(quote); const created = await createQuote.mutateAsync(quote);
const token = params.get("token");
const draftId = params.get("draft") || params.get("draft_id"); const draftId = params.get("draft") || params.get("draft_id");
if (draftId) { if (token) {
isCommittedOrCancelledRef.current = true;
try {
await api.drafts.commitByToken(token, created.id);
} catch (err) {
console.warn("Could not commit draft via token:", err);
}
} else if (draftId) {
isCommittedOrCancelledRef.current = true; isCommittedOrCancelledRef.current = true;
try { try {
await api.drafts.commit(draftId, created.id); await api.drafts.commit(draftId, created.id);
@@ -627,6 +779,15 @@ export function WizardPage() {
} }
toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید."); toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید.");
track("quote_submitted", { quote_id: created.id, items: lineItems.length }); track("quote_submitted", { quote_id: created.id, items: lineItems.length });
if (isStandaloneMode) {
setExitInfo({
show: true,
title: "پیشنهاد با موفقیت در سیستم ثبت گردید ✅",
subtitle: `شناسه استعلام: #${created.id.slice(0, 8)} — تغییرات شما با موفقیت اعمال شد. اکنون می‌توانید این برگه را ببندید و به ربات بله بازگردید.`,
});
return;
}
navigate("/"); navigate("/");
} catch (e) { } catch (e) {
const target = resolveErrorTarget(e); const target = resolveErrorTarget(e);
@@ -635,15 +796,30 @@ export function WizardPage() {
}; };
const handleCancel = async () => { const handleCancel = async () => {
const token = params.get("token");
const draftId = params.get("draft") || params.get("draft_id"); const draftId = params.get("draft") || params.get("draft_id");
if (draftId) { isCommittedOrCancelledRef.current = true;
isCommittedOrCancelledRef.current = true; if (token) {
try {
await api.drafts.cancelByToken(token);
} catch (err) {
console.warn("Could not cancel draft via token:", err);
}
} else if (draftId) {
try { try {
await api.drafts.cancel(draftId); await api.drafts.cancel(draftId);
} catch (err) { } catch (err) {
console.warn("Could not cancel draft:", err); console.warn("Could not cancel draft:", err);
} }
} }
if (isStandaloneMode) {
setExitInfo({
show: true,
title: "پیش‌نویس لغو شد",
subtitle: "این پیش‌نویس استعلام لغو گردید. می‌توانید این برگه را ببندید و به ربات بله بازگردید.",
});
return;
}
navigate("/"); navigate("/");
}; };
@@ -661,14 +837,55 @@ export function WizardPage() {
void doSave(); void doSave();
}; };
if (exitInfo.show) {
return <SafeExitScreen title={exitInfo.title} subtitle={exitInfo.subtitle} />;
}
if (tokenError) {
return <TokenInvalidScreen message={tokenError} />;
}
return ( return (
<div className="min-h-screen pb-10"> <div className="min-h-screen pb-10">
<Header {isStandaloneMode ? (
title="افزودن پیشنهاد جدید" <header className="sticky top-0 z-40 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-md border-b border-line px-4 py-3.5 shadow-xs">
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید" <div className="max-w-5xl mx-auto flex items-center justify-between">
backTo="/" <div className="flex items-center gap-3">
backLabel="بازگشت به دفتر" <div className="h-9 w-9 rounded-xl bg-blue-600 text-white flex items-center justify-center shadow-xs">
/> <ShieldCheck className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-base font-bold text-ink">
بازبینی و تکمیل پیش‌نویس استعلام
</h1>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 dark:bg-blue-950/80 dark:text-blue-300">
ربات بله
</span>
</div>
<p className="text-xs text-muted mt-0.5">
محیط امن بازبینی — اطلاعات با ربات بله همگام‌سازی می‌شود
</p>
</div>
</div>
<button
type="button"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted hover:text-rose-600 rounded-xl hover:bg-panel transition-colors"
onClick={handleCancel}
>
<X className="w-4 h-4" />
<span>انصراف و خروج</span>
</button>
</div>
</header>
) : (
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
backTo="/"
backLabel="بازگشت به دفتر"
/>
)}
<PageLayout> <PageLayout>
{loadedDraftId && ( {loadedDraftId && (
<div className="mb-4 flex items-center justify-between rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/40 dark:text-blue-200 shadow-xs"> <div className="mb-4 flex items-center justify-between rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/40 dark:text-blue-200 shadow-xs">