diff --git a/ai procurement/app/api/client.py b/ai procurement/app/api/client.py index dca7026..9dee7e2 100644 --- a/ai procurement/app/api/client.py +++ b/ai procurement/app/api/client.py @@ -135,3 +135,16 @@ class ErpClient: return (await self._request( "PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id )).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 {} diff --git a/ai procurement/app/config.py b/ai procurement/app/config.py index 7c6acd6..10f9d5c 100644 --- a/ai procurement/app/config.py +++ b/ai procurement/app/config.py @@ -31,6 +31,13 @@ class Settings(BaseSettings): # Frontend URL (for opening Wizard from Bale bot) 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. AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1 AI_API_KEY: str = "" diff --git a/ai procurement/app/handlers/__init__.py b/ai procurement/app/handlers/__init__.py index 20adfd7..1d74591 100644 --- a/ai procurement/app/handlers/__init__.py +++ b/ai procurement/app/handlers/__init__.py @@ -14,6 +14,7 @@ from app import drafts as draft_store from app.config import settings from app.nlu import understand from app.runtime import BotContext, LinkedUser +from app.storage import save_proforma_file from app.stt import transcribe, transcribe_multi 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: - text = (message.get("text") or "").strip() + text = (message.get("text") or message.get("caption") or "").strip() source = "text" voice_transcripts: list[dict] | None = None + message_id = message.get("message_id") if text.startswith("/"): await commands.handle_command(ctx, chat_id, text) 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. flow = ctx.pending.get(chat_id) if flow: @@ -79,11 +60,43 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: if user is None: return # gate either started linking or is mid-linking - message_id = message.get("message_id") reply_to = message.get("reply_to_message") 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: target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id) if target_draft is None: @@ -94,6 +107,92 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: ) 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 input_payload = voice_transcripts if voice_transcripts else text result = await understand(settings, input_payload, draft_summary) diff --git a/ai procurement/app/handlers/quote.py b/ai procurement/app/handlers/quote.py index 696ee1e..378cbdf 100644 --- a/ai procurement/app/handlers/quote.py +++ b/ai procurement/app/handlers/quote.py @@ -198,9 +198,17 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non reply_to: int | None = None) -> None: from app.config import settings + from app.security import create_draft_token + payload = draft.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 += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{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.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: try: - quote_id = (created or {}).get("id") or "" msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}" await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None) await draft_store.set_bale_updated_at(draft.id) diff --git a/ai procurement/app/security.py b/ai procurement/app/security.py new file mode 100644 index 0000000..df49445 --- /dev/null +++ b/ai procurement/app/security.py @@ -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 diff --git a/ai procurement/app/storage.py b/ai procurement/app/storage.py new file mode 100644 index 0000000..4450aca --- /dev/null +++ b/ai procurement/app/storage.py @@ -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(), + } diff --git a/backend/app/config.py b/backend/app/config.py index c2d666a..fb3a4bd 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -40,6 +40,9 @@ class Settings(BaseSettings): MINIO_BUCKET: str = "procurement-files" 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. AUTO_EXPIRE_ENABLED: bool = True AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600 diff --git a/backend/app/routers/drafts.py b/backend/app/routers/drafts.py index b809262..7517120 100644 --- a/backend/app/routers/drafts.py +++ b/backend/app/routers/drafts.py @@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db.engine import get_db 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"]) @@ -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}") async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)): stmt = select(BotDraft).where(BotDraft.id == draft_id) diff --git a/backend/app/utils/draft_token.py b/backend/app/utils/draft_token.py new file mode 100644 index 0000000..974539c --- /dev/null +++ b/backend/app/utils/draft_token.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8fa9445..d9eb7f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -83,6 +83,7 @@ export default function App() { }> + } /> } /> ; return null; } - if (pathname === "/wizard") return null; + if (pathname === "/wizard" || pathname.startsWith("/review")) return null; const onOfficePage = pathname === "/" || pathname === "/projects" || diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d17fe3f..7fdfdea 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1053,6 +1053,38 @@ export const api = { ): Promise => { await postData(`/drafts/${id}/cancel`, {}); }, + verifyToken: async ( + token: string, + ): Promise<{ + id: string; + chat_id: number; + kind: string; + status: string; + payload: Record; + 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, + ): Promise => { + await patchData(`/drafts/by-token?token=${encodeURIComponent(token)}`, { payload }); + }, + commitByToken: async ( + token: string, + quoteId?: string, + ): Promise => { + await postData(`/drafts/by-token/commit?token=${encodeURIComponent(token)}`, { quote_id: quoteId }); + }, + cancelByToken: async ( + token: string, + ): Promise => { + await postData(`/drafts/by-token/cancel?token=${encodeURIComponent(token)}`, {}); + }, }, suppliers: { diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index 91a611f..f7d1ebb 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { track } from "../../lib/analytics"; 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 type { LineItem, Quote } from "../../lib/types"; import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api"; @@ -39,11 +39,111 @@ import { const TOTAL_STEPS = 5; -// ویزارد ۵ مرحله‌ای افزودن پیشنهاد جدید -export function WizardPage() { +// ---------- صفحه خروج امن (Safe Exit Screen) ---------- +function SafeExitScreen({ + title, + subtitle, +}: { + title?: string; + subtitle?: string; +}) { + return ( +
+
+
+ +
+

+ {title || "پیش‌نویس استعلام ذخیره گردید"} +

+

+ {subtitle || + "اطلاعات با موفقیت ذخیره شد. اکنون می‌توانید این برگه را ببندید و برای ادامه کار به ربات بله بازگردید."} +

+
+ +

+ (در صورت مسدود بودن بستن خودکار توسط مرورگر، لطفاً تب مرورگر را به صورت دستی ببندید) +

+
+
+
+ ); +} + +// ---------- صفحه خطای توکن نامعتبر ---------- +function TokenInvalidScreen({ message }: { message: string }) { + return ( +
+
+
+ +
+

+ لینک نامعتبر یا منقضی شده +

+

+ {message || + "اعتبار این لینک بازبینی به پایان رسیده است. لطفاً از طریق ربات بله، مجدداً دکمه بازبینی را دریافت فرمایید."} +

+ +
+
+ ); +} + +// ویزارد ۵ مرحله‌ای افزودن پیشنهاد جدید و بازبینی پیش‌نویس +export function WizardPage({ isStandalone = false }: { isStandalone?: boolean }) { useStoreData(); const navigate = useNavigate(); 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(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 suppliers = useSuppliers(); @@ -214,16 +314,25 @@ export function WizardPage() { } }, [params, suppliers, topics]); - // ---------- بارگذاری اولیه پیش‌نویس ربات بله (?draft=&draft_id=) ---------- + // ---------- بارگذاری اولیه پیش‌نویس ربات بله (?token= یا ?draft=) ---------- useEffect(() => { const draftId = params.get("draft") || params.get("draft_id"); - if (!draftId || draftFetchedRef.current) return; + if ((!token && !draftId) || draftFetchedRef.current) return; draftFetchedRef.current = true; void (async () => { try { - const draft = await api.drafts.get(draftId); - if (!draft || !draft.payload) return; + 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 || !draft.payload) { + if (token) setTokenError("پیش‌نویس معتبری یافت نشد."); + return; + } setLoadedDraftId(draft.id); lastRemoteUpdatedAtRef.current = draft.updated_at || null; @@ -234,31 +343,57 @@ export function WizardPage() { toast.success("پیش‌نویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", { duration: 6000, }); - } catch (err) { - toast.error(`خطا در فراخوانی پیش‌نویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`); + } catch (err: any) { + if (token) { + setTokenError(err?.message || "لینک بازبینی نامعتبر یا منقضی شده است."); + } else { + toast.error(`خطا در فراخوانی پیش‌نویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`); + } } })(); - }, [params, applyDraftPayloadToForm]); + }, [params, token, applyDraftPayloadToForm]); // ---------- پولینگ دوره‌ای برای دریافت تغییرات یا ثبت از سمت ربات بله ---------- useEffect(() => { const draftId = params.get("draft") || params.get("draft_id"); - if (!draftId) return; + if (!token && !draftId) return; const interval = setInterval(async () => { if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return; 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.status === "committed") { isCommittedOrCancelledRef.current = true; + if (isStandaloneMode) { + setExitInfo({ + show: true, + title: "این پیشنهاد ثبت نهایی شد ✅", + subtitle: "استعلام با موفقیت در سیستم ثبت گردید. می‌توانید این برگه را ببندید و به ربات بله بازگردید.", + }); + return; + } toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 }); navigate("/"); return; } if (draft.status === "cancelled") { isCommittedOrCancelledRef.current = true; + if (isStandaloneMode) { + setExitInfo({ + show: true, + title: "پیش‌نویس لغو شد", + subtitle: "این پیش‌نویس از طریق ربات بله لغو گردید. می‌توانید این برگه را ببندید.", + }); + return; + } toast.error("این پیش‌نویس از طریق ربات بله لغو شد.", { duration: 6000 }); navigate("/"); return; @@ -279,12 +414,12 @@ export function WizardPage() { }, 2000); return () => clearInterval(interval); - }, [params, navigate, applyDraftPayloadToForm]); + }, [params, token, isStandaloneMode, navigate, applyDraftPayloadToForm]); // ---------- همگام‌سازی خودکار تغییرات ویزارد به پیش‌نویس ربات (Debounced Auto-Save) ---------- useEffect(() => { 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 () => { isSelfUpdatingRef.current = true; @@ -312,10 +447,19 @@ export function WizardPage() { expiry_date: expiryDate || null, }; - await api.drafts.update(draftId, payloadToSave); - const updated = await api.drafts.get(draftId); - if (updated?.updated_at) { - lastRemoteUpdatedAtRef.current = updated.updated_at; + if (token) { + await api.drafts.updateByToken(token, payloadToSave); + const res = await api.drafts.verifyToken(token); + 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) { console.warn("Auto-save to draft failed:", err); @@ -327,7 +471,7 @@ export function WizardPage() { }, 800); return () => clearTimeout(timer); - }, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params]); + }, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params, token]); // ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- const completedSteps = useMemo(() => { @@ -607,8 +751,16 @@ export function WizardPage() { try { const created = await createQuote.mutateAsync(quote); + const token = params.get("token"); 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; try { await api.drafts.commit(draftId, created.id); @@ -627,6 +779,15 @@ export function WizardPage() { } toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید."); track("quote_submitted", { quote_id: created.id, items: lineItems.length }); + + if (isStandaloneMode) { + setExitInfo({ + show: true, + title: "پیشنهاد با موفقیت در سیستم ثبت گردید ✅", + subtitle: `شناسه استعلام: #${created.id.slice(0, 8)} — تغییرات شما با موفقیت اعمال شد. اکنون می‌توانید این برگه را ببندید و به ربات بله بازگردید.`, + }); + return; + } navigate("/"); } catch (e) { const target = resolveErrorTarget(e); @@ -635,15 +796,30 @@ export function WizardPage() { }; const handleCancel = async () => { + const token = params.get("token"); 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 { await api.drafts.cancel(draftId); } catch (err) { console.warn("Could not cancel draft:", err); } } + if (isStandaloneMode) { + setExitInfo({ + show: true, + title: "پیش‌نویس لغو شد", + subtitle: "این پیش‌نویس استعلام لغو گردید. می‌توانید این برگه را ببندید و به ربات بله بازگردید.", + }); + return; + } navigate("/"); }; @@ -661,14 +837,55 @@ export function WizardPage() { void doSave(); }; + if (exitInfo.show) { + return ; + } + + if (tokenError) { + return ; + } + return (
-
+ {isStandaloneMode ? ( +
+
+
+
+ +
+
+
+

+ بازبینی و تکمیل پیش‌نویس استعلام +

+ + ربات بله + +
+

+ محیط امن بازبینی — اطلاعات با ربات بله همگام‌سازی می‌شود +

+
+
+ +
+
+ ) : ( +
+ )} {loadedDraftId && (