diff --git a/.env.example b/.env.example index e69de29..9cacb22 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,4 @@ +SCRAPER_SCHEDULE_ENABLED=false +SIMILARITY_DEVICE=cpu +SIMILARITY_MIN_RESULTS=10 +SCRAPER_MAX_RESULTS=25 \ No newline at end of file diff --git a/README.md b/README.md index 1ce71d1..9f4dca1 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,62 @@ A Bangla-first AI commerce OS for marketplace sellers. Monitors competitor price The frontend currently stores JWTs in `localStorage`, which is vulnerable to token theft if an XSS occurs. For production deployments, prefer httpOnly cookies (or another mitigated storage strategy) and enforce a strong CSP + input sanitization to reduce XSS risk. Database bootstrap helpers run only when `DB_BOOTSTRAP_ON_STARTUP` is enabled (defaults to `true` in development and `false` otherwise). Use migrations for predictable schema changes in multi-instance environments. + +## Telegram product intelligence snapshots + +**Frontend flow** +1. Open **Telegram Alerts**. +2. In **Product Intelligence Snapshot**, select a product and click **Send Snapshot**. +3. A success toast appears and the realtime activity feed updates. + +**API endpoint** +`POST /api/v1/telegram/send-product-snapshot` + +**cURL example** +```bash +curl -X POST http://localhost:8000/api/v1/telegram/send-product-snapshot \ + -H "Authorization: Bearer TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"product_id":"UUID"}' +``` + +**Postman example** +1. Method: `POST` +2. URL: `http://localhost:8000/api/v1/telegram/send-product-snapshot` +3. Headers: `Authorization: Bearer TOKEN`, `Content-Type: application/json` +4. Body (raw JSON): `{"product_id":"UUID"}` + +**Expected Telegram output** +``` +📊 BazarSync AI Product Intelligence + +🛒 Product: +Dove Body Lotion 200ml + +💰 Current Price: +৳320 + +📦 Current Stock: +45 units + +🚚 Supplier Lead Time: +5 days + +📉 Competitor Median Price: +৳305 + +⚠️ Price Gap: +-4.7% + +🤖 Latest AI Recommendation: +PRICE LOWER + +🧠 AI Reason: +Competitor prices are currently lower while 2 sellers are out-of-stock. Lowering price slightly may capture additional demand. + +📈 Confidence: +HIGH + +⏰ Updated: +2026-05-28 10:45 PM +``` diff --git a/backend/alembic/env.py b/backend/alembic/env.py index ae2cd52..c2f7ae5 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -3,20 +3,28 @@ from sqlalchemy import engine_from_config, pool from alembic import context +from dotenv import load_dotenv # Import your SQLAlchemy Base from app.db.base import Base # Import ALL models so Alembic can detect them -from app.models.seller import Seller -from app.models.product import Product -from app.models.scrape_run import ScrapeRun +from app.models.alert import Alert +from app.models.alert_delivery_log import AlertDeliveryLog from app.models.competitor import Competitor from app.models.price_history import PriceHistory +from app.models.product import Product +from app.models.recommendation import Recommendation +from app.models.scrape_run import ScrapeRun +from app.models.seller import Seller +from app.models.telegram_connection import SellerTelegramConnection +from app.models.telegram_snapshot_log import TelegramProductSnapshotLog # Alembic Config object config = context.config +load_dotenv() + # Load DATABASE_URL from environment variable database_url = os.getenv("DATABASE_URL") @@ -83,11 +91,4 @@ def run_migrations_online() -> None: if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() - -import os - -config.set_main_option( - "sqlalchemy.url", - os.getenv("DATABASE_URL") -) \ No newline at end of file + run_migrations_online() \ No newline at end of file diff --git a/backend/alembic/versions/1c8c3e4d9a5b_add_product_and_competitor_metadata.py b/backend/alembic/versions/1c8c3e4d9a5b_add_product_and_competitor_metadata.py index 3983b59..f8d0037 100644 --- a/backend/alembic/versions/1c8c3e4d9a5b_add_product_and_competitor_metadata.py +++ b/backend/alembic/versions/1c8c3e4d9a5b_add_product_and_competitor_metadata.py @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision: str = "1c8c3e4d9a5b" -down_revision: Union[str, Sequence[str], None] = "9ba821df6da5" +down_revision: Union[str, Sequence[str], None] = "d4f5a8c9b2e1" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/alembic/versions/4f3b6e8a92b1_add_telegram_connections_and_delivery_logs.py b/backend/alembic/versions/4f3b6e8a92b1_add_telegram_connections_and_delivery_logs.py new file mode 100644 index 0000000..38d00aa --- /dev/null +++ b/backend/alembic/versions/4f3b6e8a92b1_add_telegram_connections_and_delivery_logs.py @@ -0,0 +1,173 @@ +"""add telegram connections and delivery logs + +Revision ID: 4f3b6e8a92b1 +Revises: 1c8c3e4d9a5b +Create Date: 2026-05-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "4f3b6e8a92b1" +down_revision: Union[str, Sequence[str], None] = "1c8c3e4d9a5b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "seller_telegram_connections" not in inspector.get_table_names(): + op.create_table( + "seller_telegram_connections", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("seller_id", postgresql.UUID(as_uuid=True), nullable=False, unique=True), + sa.Column("telegram_chat_id", sa.String(length=120), nullable=True), + sa.Column("telegram_username", sa.String(length=120), nullable=True), + sa.Column("telegram_first_name", sa.String(length=120), nullable=True), + sa.Column("telegram_last_name", sa.String(length=120), nullable=True), + sa.Column("connection_token", sa.String(length=128), nullable=True), + sa.Column("token_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "is_connected", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column("connected_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_alert_sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["seller_id"], ["sellers.id"], ondelete="CASCADE"), + ) + existing_indexes: set[str] = set() + else: + existing_indexes = { + index["name"] + for index in inspector.get_indexes("seller_telegram_connections") + } + + if "ix_seller_telegram_connections_seller_id" not in existing_indexes: + op.create_index( + "ix_seller_telegram_connections_seller_id", + "seller_telegram_connections", + ["seller_id"], + unique=True, + ) + if "ix_seller_telegram_connections_token" not in existing_indexes: + op.create_index( + "ix_seller_telegram_connections_token", + "seller_telegram_connections", + ["connection_token"], + unique=False, + ) + if "ix_seller_telegram_connections_chat_id" not in existing_indexes: + op.create_index( + "ix_seller_telegram_connections_chat_id", + "seller_telegram_connections", + ["telegram_chat_id"], + unique=False, + ) + if "ix_seller_telegram_connections_is_connected" not in existing_indexes: + op.create_index( + "ix_seller_telegram_connections_is_connected", + "seller_telegram_connections", + ["is_connected"], + unique=False, + ) + if "alerts" in inspector.get_table_names(): + op.create_table( + "alert_delivery_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("seller_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("alert_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("telegram_chat_id", sa.String(length=120), nullable=True), + sa.Column( + "delivery_status", + sa.Enum( + "delivered", + "failed", + "pending", + "duplicate", + name="alert_delivery_status", + ), + nullable=False, + server_default="pending", + ), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["seller_id"], ["sellers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["alert_id"], ["alerts.id"], ondelete="CASCADE"), + ) + op.create_index( + "ix_alert_delivery_logs_seller_id", + "alert_delivery_logs", + ["seller_id"], + unique=False, + ) + op.create_index( + "ix_alert_delivery_logs_alert_id", + "alert_delivery_logs", + ["alert_id"], + unique=False, + ) + op.create_index( + "ix_alert_delivery_logs_status", + "alert_delivery_logs", + ["delivery_status"], + unique=False, + ) + op.create_index( + "ix_alert_delivery_logs_sent_at", + "alert_delivery_logs", + ["sent_at"], + unique=False, + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "alert_delivery_logs" in inspector.get_table_names(): + op.drop_index("ix_alert_delivery_logs_sent_at", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_status", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_alert_id", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_seller_id", table_name="alert_delivery_logs") + op.drop_table("alert_delivery_logs") + + if "seller_telegram_connections" in inspector.get_table_names(): + op.drop_index( + "ix_seller_telegram_connections_is_connected", + table_name="seller_telegram_connections", + ) + op.drop_index( + "ix_seller_telegram_connections_chat_id", + table_name="seller_telegram_connections", + ) + op.drop_index( + "ix_seller_telegram_connections_token", + table_name="seller_telegram_connections", + ) + op.drop_index( + "ix_seller_telegram_connections_seller_id", + table_name="seller_telegram_connections", + ) + op.drop_table("seller_telegram_connections") diff --git a/backend/alembic/versions/7d2c3f6a9b0e_add_snapshot_logs.py b/backend/alembic/versions/7d2c3f6a9b0e_add_snapshot_logs.py new file mode 100644 index 0000000..201d341 --- /dev/null +++ b/backend/alembic/versions/7d2c3f6a9b0e_add_snapshot_logs.py @@ -0,0 +1,62 @@ +"""add telegram snapshot logs + +Revision ID: 7d2c3f6a9b0e +Revises: 4f3b6e8a92b1 +Create Date: 2026-05-28 23:59:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "7d2c3f6a9b0e" +down_revision: Union[str, Sequence[str], None] = "4f3b6e8a92b1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "snapshot_logs" not in inspector.get_table_names(): + op.create_table( + "snapshot_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("seller_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("telegram_chat_id", sa.String(length=120), nullable=False), + sa.Column( + "sent_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("error_message", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["seller_id"], ["sellers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"), + ) + op.create_index( + "ix_snapshot_logs_seller_id", "snapshot_logs", ["seller_id"], unique=False + ) + op.create_index( + "ix_snapshot_logs_product_id", "snapshot_logs", ["product_id"], unique=False + ) + op.create_index( + "ix_snapshot_logs_status", "snapshot_logs", ["status"], unique=False + ) + op.create_index( + "ix_snapshot_logs_sent_at", "snapshot_logs", ["sent_at"], unique=False + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "snapshot_logs" in inspector.get_table_names(): + op.drop_index("ix_snapshot_logs_sent_at", table_name="snapshot_logs") + op.drop_index("ix_snapshot_logs_status", table_name="snapshot_logs") + op.drop_index("ix_snapshot_logs_product_id", table_name="snapshot_logs") + op.drop_index("ix_snapshot_logs_seller_id", table_name="snapshot_logs") + op.drop_table("snapshot_logs") diff --git a/backend/alembic/versions/91e0e754a8e6_merge_heads.py b/backend/alembic/versions/91e0e754a8e6_merge_heads.py new file mode 100644 index 0000000..5e9e1cb --- /dev/null +++ b/backend/alembic/versions/91e0e754a8e6_merge_heads.py @@ -0,0 +1,28 @@ +"""merge heads + +Revision ID: 91e0e754a8e6 +Revises: 9b2d6a4d2a19, 7d2c3f6a9b0e +Create Date: 2026-05-29 00:52:16.179202 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '91e0e754a8e6' +down_revision: Union[str, Sequence[str], None] = ('9b2d6a4d2a19', '7d2c3f6a9b0e') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/backend/alembic/versions/9b2d6a4d2a19_create_alert_delivery_logs.py b/backend/alembic/versions/9b2d6a4d2a19_create_alert_delivery_logs.py new file mode 100644 index 0000000..07d078a --- /dev/null +++ b/backend/alembic/versions/9b2d6a4d2a19_create_alert_delivery_logs.py @@ -0,0 +1,91 @@ +"""create alert delivery logs + +Revision ID: 9b2d6a4d2a19 +Revises: c6e6f2b1b19a +Create Date: 2026-05-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "9b2d6a4d2a19" +down_revision: Union[str, Sequence[str], None] = "c6e6f2b1b19a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "alerts" not in tables or "alert_delivery_logs" in tables: + return + + op.create_table( + "alert_delivery_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("seller_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("alert_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("telegram_chat_id", sa.String(length=120), nullable=True), + sa.Column( + "delivery_status", + postgresql.ENUM( + "delivered", + "failed", + "pending", + "duplicate", + name="alert_delivery_status", + create_type=False, + ), + nullable=False, + server_default=sa.text("'pending'"), + ), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["seller_id"], ["sellers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["alert_id"], ["alerts.id"], ondelete="CASCADE"), + ) + op.create_index( + "ix_alert_delivery_logs_seller_id", + "alert_delivery_logs", + ["seller_id"], + unique=False, + ) + op.create_index( + "ix_alert_delivery_logs_alert_id", + "alert_delivery_logs", + ["alert_id"], + unique=False, + ) + op.create_index( + "ix_alert_delivery_logs_status", + "alert_delivery_logs", + ["delivery_status"], + unique=False, + ) + op.create_index( + "ix_alert_delivery_logs_sent_at", + "alert_delivery_logs", + ["sent_at"], + unique=False, + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "alert_delivery_logs" not in inspector.get_table_names(): + return + op.drop_index("ix_alert_delivery_logs_sent_at", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_status", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_alert_id", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_seller_id", table_name="alert_delivery_logs") + op.drop_table("alert_delivery_logs") diff --git a/backend/alembic/versions/c6e6f2b1b19a_create_alerts_table.py b/backend/alembic/versions/c6e6f2b1b19a_create_alerts_table.py new file mode 100644 index 0000000..0d98ec7 --- /dev/null +++ b/backend/alembic/versions/c6e6f2b1b19a_create_alerts_table.py @@ -0,0 +1,250 @@ +"""create alerts table + +Revision ID: c6e6f2b1b19a +Revises: 4f3b6e8a92b1 +Create Date: 2026-05-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "c6e6f2b1b19a" +down_revision: Union[str, Sequence[str], None] = "4f3b6e8a92b1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + enum_names = {enum["name"] for enum in inspector.get_enums()} + + _ensure_enum( + enum_names, + "alert_urgency_level", + ["LOW", "MEDIUM", "HIGH", "CRITICAL"], + ) + _ensure_enum( + enum_names, + "alert_action_type", + [ + "PRICE_LOWER", + "PRICE_RAISE", + "HOLD", + "REORDER", + "STOCK_WARNING", + "OPPORTUNITY_CAPTURE", + ], + ) + _ensure_enum(enum_names, "alert_confidence", ["LOW", "MEDIUM", "HIGH"]) + _ensure_enum( + enum_names, + "alert_delivery_status", + ["delivered", "failed", "pending", "duplicate"], + ) + + existing_tables = set(inspector.get_table_names()) + if "alerts" not in existing_tables: + op.create_table( + "alerts", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("seller_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("recommendation_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column( + "alert_type", + sa.String(length=120), + nullable=False, + server_default=sa.text("'urgency_alert'"), + ), + sa.Column( + "urgency_level", + postgresql.ENUM( + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL", + name="alert_urgency_level", + create_type=False, + ), + nullable=False, + server_default=sa.text("'MEDIUM'"), + ), + sa.Column( + "action_type", + postgresql.ENUM( + "PRICE_LOWER", + "PRICE_RAISE", + "HOLD", + "REORDER", + "STOCK_WARNING", + "OPPORTUNITY_CAPTURE", + name="alert_action_type", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "confidence", + postgresql.ENUM( + "LOW", + "MEDIUM", + "HIGH", + name="alert_confidence", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "delivery_status", + postgresql.ENUM( + "delivered", + "failed", + "pending", + "duplicate", + name="alert_delivery_status", + create_type=False, + ), + nullable=False, + server_default=sa.text("'pending'"), + ), + sa.Column("telegram_message_id", sa.String(length=120), nullable=True), + sa.Column("telegram_chat_id", sa.String(length=120), nullable=True), + sa.Column("delivery_error", sa.Text(), nullable=True), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["seller_id"], ["sellers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["recommendation_id"], + ["recommendations.id"], + ondelete="SET NULL", + ), + ) + existing_indexes: set[str] = set() + else: + existing_indexes = {index["name"] for index in inspector.get_indexes("alerts")} + + if "ix_alerts_seller_id" not in existing_indexes: + op.create_index("ix_alerts_seller_id", "alerts", ["seller_id"], unique=False) + if "ix_alerts_product_id" not in existing_indexes: + op.create_index("ix_alerts_product_id", "alerts", ["product_id"], unique=False) + if "ix_alerts_recommendation_id" not in existing_indexes: + op.create_index( + "ix_alerts_recommendation_id", "alerts", ["recommendation_id"], unique=False + ) + if "ix_alerts_delivery_status" not in existing_indexes: + op.create_index( + "ix_alerts_delivery_status", "alerts", ["delivery_status"], unique=False + ) + if "ix_alerts_urgency_level" not in existing_indexes: + op.create_index("ix_alerts_urgency_level", "alerts", ["urgency_level"], unique=False) + if "ix_alerts_created_at" not in existing_indexes: + op.create_index("ix_alerts_created_at", "alerts", ["created_at"], unique=False) + + existing_tables = set(inspector.get_table_names()) + if "alerts" in existing_tables and "alert_delivery_logs" not in existing_tables: + op.create_table( + "alert_delivery_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("seller_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("alert_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("telegram_chat_id", sa.String(length=120), nullable=True), + sa.Column( + "delivery_status", + postgresql.ENUM( + "delivered", + "failed", + "pending", + "duplicate", + name="alert_delivery_status", + create_type=False, + ), + nullable=False, + server_default=sa.text("'pending'"), + ), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["seller_id"], ["sellers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["alert_id"], ["alerts.id"], ondelete="CASCADE"), + ) + existing_tables.add("alert_delivery_logs") + log_indexes: set[str] = set() + else: + log_indexes = { + index["name"] for index in inspector.get_indexes("alert_delivery_logs") + } if "alert_delivery_logs" in existing_tables else set() + + if "alert_delivery_logs" in existing_tables: + if "ix_alert_delivery_logs_seller_id" not in log_indexes: + op.create_index( + "ix_alert_delivery_logs_seller_id", + "alert_delivery_logs", + ["seller_id"], + unique=False, + ) + if "ix_alert_delivery_logs_alert_id" not in log_indexes: + op.create_index( + "ix_alert_delivery_logs_alert_id", + "alert_delivery_logs", + ["alert_id"], + unique=False, + ) + if "ix_alert_delivery_logs_status" not in log_indexes: + op.create_index( + "ix_alert_delivery_logs_status", + "alert_delivery_logs", + ["delivery_status"], + unique=False, + ) + if "ix_alert_delivery_logs_sent_at" not in log_indexes: + op.create_index( + "ix_alert_delivery_logs_sent_at", + "alert_delivery_logs", + ["sent_at"], + unique=False, + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "alert_delivery_logs" in inspector.get_table_names(): + op.drop_index("ix_alert_delivery_logs_sent_at", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_status", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_alert_id", table_name="alert_delivery_logs") + op.drop_index("ix_alert_delivery_logs_seller_id", table_name="alert_delivery_logs") + op.drop_table("alert_delivery_logs") + + if "alerts" in inspector.get_table_names(): + op.drop_index("ix_alerts_created_at", table_name="alerts") + op.drop_index("ix_alerts_urgency_level", table_name="alerts") + op.drop_index("ix_alerts_delivery_status", table_name="alerts") + op.drop_index("ix_alerts_recommendation_id", table_name="alerts") + op.drop_index("ix_alerts_product_id", table_name="alerts") + op.drop_index("ix_alerts_seller_id", table_name="alerts") + op.drop_table("alerts") + + +def _ensure_enum(enum_names: set[str], name: str, values: list[str]) -> None: + if name in enum_names: + return + values_sql = ", ".join(f"'{value}'" for value in values) + op.execute(sa.text(f"CREATE TYPE {name} AS ENUM ({values_sql})")) + enum_names.add(name) diff --git a/backend/alembic/versions/d4f5a8c9b2e1_placeholder_revision.py b/backend/alembic/versions/d4f5a8c9b2e1_placeholder_revision.py new file mode 100644 index 0000000..8c6953a --- /dev/null +++ b/backend/alembic/versions/d4f5a8c9b2e1_placeholder_revision.py @@ -0,0 +1,24 @@ +"""placeholder revision to repair migration chain. + +Revision ID: d4f5a8c9b2e1 +Revises: 9ba821df6da5 +Create Date: 2026-05-28 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d4f5a8c9b2e1" +down_revision: Union[str, Sequence[str], None] = "9ba821df6da5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("SELECT 1") + + +def downgrade() -> None: + op.execute("SELECT 1") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index bab0467..5e1de38 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -2,21 +2,29 @@ from fastapi import APIRouter, FastAPI +from app.api.v1.alerts import router as alerts_router from app.api.v1.auth import router as auth_router from app.api.v1.competitors import router as competitors_router +from app.api.v1.dashboard import router as dashboard_router from app.api.v1.health import router as health_router from app.api.v1.products import router as products_router from app.api.v1.recommendations import router as recommendations_router from app.api.v1.scraper import router as scraper_router from app.core.config import settings +from app.services.telegram.telegram_router import router as telegram_router +from app.api.v1.telegram_snapshot import router as telegram_snapshot_router api_router = APIRouter() api_router.include_router(health_router, tags=["health"]) +api_router.include_router(alerts_router) api_router.include_router(auth_router) api_router.include_router(competitors_router) +api_router.include_router(dashboard_router) api_router.include_router(products_router) api_router.include_router(recommendations_router) api_router.include_router(scraper_router) +api_router.include_router(telegram_router) +api_router.include_router(telegram_snapshot_router) def register_routers(app: FastAPI) -> None: diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py new file mode 100644 index 0000000..746b3ed --- /dev/null +++ b/backend/app/api/v1/alerts.py @@ -0,0 +1,75 @@ +"""Alert API routes.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.services.telegram.telegram_client import ( + TelegramApiError, + TelegramConfigError, + TelegramInvalidChatError, + TelegramNetworkError, + TelegramPayloadError, + TelegramRateLimitError, + TelegramTimeoutError, + TelegramUnauthorizedError, +) +from app.services.telegram.telegram_formatter import AlertMessageContext +from app.services.telegram.telegram_service import TelegramDeliveryResult, TelegramService + +router = APIRouter(prefix="/alerts", tags=["alerts"]) + + +class TelegramTestAlertRequest(BaseModel): + chat_id: int | str | None = None + product_name: str = Field(default="BazarSync Demo Product") + action: str = Field(default="Lower price by 3%") + suggested_price_min: float | None = 1200 + suggested_price_max: float | None = 1350 + competitor_insight: str = Field( + default="Competitor dropped price by 5% in the last 24 hours." + ) + opportunity: str = Field(default="Regain buy box and boost conversion.") + confidence: str = Field(default="High") + revenue_opportunity: float | None = 3200 + currency: str = Field(default="BDT", min_length=1) + custom_message: str | None = Field(default="Test alert generated from /alerts/test.") + + +@router.post("/test", response_model=TelegramDeliveryResult) +async def send_test_alert( + payload: TelegramTestAlertRequest | None = None, +) -> TelegramDeliveryResult: + payload = payload or TelegramTestAlertRequest() + try: + service = TelegramService(logger=logging.getLogger("app.telegram.test")) + except TelegramConfigError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + alert = AlertMessageContext( + product_name=payload.product_name, + action=payload.action, + suggested_price_min=payload.suggested_price_min, + suggested_price_max=payload.suggested_price_max, + competitor_insight=payload.competitor_insight, + opportunity=payload.opportunity, + confidence=payload.confidence, + revenue_opportunity=payload.revenue_opportunity, + currency=payload.currency, + custom_message=payload.custom_message, + alert_type="test_alert", + ) + try: + return await service.send_alert_message(alert, chat_id=payload.chat_id) + except TelegramRateLimitError as exc: + raise HTTPException(status_code=429, detail=str(exc)) from exc + except (TelegramInvalidChatError, TelegramPayloadError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except TelegramUnauthorizedError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + except (TelegramTimeoutError, TelegramNetworkError, TelegramApiError) as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except TelegramConfigError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index be29736..075df06 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -9,9 +9,11 @@ from app.dependencies.db import get_db from app.models.seller import Seller from app.schemas.auth import ( + DeleteAccountResponse, LoginResponse, RegisterResponse, SellerCreate, + SellerDeleteRequest, SellerLogin, SellerProfileUpdate, SellerResponse, @@ -69,3 +71,21 @@ def update_profile( service = AuthService(db) seller = service.update_seller_profile(current_seller, payload) return seller + + +@router.post("/delete-account", response_model=DeleteAccountResponse) +def delete_account( + payload: SellerDeleteRequest, + db: Session = Depends(get_db), + current_seller: Seller = Depends(get_current_seller), +) -> DeleteAccountResponse: + service = AuthService(db) + try: + service.delete_seller_account(current_seller, payload.password) + except InvalidCredentialsError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid password.", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + return DeleteAccountResponse() diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py new file mode 100644 index 0000000..1586ba8 --- /dev/null +++ b/backend/app/api/v1/dashboard.py @@ -0,0 +1,236 @@ +"""Dashboard API routes.""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies.async_db import get_async_db +from app.models.alert import ( + Alert, + AlertActionType, + AlertDeliveryStatus, + AlertUrgencyLevel, +) +from app.models.recommendation import RecommendationAction, RecommendationConfidence +from app.repositories.alert_repository import AlertRepository +from app.repositories.competitor_repository import CompetitorRepository +from app.repositories.query_utils import PaginationParams, SortDirection +from app.repositories.recommendation_repository import RecommendationRepository +from app.schemas.dashboard.alert_schema import AlertListResponse, AlertSortField +from app.schemas.dashboard.competitor_schema import CompetitorListResponse, CompetitorSortField +from app.schemas.dashboard.opportunity_schema import OpportunityListResponse, OpportunitySortField +from app.schemas.dashboard.recommendation_schema import ( + RecommendationListResponse, + RecommendationSortField, +) +from app.schemas.dashboard.summary_schema import DashboardSummaryResponse +from app.services.dashboard.alert_service import AlertDashboardService +from app.services.dashboard.competitor_service import CompetitorDashboardService +from app.services.dashboard.dashboard_service import DashboardService +from app.services.dashboard.opportunity_service import OpportunityDashboardService +from app.services.dashboard.recommendation_service import RecommendationDashboardService + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) +logger = logging.getLogger(__name__) + + +class AlertResolveResponse(BaseModel): + id: str + status: str + + +@router.get("/summary", response_model=DashboardSummaryResponse) +async def get_dashboard_summary( + seller_id: UUID | None = Query(None, description="Filter by seller"), + db: AsyncSession = Depends(get_async_db), +) -> DashboardSummaryResponse: + start = time.monotonic() + service = DashboardService(db, logger=logger) + response = await service.get_summary(seller_id=str(seller_id) if seller_id else None) + _log_endpoint("dashboard_summary", start, seller_id=seller_id) + return response + + +@router.get("/competitors", response_model=CompetitorListResponse) +async def list_competitors( + seller_id: UUID | None = Query(None, description="Filter by seller"), + product_id: UUID | None = Query(None, description="Filter by product"), + stock_status: str | None = Query(None, description="Filter by stock status"), + search: str | None = Query(None, min_length=2, description="Search competitor or product"), + sort_by: CompetitorSortField = Query( + CompetitorSortField.created_at, description="Sort field" + ), + sort_direction: SortDirection = Query( + SortDirection.desc, description="Sort direction" + ), + limit: int | None = Query(None, ge=1, le=200), + offset: int | None = Query(None, ge=0), + page: int | None = Query(None, ge=1), + page_size: int | None = Query(None, ge=1, le=200), + db: AsyncSession = Depends(get_async_db), +) -> CompetitorListResponse: + start = time.monotonic() + pagination = PaginationParams.from_request( + limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200 + ) + service = CompetitorDashboardService(CompetitorRepository(db), logger=logger) + response = await service.list_competitors( + seller_id=str(seller_id) if seller_id else None, + product_id=str(product_id) if product_id else None, + stock_status=stock_status, + search=search, + sort_by=sort_by.value, + sort_direction=sort_direction, + pagination=pagination, + ) + _log_endpoint("dashboard_competitors", start, seller_id=seller_id) + return response + + +@router.get("/recommendations", response_model=RecommendationListResponse) +async def list_recommendations( + seller_id: UUID | None = Query(None, description="Filter by seller"), + product_id: UUID | None = Query(None, description="Filter by product"), + action_type: RecommendationAction | None = Query(None, description="Filter by action"), + confidence: RecommendationConfidence | None = Query( + None, description="Filter by confidence" + ), + search: str | None = Query(None, min_length=2, description="Search by product or reasoning"), + date_from: datetime | None = Query(None, description="Start date"), + date_to: datetime | None = Query(None, description="End date"), + sort_by: RecommendationSortField = Query( + RecommendationSortField.created_at, description="Sort field" + ), + sort_direction: SortDirection = Query(SortDirection.desc, description="Sort direction"), + limit: int | None = Query(None, ge=1, le=200), + offset: int | None = Query(None, ge=0), + page: int | None = Query(None, ge=1), + page_size: int | None = Query(None, ge=1, le=200), + db: AsyncSession = Depends(get_async_db), +) -> RecommendationListResponse: + start = time.monotonic() + pagination = PaginationParams.from_request( + limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200 + ) + service = RecommendationDashboardService(RecommendationRepository(db), logger=logger) + response = await service.list_recommendations( + seller_id=str(seller_id) if seller_id else None, + product_id=str(product_id) if product_id else None, + action_type=action_type.value if action_type else None, + confidence=confidence.value if confidence else None, + search=search, + date_from=date_from, + date_to=date_to, + sort_by=sort_by.value, + sort_direction=sort_direction, + pagination=pagination, + ) + _log_endpoint("dashboard_recommendations", start, seller_id=seller_id) + return response + + +@router.get("/alerts", response_model=AlertListResponse) +async def list_alerts( + seller_id: UUID | None = Query(None, description="Filter by seller"), + product_id: UUID | None = Query(None, description="Filter by product"), + urgency_level: AlertUrgencyLevel | None = Query( + None, description="Filter by urgency level" + ), + action_type: AlertActionType | None = Query( + None, description="Filter by action type" + ), + delivery_status: AlertDeliveryStatus | None = Query( + None, description="Filter by delivery status" + ), + is_read: bool | None = Query(None, description="Filter by read status"), + date_from: datetime | None = Query(None, description="Start date"), + date_to: datetime | None = Query(None, description="End date"), + sort_by: AlertSortField = Query(AlertSortField.created_at, description="Sort field"), + sort_direction: SortDirection = Query(SortDirection.desc, description="Sort direction"), + limit: int | None = Query(None, ge=1, le=200), + offset: int | None = Query(None, ge=0), + page: int | None = Query(None, ge=1), + page_size: int | None = Query(None, ge=1, le=200), + db: AsyncSession = Depends(get_async_db), +) -> AlertListResponse: + start = time.monotonic() + pagination = PaginationParams.from_request( + limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200 + ) + service = AlertDashboardService(AlertRepository(db), logger=logger) + response = await service.list_alerts( + seller_id=str(seller_id) if seller_id else None, + product_id=str(product_id) if product_id else None, + urgency_level=urgency_level.value if urgency_level else None, + action_type=action_type.value if action_type else None, + delivery_status=delivery_status.value if delivery_status else None, + is_read=is_read, + date_from=date_from, + date_to=date_to, + sort_by=sort_by.value, + sort_direction=sort_direction, + pagination=pagination, + ) + _log_endpoint("dashboard_alerts", start, seller_id=seller_id) + return response + + +@router.get("/market-opportunities", response_model=OpportunityListResponse) +async def list_market_opportunities( + seller_id: UUID | None = Query(None, description="Filter by seller"), + min_revenue: float | None = Query(None, ge=0, description="Minimum revenue opportunity"), + date_from: datetime | None = Query(None, description="Start date"), + date_to: datetime | None = Query(None, description="End date"), + sort_by: OpportunitySortField = Query( + OpportunitySortField.revenue_opportunity, description="Sort field" + ), + sort_direction: SortDirection = Query(SortDirection.desc, description="Sort direction"), + limit: int | None = Query(None, ge=1, le=200), + offset: int | None = Query(None, ge=0), + page: int | None = Query(None, ge=1), + page_size: int | None = Query(None, ge=1, le=200), + db: AsyncSession = Depends(get_async_db), +) -> OpportunityListResponse: + start = time.monotonic() + pagination = PaginationParams.from_request( + limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200 + ) + service = OpportunityDashboardService(RecommendationRepository(db), logger=logger) + response = await service.list_opportunities( + seller_id=str(seller_id) if seller_id else None, + min_revenue=min_revenue, + date_from=date_from, + date_to=date_to, + sort_by=sort_by.value, + sort_direction=sort_direction, + pagination=pagination, + ) + _log_endpoint("dashboard_opportunities", start, seller_id=seller_id) + return response + + +@router.patch("/alerts/{alert_id}/resolve", response_model=AlertResolveResponse) +async def resolve_alert( + alert_id: UUID, + db: AsyncSession = Depends(get_async_db), +) -> AlertResolveResponse: + alert = await db.get(Alert, alert_id) + if not alert: + raise HTTPException(status_code=404, detail="Alert not found.") + if alert.read_at is None: + alert.read_at = datetime.now(timezone.utc) + await db.commit() + return AlertResolveResponse(id=str(alert.id), status="resolved") + + +def _log_endpoint(name: str, start: float, **fields: Any) -> None: + duration_ms = int((time.monotonic() - start) * 1000) + payload = " ".join(f"{key}={value}" for key, value in fields.items() if value is not None) + logger.info("%s duration_ms=%s %s", name, duration_ms, payload) diff --git a/backend/app/api/v1/recommendations.py b/backend/app/api/v1/recommendations.py index c2c1822..2f305aa 100644 --- a/backend/app/api/v1/recommendations.py +++ b/backend/app/api/v1/recommendations.py @@ -8,6 +8,7 @@ from app.dependencies.auth import get_current_seller from app.dependencies.db import get_db +from app.dependencies.events import get_dashboard_event_emitter from app.models.seller import Seller from app.schemas.recommendation import ( RecommendationErrorResponse, @@ -22,6 +23,7 @@ RecommendationPersistenceError, RecommendationService, ) +from app.services.dashboard.events import DashboardEventEmitter router = APIRouter(prefix="/recommendations", tags=["recommendations"]) @@ -43,8 +45,9 @@ async def generate_recommendation( product_id: UUID, db: Session = Depends(get_db), current_seller: Seller = Depends(get_current_seller), + event_emitter: DashboardEventEmitter = Depends(get_dashboard_event_emitter), ) -> RecommendationGenerateResponse | JSONResponse: - service = RecommendationService(db) + service = RecommendationService(db, event_emitter=event_emitter) try: return await service.generate_recommendation( seller_id=current_seller.id, diff --git a/backend/app/api/v1/telegram.py b/backend/app/api/v1/telegram.py new file mode 100644 index 0000000..6ffafcb --- /dev/null +++ b/backend/app/api/v1/telegram.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Request + +router = APIRouter() + + +@router.post("/webhook") +async def telegram_webhook(request: Request): + + data = await request.json() + + print("\n=== TELEGRAM WEBHOOK RECEIVED ===") + print(data) + + return {"ok": True} \ No newline at end of file diff --git a/backend/app/api/v1/telegram_snapshot.py b/backend/app/api/v1/telegram_snapshot.py new file mode 100644 index 0000000..d646013 --- /dev/null +++ b/backend/app/api/v1/telegram_snapshot.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from app.dependencies.auth import get_current_seller +from app.dependencies.db import get_db +from app.models.seller import Seller +from app.schemas.product_snapshot import ( + ProductSnapshotErrorResponse, + ProductSnapshotRequest, + ProductSnapshotResponse, +) +from app.services.telegram.product_snapshot_service import ( + ProductSnapshotService, + ProductSnapshotServiceError, +) + +router = APIRouter(prefix="/telegram", tags=["telegram"]) +logger = logging.getLogger(__name__) + + +@router.post( + "/send-product-snapshot", + response_model=ProductSnapshotResponse, + status_code=status.HTTP_200_OK, + responses={ + 400: {"model": ProductSnapshotErrorResponse}, + 401: {"model": ProductSnapshotErrorResponse}, + 403: {"model": ProductSnapshotErrorResponse}, + 404: {"model": ProductSnapshotErrorResponse}, + 422: {"model": ProductSnapshotErrorResponse}, + 500: {"model": ProductSnapshotErrorResponse}, + 502: {"model": ProductSnapshotErrorResponse}, + }, +) +async def send_product_snapshot( + payload: ProductSnapshotRequest, + db: Session = Depends(get_db), + current_seller: Seller = Depends(get_current_seller), +) -> ProductSnapshotResponse | JSONResponse: + service = ProductSnapshotService(db, logger=logger) + try: + result = await service.send_snapshot( + seller_id=current_seller.id, + product_id=payload.product_id, + ) + except ProductSnapshotServiceError as exc: + return _error_response(exc) + return ProductSnapshotResponse( + success=True, + message="Snapshot sent successfully", + sent_at=result.sent_at, + delivery_status=result.delivery_status, + ) + + +def _error_response( + exc: ProductSnapshotServiceError, *, status_code: int | None = None +) -> JSONResponse: + code = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR) + payload = ProductSnapshotErrorResponse( + success=False, + detail=getattr(exc, "detail", str(exc)), + error_code=getattr(exc, "error_code", None), + ).model_dump() + return JSONResponse(status_code=status_code or code, content=payload) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d70f554..e8ed925 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -37,6 +37,14 @@ class Settings(BaseModel): supabase_url: str | None = None supabase_key: str | None = None redis_broker_url: str | None = None + socketio_mount_path: str = "/ws" + socketio_path: str = "socket.io" + socketio_ping_interval: int = 20 + socketio_ping_timeout: int = 25 + socketio_max_http_buffer_size: int = 1_000_000 + socketio_session_ttl_seconds: int = 300 + socketio_heartbeat_interval: int = 15 + socketio_heartbeat_timeout: int = 60 @classmethod def load(cls) -> "Settings": @@ -64,6 +72,22 @@ def load(cls) -> "Settings": "supabase_url": os.getenv("SUPABASE_URL"), "supabase_key": os.getenv("SUPABASE_KEY"), "redis_broker_url": os.getenv("REDIS_BROKER_URL"), + "socketio_mount_path": os.getenv("SOCKETIO_MOUNT_PATH", "/ws"), + "socketio_path": os.getenv("SOCKETIO_PATH", "socket.io"), + "socketio_ping_interval": int(os.getenv("SOCKETIO_PING_INTERVAL", "20")), + "socketio_ping_timeout": int(os.getenv("SOCKETIO_PING_TIMEOUT", "25")), + "socketio_max_http_buffer_size": int( + os.getenv("SOCKETIO_MAX_HTTP_BUFFER_SIZE", "1000000") + ), + "socketio_session_ttl_seconds": int( + os.getenv("SOCKETIO_SESSION_TTL_SECONDS", "300") + ), + "socketio_heartbeat_interval": int( + os.getenv("SOCKETIO_HEARTBEAT_INTERVAL", "15") + ), + "socketio_heartbeat_timeout": int( + os.getenv("SOCKETIO_HEARTBEAT_TIMEOUT", "60") + ), } return cls.model_validate(data) diff --git a/backend/app/core/exception_handlers.py b/backend/app/core/exception_handlers.py new file mode 100644 index 0000000..54f4f8e --- /dev/null +++ b/backend/app/core/exception_handlers.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app.schemas.dashboard.error_schema import DashboardErrorResponse, ErrorDetail +from app.services.dashboard.exceptions import DashboardServiceError + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(DashboardServiceError) + async def handle_dashboard_error( + _: Request, exc: DashboardServiceError + ) -> JSONResponse: + payload = DashboardErrorResponse( + error=ErrorDetail( + code=getattr(exc, "error_code", "dashboard_error"), + message=exc.detail, + ) + ) + return JSONResponse(status_code=exc.status_code, content=payload.model_dump()) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index db2728e..6ee3c04 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -16,7 +16,10 @@ def get_password_hash(password: str) -> str: def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) + try: + return pwd_context.verify(plain_password, hashed_password) + except ValueError: + return False def create_access_token(subject: str, expires_minutes: int | None = None) -> str: diff --git a/backend/app/core/socketio_config.py b/backend/app/core/socketio_config.py new file mode 100644 index 0000000..772bf89 --- /dev/null +++ b/backend/app/core/socketio_config.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import socketio + +from app.core.config import settings + + +@dataclass(frozen=True) +class SocketIOResources: + server: socketio.AsyncServer + app: socketio.ASGIApp + + +def _build_client_manager() -> socketio.AsyncRedisManager | None: + if not settings.redis_broker_url: + return None + return socketio.AsyncRedisManager(settings.redis_broker_url) + + +def build_socketio_resources( + logger: logging.Logger | None = None, +) -> SocketIOResources: + client_manager = _build_client_manager() + server = socketio.AsyncServer( + async_mode="asgi", + cors_allowed_origins=settings.cors_origins, + ping_interval=settings.socketio_ping_interval, + ping_timeout=settings.socketio_ping_timeout, + max_http_buffer_size=settings.socketio_max_http_buffer_size, + client_manager=client_manager, + logger=logger, + engineio_logger=logger, + ) + app = socketio.ASGIApp(server, socketio_path=settings.socketio_path) + return SocketIOResources(server=server, app=app) diff --git a/backend/app/db/async_session.py b/backend/app/db/async_session.py new file mode 100644 index 0000000..bec4a29 --- /dev/null +++ b/backend/app/db/async_session.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from urllib.parse import parse_qs, urlparse +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.core.config import settings + + +def _build_async_database_url(database_url: str) -> str: + if database_url.startswith("postgresql+asyncpg://"): + return database_url + if database_url.startswith("postgresql://"): + return database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + if database_url.startswith("postgres://"): + return database_url.replace("postgres://", "postgresql+asyncpg://", 1) + return database_url + + +def _using_pgbouncer(database_url: str) -> bool: + parsed = urlparse(database_url) + hostname = (parsed.hostname or "").lower() + if "pgbouncer" in hostname or "pooler" in hostname: + return True + if parsed.port == 6543: + return True + query = parse_qs(parsed.query) + pgbouncer_flag = (query.get("pgbouncer") or [None])[0] + return str(pgbouncer_flag).lower() in {"1", "true", "yes", "on"} + + +ASYNC_DATABASE_URL = _build_async_database_url(settings.database_url) +USE_PGBOUNCER = _using_pgbouncer(ASYNC_DATABASE_URL) +ASYNC_CONNECT_ARGS: dict[str, object] = {} +if ASYNC_DATABASE_URL.startswith("postgresql+asyncpg://"): + # Disable prepared statement caching to avoid pgbouncer transaction-pool conflicts. + ASYNC_CONNECT_ARGS["statement_cache_size"] = 0 + ASYNC_CONNECT_ARGS["prepared_statement_cache_size"] = 0 + if USE_PGBOUNCER: + ASYNC_CONNECT_ARGS["prepared_statement_name_func"] = ( + lambda: f"__asyncpg_{uuid4()}__" + ) + +engine_kwargs: dict[str, object] = { + "pool_pre_ping": True, + "connect_args": ASYNC_CONNECT_ARGS, +} +if USE_PGBOUNCER: + engine_kwargs["poolclass"] = NullPool +else: + engine_kwargs["pool_size"] = 5 + engine_kwargs["max_overflow"] = 10 + +async_engine: AsyncEngine = create_async_engine(ASYNC_DATABASE_URL, **engine_kwargs) + +AsyncSessionLocal = async_sessionmaker( + bind=async_engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, +) + + +async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/db/bootstrap.py b/backend/app/db/bootstrap.py index d196d04..f17f0b3 100644 --- a/backend/app/db/bootstrap.py +++ b/backend/app/db/bootstrap.py @@ -28,6 +28,23 @@ "created_at": "TIMESTAMPTZ NOT NULL DEFAULT NOW()", } +_SNAPSHOT_LOG_COLUMNS: dict[str, str] = { + "id": "UUID PRIMARY KEY", + "seller_id": "UUID NOT NULL REFERENCES sellers(id) ON DELETE CASCADE", + "product_id": "UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE", + "telegram_chat_id": "VARCHAR(120) NOT NULL", + "sent_at": "TIMESTAMPTZ NOT NULL DEFAULT NOW()", + "status": "VARCHAR(50) NOT NULL", + "error_message": "TEXT", +} + +_SNAPSHOT_LOG_INDEXES: dict[str, str] = { + "ix_snapshot_logs_seller_id": "seller_id", + "ix_snapshot_logs_product_id": "product_id", + "ix_snapshot_logs_status": "status", + "ix_snapshot_logs_sent_at": "sent_at", +} + def ensure_sellers_profile_columns(engine: Engine) -> None: inspector = inspect(engine) @@ -73,3 +90,23 @@ def ensure_products_table(engine: Engine) -> None: .replace(" PRIMARY KEY", "") ) connection.execute(text(f"ALTER TABLE products ADD COLUMN {name} {safe_type}")) + + +def ensure_snapshot_logs_table(engine: Engine) -> None: + inspector = inspect(engine) + if "snapshot_logs" not in inspector.get_table_names(): + columns_sql = ", ".join( + f"{name} {sql_type}" for name, sql_type in _SNAPSHOT_LOG_COLUMNS.items() + ) + with engine.begin() as connection: + connection.execute( + text(f"CREATE TABLE IF NOT EXISTS snapshot_logs ({columns_sql})") + ) + for index_name, column_name in _SNAPSHOT_LOG_INDEXES.items(): + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS " + f"{index_name} ON snapshot_logs ({column_name})" + ) + ) + return diff --git a/backend/app/dependencies/async_db.py b/backend/app/dependencies/async_db.py new file mode 100644 index 0000000..b6c6cb0 --- /dev/null +++ b/backend/app/dependencies/async_db.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.async_session import AsyncSessionLocal + + +async def get_async_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py index e8ac5ff..c1edf55 100644 --- a/backend/app/dependencies/auth.py +++ b/backend/app/dependencies/auth.py @@ -5,10 +5,12 @@ from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jwt import ExpiredSignatureError, InvalidTokenError +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.core.config import settings from app.core.security import decode_access_token +from app.dependencies.async_db import get_async_db from app.dependencies.db import get_db from app.models.seller import Seller from app.services.auth_service import AuthService @@ -61,3 +63,49 @@ def get_current_seller( headers={"WWW-Authenticate": "Bearer"}, ) return seller + + +async def get_current_seller_async( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_async_db), +) -> Seller: + try: + payload = decode_access_token(token) + except ExpiredSignatureError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired.", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + except InvalidTokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token.", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + subject = payload.get("sub") + if not subject: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + seller_id = UUID(subject) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token.", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + seller = await db.get(Seller, seller_id) + if not seller: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Seller not found.", + headers={"WWW-Authenticate": "Bearer"}, + ) + return seller diff --git a/backend/app/dependencies/events.py b/backend/app/dependencies/events.py new file mode 100644 index 0000000..d2eab64 --- /dev/null +++ b/backend/app/dependencies/events.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from app.services.dashboard.events import DashboardEventEmitter +from app.websocket.socket_manager import socket_manager + + +async def get_dashboard_event_emitter() -> DashboardEventEmitter: + return socket_manager diff --git a/backend/app/main.py b/backend/app/main.py index 90ebb6e..02423d4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,30 +7,64 @@ from sqlalchemy import text from app.api.router import register_routers +from app.api.v1.telegram import router as telegram_router + from app.core.config import settings -from app.db.bootstrap import ensure_products_table, ensure_sellers_profile_columns +from app.core.exception_handlers import register_exception_handlers + +from app.db.bootstrap import ( + ensure_products_table, + ensure_snapshot_logs_table, + ensure_sellers_profile_columns, +) + from app.db.session import engine + from app.scraper.browser import BrowserManager +from app.websocket.socket_manager import init_socketio + @asynccontextmanager -async def lifespan(_: FastAPI): - # Fail fast on startup if the database is unreachable or misconfigured. +async def lifespan(app: FastAPI): + + # Database health check with engine.connect() as connection: connection.execute(text("SELECT 1")) + + # Bootstrap database tables/columns if settings.bootstrap_db_on_startup: ensure_sellers_profile_columns(engine) ensure_products_table(engine) + ensure_snapshot_logs_table(engine) + + # Start websocket/socket manager + socket_manager = getattr(app.state, "socket_manager", None) + + if socket_manager: + await socket_manager.start() + try: yield + finally: + + # Shutdown websocket manager + if socket_manager: + await socket_manager.shutdown() + + # Shutdown shared Playwright browser await BrowserManager.shutdown_shared() def create_app() -> FastAPI: - app = FastAPI(title=settings.app_name, lifespan=lifespan) - # Centralized CORS policy for React frontends and future domains. + app = FastAPI( + title=settings.app_name, + lifespan=lifespan, + ) + + # CORS configuration app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, @@ -39,7 +73,22 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # Global exception handlers + register_exception_handlers(app) + + # Main API routers register_routers(app) + + # Telegram webhook router + app.include_router( + telegram_router, + prefix="/api/v1/telegram", + tags=["Telegram"], + ) + + # Initialize Socket.IO manager + app.state.socket_manager = init_socketio(app) + return app diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 73168c7..ee36a12 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,17 +1,25 @@ from __future__ import annotations +from app.models.alert import Alert +from app.models.alert_delivery_log import AlertDeliveryLog from app.models.competitor import Competitor from app.models.price_history import PriceHistory from app.models.product import Product from app.models.recommendation import Recommendation from app.models.scrape_run import ScrapeRun from app.models.seller import Seller +from app.models.telegram_connection import SellerTelegramConnection +from app.models.telegram_snapshot_log import TelegramProductSnapshotLog __all__ = [ + "Alert", + "AlertDeliveryLog", "Competitor", "PriceHistory", "Product", "Recommendation", "ScrapeRun", "Seller", + "SellerTelegramConnection", + "TelegramProductSnapshotLog", ] diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..28b049d --- /dev/null +++ b/backend/app/models/alert.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import datetime +import uuid +from enum import Enum +from typing import TYPE_CHECKING, Any + +from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.product import Product + from app.models.recommendation import Recommendation + from app.models.seller import Seller + from app.models.alert_delivery_log import AlertDeliveryLog + + +class AlertDeliveryStatus(str, Enum): + delivered = "delivered" + failed = "failed" + pending = "pending" + duplicate = "duplicate" + + +class AlertUrgencyLevel(str, Enum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + CRITICAL = "CRITICAL" + + +class AlertActionType(str, Enum): + PRICE_LOWER = "PRICE_LOWER" + PRICE_RAISE = "PRICE_RAISE" + HOLD = "HOLD" + REORDER = "REORDER" + STOCK_WARNING = "STOCK_WARNING" + OPPORTUNITY_CAPTURE = "OPPORTUNITY_CAPTURE" + + +class AlertConfidence(str, Enum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + + +class Alert(Base): + __tablename__ = "alerts" + __table_args__ = ( + Index("ix_alerts_seller_id", "seller_id"), + Index("ix_alerts_product_id", "product_id"), + Index("ix_alerts_recommendation_id", "recommendation_id"), + Index("ix_alerts_delivery_status", "delivery_status"), + Index("ix_alerts_urgency_level", "urgency_level"), + Index("ix_alerts_created_at", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + seller_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sellers.id", ondelete="CASCADE"), + nullable=False, + ) + product_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("products.id", ondelete="CASCADE"), + nullable=False, + ) + recommendation_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("recommendations.id", ondelete="SET NULL"), + nullable=True, + ) + alert_type: Mapped[str] = mapped_column(String(120), nullable=False, default="urgency_alert") + urgency_level: Mapped[AlertUrgencyLevel] = mapped_column( + SAEnum(AlertUrgencyLevel, name="alert_urgency_level"), + nullable=False, + default=AlertUrgencyLevel.MEDIUM, + ) + action_type: Mapped[AlertActionType] = mapped_column( + SAEnum(AlertActionType, name="alert_action_type"), + nullable=False, + ) + confidence: Mapped[AlertConfidence] = mapped_column( + SAEnum(AlertConfidence, name="alert_confidence"), + nullable=False, + ) + delivery_status: Mapped[AlertDeliveryStatus] = mapped_column( + SAEnum(AlertDeliveryStatus, name="alert_delivery_status"), + nullable=False, + default=AlertDeliveryStatus.pending, + ) + telegram_message_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + telegram_chat_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + delivery_error: Mapped[str | None] = mapped_column(Text, nullable=True) + payload: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + product: Mapped["Product"] = relationship(back_populates="alerts") + seller: Mapped["Seller"] = relationship(back_populates="alerts") + recommendation: Mapped["Recommendation | None"] = relationship(back_populates="alerts") + delivery_logs: Mapped[list["AlertDeliveryLog"]] = relationship( + back_populates="alert", + cascade="all, delete-orphan", + ) diff --git a/backend/app/models/alert_delivery_log.py b/backend/app/models/alert_delivery_log.py new file mode 100644 index 0000000..05f5c7e --- /dev/null +++ b/backend/app/models/alert_delivery_log.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from datetime import datetime +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.alert import AlertDeliveryStatus + +if TYPE_CHECKING: + from app.models.alert import Alert + from app.models.seller import Seller + + +class AlertDeliveryLog(Base): + __tablename__ = "alert_delivery_logs" + __table_args__ = ( + Index("ix_alert_delivery_logs_seller_id", "seller_id"), + Index("ix_alert_delivery_logs_alert_id", "alert_id"), + Index("ix_alert_delivery_logs_status", "delivery_status"), + Index("ix_alert_delivery_logs_sent_at", "sent_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + seller_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sellers.id", ondelete="CASCADE"), + nullable=False, + ) + alert_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("alerts.id", ondelete="CASCADE"), + nullable=False, + ) + telegram_chat_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + delivery_status: Mapped[AlertDeliveryStatus] = mapped_column( + SAEnum(AlertDeliveryStatus, name="alert_delivery_status"), + nullable=False, + default=AlertDeliveryStatus.pending, + ) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + seller: Mapped["Seller"] = relationship(back_populates="alert_delivery_logs") + alert: Mapped["Alert"] = relationship(back_populates="delivery_logs") diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 04ee54d..e19bfa7 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -16,6 +16,7 @@ from app.models.competitor import Competitor from app.models.scrape_run import ScrapeRun from app.models.recommendation import Recommendation + from app.models.alert import Alert from app.models.seller import Seller @@ -67,6 +68,10 @@ class Product(Base): back_populates="product", cascade="all, delete-orphan", ) + alerts: Mapped[list["Alert"]] = relationship( + back_populates="product", + cascade="all, delete-orphan", + ) @property def pricing_warning(self) -> str | None: diff --git a/backend/app/models/recommendation.py b/backend/app/models/recommendation.py index ebeced0..cb7f0c1 100644 --- a/backend/app/models/recommendation.py +++ b/backend/app/models/recommendation.py @@ -49,6 +49,7 @@ if TYPE_CHECKING: from app.models.product import Product + from app.models.alert import Alert class RecommendationAction(str, Enum): @@ -118,6 +119,7 @@ class Recommendation(Base): ) product: Mapped["Product"] = relationship(back_populates="recommendations") + alerts: Mapped[list["Alert"]] = relationship(back_populates="recommendation") @validates("reasoning_bn") def _validate_reasoning(self, _: str, value: str) -> str: diff --git a/backend/app/models/seller.py b/backend/app/models/seller.py index 5b16541..13a4859 100644 --- a/backend/app/models/seller.py +++ b/backend/app/models/seller.py @@ -13,6 +13,10 @@ if TYPE_CHECKING: from app.models.scrape_run import ScrapeRun from app.models.product import Product + from app.models.alert import Alert + from app.models.alert_delivery_log import AlertDeliveryLog + from app.models.telegram_connection import SellerTelegramConnection + from app.models.telegram_snapshot_log import TelegramProductSnapshotLog class Seller(Base): @@ -47,3 +51,20 @@ class Seller(Base): back_populates="seller", cascade="all, delete-orphan", ) + alerts: Mapped[list["Alert"]] = relationship( + back_populates="seller", + cascade="all, delete-orphan", + ) + alert_delivery_logs: Mapped[list["AlertDeliveryLog"]] = relationship( + back_populates="seller", + cascade="all, delete-orphan", + ) + snapshot_logs: Mapped[list["TelegramProductSnapshotLog"]] = relationship( + back_populates="seller", + cascade="all, delete-orphan", + ) + telegram_connection: Mapped["SellerTelegramConnection | None"] = relationship( + back_populates="seller", + cascade="all, delete-orphan", + uselist=False, + ) diff --git a/backend/app/models/telegram_connection.py b/backend/app/models/telegram_connection.py new file mode 100644 index 0000000..5994a84 --- /dev/null +++ b/backend/app/models/telegram_connection.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from datetime import datetime +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.seller import Seller + + +class SellerTelegramConnection(Base): + __tablename__ = "seller_telegram_connections" + __table_args__ = ( + Index("ix_seller_telegram_connections_seller_id", "seller_id", unique=True), + Index("ix_seller_telegram_connections_token", "connection_token"), + Index("ix_seller_telegram_connections_chat_id", "telegram_chat_id"), + Index("ix_seller_telegram_connections_is_connected", "is_connected"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + seller_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sellers.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + telegram_chat_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + telegram_username: Mapped[str | None] = mapped_column(String(120), nullable=True) + telegram_first_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + telegram_last_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + connection_token: Mapped[str | None] = mapped_column(String(128), nullable=True) + token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + is_connected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + connected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_alert_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + seller: Mapped["Seller"] = relationship(back_populates="telegram_connection") diff --git a/backend/app/models/telegram_snapshot_log.py b/backend/app/models/telegram_snapshot_log.py new file mode 100644 index 0000000..9cc99d7 --- /dev/null +++ b/backend/app/models/telegram_snapshot_log.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from datetime import datetime +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.product import Product + from app.models.seller import Seller + + +class TelegramProductSnapshotLog(Base): + __tablename__ = "snapshot_logs" + __table_args__ = ( + Index("ix_snapshot_logs_seller_id", "seller_id"), + Index("ix_snapshot_logs_product_id", "product_id"), + Index("ix_snapshot_logs_status", "status"), + Index("ix_snapshot_logs_sent_at", "sent_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + seller_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sellers.id", ondelete="CASCADE"), + nullable=False, + ) + product_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("products.id", ondelete="CASCADE"), + nullable=False, + ) + telegram_chat_id: Mapped[str] = mapped_column(String(120), nullable=False) + sent_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + status: Mapped[str] = mapped_column(String(50), nullable=False) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + seller: Mapped["Seller"] = relationship(back_populates="snapshot_logs") + product: Mapped["Product"] = relationship() diff --git a/backend/app/recommendations/alert_engine.py b/backend/app/recommendations/alert_engine.py new file mode 100644 index 0000000..c84d05d --- /dev/null +++ b/backend/app/recommendations/alert_engine.py @@ -0,0 +1,247 @@ +"""Alert engine for urgency outputs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import logging +from typing import Any, Mapping +from uuid import uuid4 + +from app.recommendations.urgency_rules import AlertConfig, UrgencyEngineConfig, UrgencyResult + + +class AlertEngineError(Exception): + """Base error for alert generation failures.""" + + +@dataclass(frozen=True) +class AlertInput: + urgency_result: UrgencyResult + product_id: str + seller_id: str + product_title: str | None = None + seller_price: float | None = None + recommended_price: float | None = None + + +@dataclass(frozen=True) +class AlertResult: + alert_id: str + should_notify: bool + summary: str + telegram_payload: dict[str, Any] + websocket_event: dict[str, Any] + alert_payload: dict[str, Any] + created_at: datetime + + def to_dict(self) -> dict[str, Any]: + return { + "alert_id": self.alert_id, + "should_notify": self.should_notify, + "summary": self.summary, + "telegram_payload": self.telegram_payload, + "websocket_event": self.websocket_event, + "alert_payload": self.alert_payload, + "created_at": self.created_at.isoformat(), + } + + +class AlertEngine: + """Generates alert payloads for Telegram and realtime dashboard updates.""" + + def __init__( + self, + config: AlertConfig | None = None, + *, + logger: logging.Logger | None = None, + ) -> None: + self._config = config or UrgencyEngineConfig.from_env().alerts + self._logger = logger or logging.getLogger(__name__) + + def build_alert(self, input_data: AlertInput) -> AlertResult: + urgency = input_data.urgency_result + alert_id = str(uuid4()) + should_notify = self._should_notify(urgency) + summary = self._build_summary(input_data) + telegram_payload = self._build_telegram_payload(input_data, summary) + websocket_event = self._build_websocket_event(input_data, alert_id) + alert_payload = self._build_alert_payload(input_data, alert_id) + + self._logger.info( + "alert_generated alert_id=%s product_id=%s urgency=%s notify=%s", + alert_id, + input_data.product_id, + urgency.urgency_level, + should_notify, + ) + + return AlertResult( + alert_id=alert_id, + should_notify=should_notify, + summary=summary, + telegram_payload=telegram_payload, + websocket_event=websocket_event, + alert_payload=alert_payload, + created_at=datetime.now(timezone.utc), + ) + + async def build_alert_async(self, input_data: AlertInput) -> AlertResult: + return self.build_alert(input_data) + + def _should_notify(self, urgency: UrgencyResult) -> bool: + if urgency.urgency_score >= self._config.notify_min_score: + return True + levels = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4} + urgency_level = levels.get(urgency.urgency_level, 1) + min_level = levels.get(self._config.notify_min_urgency_level, 2) + return urgency_level >= min_level + + def _build_summary(self, input_data: AlertInput) -> str: + urgency = input_data.urgency_result + product_title = input_data.product_title or "Product" + return ( + f"{product_title}: {urgency.action_type} | " + f"{urgency.urgency_level} urgency ({urgency.urgency_score}) | " + f"Confidence {urgency.confidence}." + ) + + def _build_telegram_payload( + self, input_data: AlertInput, summary: str + ) -> dict[str, Any]: + urgency = input_data.urgency_result + reasoning = self._format_reasoning(urgency.reasoning_summary) + price_line = "" + if input_data.seller_price is not None: + price_line = f"\nCurrent price: {input_data.seller_price:.2f}" + if input_data.recommended_price is not None: + price_line += f"\nRecommended price: {input_data.recommended_price:.2f}" + + message = ( + f"{summary}\n" + f"Action: {urgency.action_type}\n" + f"Opportunity: {urgency.revenue_opportunity:.2f}\n" + f"Stock risk: {urgency.stock_risk_level}\n" + f"Confidence: {urgency.confidence}\n" + f"{reasoning}{price_line}" + ) + return { + "channel": "telegram", + "text": message, + "parse_mode": "Markdown", + } + + def _build_websocket_event(self, input_data: AlertInput, alert_id: str) -> dict[str, Any]: + urgency = input_data.urgency_result + return { + "type": "urgency_alert", + "event_id": alert_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "data": { + "product_id": input_data.product_id, + "seller_id": input_data.seller_id, + "urgency_score": urgency.urgency_score, + "urgency_level": urgency.urgency_level, + "action_type": urgency.action_type, + "confidence": urgency.confidence, + "revenue_opportunity": urgency.revenue_opportunity, + "market_opportunity_score": urgency.market_opportunity_score, + "reasoning_summary": urgency.reasoning_summary, + "triggered_rules": urgency.triggered_rules, + }, + } + + def _build_alert_payload(self, input_data: AlertInput, alert_id: str) -> dict[str, Any]: + urgency = input_data.urgency_result + return { + "alert_id": alert_id, + "product_id": input_data.product_id, + "seller_id": input_data.seller_id, + "action_type": urgency.action_type, + "urgency_level": urgency.urgency_level, + "urgency_score": urgency.urgency_score, + "confidence": urgency.confidence, + "confidence_score": urgency.confidence_score, + "stock_risk_level": urgency.stock_risk_level, + "revenue_opportunity": urgency.revenue_opportunity, + "market_opportunity_score": urgency.market_opportunity_score, + "reasoning_summary": urgency.reasoning_summary, + "triggered_rules": urgency.triggered_rules, + } + + @staticmethod + def _format_reasoning(reasoning: str) -> str: + if not reasoning: + return "Reasoning: Not available." + return f"Reasoning: {reasoning}" + + +EXAMPLE_ALERT_INPUT: dict[str, Any] = { + "product_id": "DBL200", + "seller_id": "SELLER-42", + "product_title": "Dove Body Lotion 200ml", + "seller_price": 450, + "recommended_price": 430, +} + +EXAMPLE_ALERT_EXPECTED_OUTPUT: dict[str, Any] = { + "should_notify": True, + "urgency_level": "HIGH", +} + + +def build_alert_from_payload( + payload: Mapping[str, Any], + *, + urgency_result: UrgencyResult, + config: AlertConfig | None = None, +) -> AlertResult: + input_data = AlertInput( + urgency_result=urgency_result, + product_id=str(payload.get("product_id") or payload.get("sku") or "UNKNOWN"), + seller_id=str(payload.get("seller_id") or payload.get("merchant_id") or "UNKNOWN"), + product_title=payload.get("product_title"), + seller_price=_optional_float(payload.get("seller_price")), + recommended_price=_optional_float(payload.get("recommended_price")), + ) + engine = AlertEngine(config=config) + return engine.build_alert(input_data) + + +if __name__ == "__main__": + sample_urgency = UrgencyResult( + urgency_score=78, + urgency_level="HIGH", + action_type="PRICE_LOWER", + confidence="MEDIUM", + confidence_score=0.58, + revenue_opportunity=4200.0, + stock_risk_level="MEDIUM", + reasoning_summary="High urgency driven by competitor undercutting.", + triggered_rules=["PRICE_GAP_THRESHOLD"], + market_opportunity_score=71, + rule_results=[], + data_quality=None, + diagnostics={}, + ) + engine = AlertEngine() + alert = engine.build_alert( + AlertInput( + urgency_result=sample_urgency, + product_id="DBL200", + seller_id="SELLER-42", + product_title="Dove Body Lotion 200ml", + seller_price=450, + recommended_price=430, + ) + ) + print(alert.to_dict()) + + +def _optional_float(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/backend/app/recommendations/opportunity_calculator.py b/backend/app/recommendations/opportunity_calculator.py new file mode 100644 index 0000000..b006150 --- /dev/null +++ b/backend/app/recommendations/opportunity_calculator.py @@ -0,0 +1,238 @@ +"""Deterministic revenue opportunity calculator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +import logging +from typing import Any, Mapping + +from app.analytics.metrics import clamp, safe_divide +from app.recommendations.urgency_rules import OpportunityConfig + + +class OpportunityCalculationError(Exception): + """Raised when opportunity calculations fail.""" + + +@dataclass(frozen=True) +class OpportunityInput: + seller_price: float + recommended_price: float | None + current_stock: int + margin_percent: float + opportunity_score: float + demand_indicator: float + competitor_oos_ratio: float + inventory_turnover_days: float | None = None + historical_daily_sales: float | None = None + + +@dataclass(frozen=True) +class OpportunityResult: + additional_revenue: float + inventory_capture_units: float + projected_demand_shift: float + estimated_profit_impact: float + revenue_opportunity_score: int + + def to_dict(self) -> dict[str, Any]: + return { + "additional_revenue": round(self.additional_revenue, 2), + "inventory_capture_units": round(self.inventory_capture_units, 2), + "projected_demand_shift": round(self.projected_demand_shift, 2), + "estimated_profit_impact": round(self.estimated_profit_impact, 2), + "revenue_opportunity_score": self.revenue_opportunity_score, + } + + +class OpportunityCalculator: + """Calculates deterministic opportunity and profit impact estimates.""" + + def __init__( + self, + *, + config: OpportunityConfig | None = None, + logger: logging.Logger | None = None, + ) -> None: + self._config = config or OpportunityConfig() + self._logger = logger or logging.getLogger(__name__) + + def calculate(self, input_data: OpportunityInput) -> OpportunityResult: + self._validate_input(input_data) + base_daily_demand = self._estimate_base_demand(input_data) + demand_shift_multiplier = self._compute_demand_shift(input_data) + + projected_demand_shift = ( + base_daily_demand * demand_shift_multiplier * self._config.demand_window_days + ) + capture_units = min( + float(input_data.current_stock), + projected_demand_shift * self._config.capture_rate, + ) + target_price = input_data.recommended_price or input_data.seller_price + additional_revenue = capture_units * target_price + profit_impact = additional_revenue * (input_data.margin_percent / 100.0) + opportunity_score = self._compute_opportunity_score(input_data) + + self._logger.info( + "opportunity_calculated revenue=%s capture_units=%s", + round(additional_revenue, 2), + round(capture_units, 2), + ) + + return OpportunityResult( + additional_revenue=additional_revenue, + inventory_capture_units=capture_units, + projected_demand_shift=projected_demand_shift, + estimated_profit_impact=profit_impact, + revenue_opportunity_score=opportunity_score, + ) + + def calculate_from_payload(self, payload: Mapping[str, Any]) -> OpportunityResult: + input_data = self._parse_payload(payload) + return self.calculate(input_data) + + def _validate_input(self, input_data: OpportunityInput) -> None: + if input_data.seller_price <= 0: + raise OpportunityCalculationError("seller_price must be positive.") + if input_data.current_stock < 0: + raise OpportunityCalculationError("current_stock cannot be negative.") + if not isfinite(input_data.margin_percent): + raise OpportunityCalculationError("margin_percent must be finite.") + + def _estimate_base_demand(self, input_data: OpportunityInput) -> float: + if input_data.historical_daily_sales and input_data.historical_daily_sales > 0: + return input_data.historical_daily_sales + if input_data.inventory_turnover_days and input_data.inventory_turnover_days > 0: + return safe_divide( + float(input_data.current_stock), + input_data.inventory_turnover_days, + default=self._config.base_daily_demand, + ) + return self._config.base_daily_demand + + def _compute_demand_shift(self, input_data: OpportunityInput) -> float: + raw_boost = ( + input_data.opportunity_score * self._config.opportunity_multiplier + + input_data.demand_indicator * self._config.demand_multiplier + + input_data.competitor_oos_ratio * self._config.out_of_stock_multiplier + ) + boost = clamp(raw_boost, 0.0, self._config.max_demand_boost) + return 1.0 + boost + + def _compute_opportunity_score(self, input_data: OpportunityInput) -> int: + score = clamp( + (input_data.opportunity_score * 0.4) + + (input_data.demand_indicator * 0.3) + + (input_data.competitor_oos_ratio * 0.3), + 0.0, + 1.0, + ) + return int(round(score * 100)) + + def _parse_payload(self, payload: Mapping[str, Any]) -> OpportunityInput: + if not isinstance(payload, Mapping): + raise OpportunityCalculationError("Payload must be a mapping.") + + def require_float(key: str) -> float: + value = payload.get(key) + if value is None or isinstance(value, bool): + raise OpportunityCalculationError(f"{key} is required.") + try: + return float(value) + except (TypeError, ValueError) as exc: + raise OpportunityCalculationError(f"{key} must be numeric.") from exc + + seller_price = require_float("seller_price") + recommended_price = _optional_float(payload.get("recommended_price"), positive=True) + try: + current_stock = int(payload.get("current_stock") or 0) + except (TypeError, ValueError) as exc: + raise OpportunityCalculationError("current_stock must be an integer.") from exc + margin_percent = require_float("margin_percent") + opportunity_score = _optional_float( + payload.get("opportunity_score"), min_value=0.0, max_value=1.0 + ) or 0.0 + demand_indicator = _optional_float( + payload.get("demand_indicator"), min_value=0.0, max_value=1.0 + ) or 0.0 + competitor_oos_ratio = _optional_float( + payload.get("competitor_oos_ratio"), min_value=0.0, max_value=1.0 + ) or 0.0 + inventory_turnover_days = _optional_float( + payload.get("inventory_turnover_days"), non_negative=True + ) + historical_daily_sales = _optional_float( + payload.get("historical_daily_sales"), non_negative=True + ) + + return OpportunityInput( + seller_price=seller_price, + recommended_price=recommended_price, + current_stock=current_stock, + margin_percent=margin_percent, + opportunity_score=opportunity_score, + demand_indicator=demand_indicator, + competitor_oos_ratio=competitor_oos_ratio, + inventory_turnover_days=inventory_turnover_days, + historical_daily_sales=historical_daily_sales, + ) + + +def _optional_float( + value: Any, + *, + positive: bool = False, + non_negative: bool = False, + min_value: float | None = None, + max_value: float | None = None, +) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not isfinite(numeric): + return None + if positive and numeric <= 0: + return None + if non_negative and numeric < 0: + return None + if min_value is not None and numeric < min_value: + return None + if max_value is not None and numeric > max_value: + return None + return numeric + + +EXAMPLE_INPUT: dict[str, Any] = { + "seller_price": 450, + "recommended_price": 465, + "current_stock": 20, + "margin_percent": 18.0, + "opportunity_score": 0.75, + "demand_indicator": 0.65, + "competitor_oos_ratio": 0.5, + "inventory_turnover_days": 10, +} + +EXAMPLE_EXPECTED_OUTPUT: dict[str, Any] = { + "revenue_opportunity_score": 71, +} + + +if __name__ == "__main__": + sample = OpportunityInput( + seller_price=450, + recommended_price=465, + current_stock=20, + margin_percent=18.0, + opportunity_score=0.75, + demand_indicator=0.65, + competitor_oos_ratio=0.5, + inventory_turnover_days=10, + ) + calculator = OpportunityCalculator() + print(calculator.calculate(sample).to_dict()) diff --git a/backend/app/recommendations/urgency_rules.py b/backend/app/recommendations/urgency_rules.py new file mode 100644 index 0000000..80e1573 --- /dev/null +++ b/backend/app/recommendations/urgency_rules.py @@ -0,0 +1,1508 @@ +"""Deterministic urgency rules and scoring engine. + +This module turns competitor intelligence, AI signals, and inventory risk into +explainable, production-ready urgency decisions. + +Example usage: + payload = { + "product_id": "DBL200", + "seller_id": "SELLER-42", + "seller_price": 450, + "recommended_price": 430, + "seller_stock": 12, + "supplier_lead_time_days": 7, + "minimum_margin_percent": 12, + "cost_price": 320, + "competitors": [ + {"price": 420, "stock_status": "in_stock"}, + {"price": 410, "stock_status": "out_of_stock"}, + {"price": 415, "stock_status": "out_of_stock"}, + ], + "opportunity_score": 0.78, + "demand_indicator": 0.7, + "inventory_turnover_days": 8, + "ai_recommendation": {"action": "PRICE_LOWER", "confidence": 0.71}, + } + + engine = UrgencyEngine() + result = engine.evaluate(payload) + print(result.to_dict()) + +Sample output (abbreviated): + { + "urgency_score": 74, + "urgency_level": "HIGH", + "action_type": "PRICE_LOWER", + "confidence": "MEDIUM", + "stock_risk_level": "MEDIUM", + "revenue_opportunity": 3654.0, + "market_opportunity_score": 71, + "reasoning_summary": "High urgency driven by competitor undercut and ...", + "triggered_rules": ["COMPETITOR_OUT_OF_STOCK", "PRICE_GAP_THRESHOLD"], + } +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from math import isfinite +import logging +import os +import time +from typing import Any, Iterable, Mapping, Protocol, Sequence, TYPE_CHECKING + +from app.analytics.metrics import ( + clamp, + compute_competitor_price_stats, + compute_price_gap_percent, + normalize_stock_status, + safe_divide, + score_market_position, + score_pricing_pressure, + score_stock_risk, +) + +ActionType = str +ConfidenceLevel = str +StockRiskLevel = str +UrgencyLevel = str + +if TYPE_CHECKING: + from app.recommendations.opportunity_calculator import OpportunityCalculator + + +class UrgencyEngineError(Exception): + """Base error for the urgency engine.""" + + +class InvalidUrgencyInputError(UrgencyEngineError): + """Raised when required inputs are missing or malformed.""" + + +class MarketDataQualityError(UrgencyEngineError): + """Raised when market data is too inconsistent for scoring.""" + + +@dataclass(frozen=True) +class UrgencyThresholds: + low_max: int = 30 + medium_max: int = 60 + high_max: int = 80 + critical_max: int = 100 + price_gap_threshold_percent: float = 6.0 + aggressive_competitor_gap_percent: float = 10.0 + low_margin_threshold_percent: float = 8.0 + market_opportunity_threshold: float = 0.65 + high_demand_threshold: float = 0.7 + competitor_oos_ratio_threshold: float = 0.5 + lead_time_risk_days: int = 10 + stockout_days_threshold: int = 5 + stock_warning_units: int = 10 + inventory_depletion_days: int = 7 + inventory_turnover_threshold_days: int = 10 + min_competitor_count: int = 2 + + +@dataclass(frozen=True) +class ConfidenceConfig: + rules_for_high_confidence: int = 4 + min_data_completeness: float = 0.6 + competitor_variance_reference: float = 0.15 + max_recommendation_delta_percent: float = 20.0 + price_signal_reference_percent: float = 15.0 + high_confidence_threshold: float = 0.7 + medium_confidence_threshold: float = 0.45 + weight_rules: float = 0.25 + weight_data: float = 0.25 + weight_competitor_consistency: float = 0.2 + weight_recommendation_stability: float = 0.15 + weight_pricing_signal: float = 0.15 + + +@dataclass(frozen=True) +class ScoringWeights: + stock_pressure: float = 0.2 + competitor_pressure: float = 0.15 + market_opportunity: float = 0.15 + price_competitiveness: float = 0.15 + margin_safety: float = 0.1 + demand_signal: float = 0.15 + lead_time_risk: float = 0.1 + + +@dataclass(frozen=True) +class OpportunityConfig: + demand_window_days: int = 7 + base_daily_demand: float = 1.0 + opportunity_multiplier: float = 0.6 + out_of_stock_multiplier: float = 0.5 + demand_multiplier: float = 0.4 + capture_rate: float = 0.6 + profit_multiplier: float = 1.0 + max_demand_boost: float = 2.5 + + +@dataclass(frozen=True) +class AlertConfig: + notify_min_urgency_level: UrgencyLevel = "MEDIUM" + notify_min_score: int = 50 + + +@dataclass(frozen=True) +class UrgencyEngineConfig: + thresholds: UrgencyThresholds = field(default_factory=UrgencyThresholds) + confidence: ConfidenceConfig = field(default_factory=ConfidenceConfig) + weights: ScoringWeights = field(default_factory=ScoringWeights) + opportunity: OpportunityConfig = field(default_factory=OpportunityConfig) + alerts: AlertConfig = field(default_factory=AlertConfig) + + @classmethod + def from_env(cls) -> "UrgencyEngineConfig": + thresholds = UrgencyThresholds( + low_max=_get_env_int("BAZARSYNC_URGENCY_LOW_MAX", 30), + medium_max=_get_env_int("BAZARSYNC_URGENCY_MEDIUM_MAX", 60), + high_max=_get_env_int("BAZARSYNC_URGENCY_HIGH_MAX", 80), + critical_max=_get_env_int("BAZARSYNC_URGENCY_CRITICAL_MAX", 100), + price_gap_threshold_percent=_get_env_float( + "BAZARSYNC_PRICE_GAP_THRESHOLD", 6.0 + ), + aggressive_competitor_gap_percent=_get_env_float( + "BAZARSYNC_AGGRESSIVE_PRICE_GAP", 10.0 + ), + low_margin_threshold_percent=_get_env_float( + "BAZARSYNC_LOW_MARGIN_THRESHOLD", 8.0 + ), + market_opportunity_threshold=_get_env_float( + "BAZARSYNC_MARKET_OPPORTUNITY_THRESHOLD", 0.65 + ), + high_demand_threshold=_get_env_float( + "BAZARSYNC_HIGH_DEMAND_THRESHOLD", 0.7 + ), + competitor_oos_ratio_threshold=_get_env_float( + "BAZARSYNC_OOS_RATIO_THRESHOLD", 0.5 + ), + lead_time_risk_days=_get_env_int("BAZARSYNC_LEAD_TIME_RISK_DAYS", 10), + stockout_days_threshold=_get_env_int( + "BAZARSYNC_STOCKOUT_DAYS_THRESHOLD", 5 + ), + stock_warning_units=_get_env_int("BAZARSYNC_STOCK_WARNING_UNITS", 10), + inventory_depletion_days=_get_env_int( + "BAZARSYNC_INVENTORY_DEPLETION_DAYS", 7 + ), + inventory_turnover_threshold_days=_get_env_int( + "BAZARSYNC_INVENTORY_TURNOVER_DAYS", 10 + ), + min_competitor_count=_get_env_int("BAZARSYNC_MIN_COMPETITOR_COUNT", 2), + ) + + confidence = ConfidenceConfig( + rules_for_high_confidence=_get_env_int( + "BAZARSYNC_CONF_RULES_HIGH", 4 + ), + min_data_completeness=_get_env_float( + "BAZARSYNC_CONF_MIN_COMPLETENESS", 0.6 + ), + competitor_variance_reference=_get_env_float( + "BAZARSYNC_CONF_COMP_VAR_REF", 0.15 + ), + max_recommendation_delta_percent=_get_env_float( + "BAZARSYNC_CONF_MAX_RECO_DELTA", 20.0 + ), + price_signal_reference_percent=_get_env_float( + "BAZARSYNC_CONF_PRICE_SIGNAL_REF", 15.0 + ), + high_confidence_threshold=_get_env_float( + "BAZARSYNC_CONF_HIGH_THRESHOLD", 0.7 + ), + medium_confidence_threshold=_get_env_float( + "BAZARSYNC_CONF_MED_THRESHOLD", 0.45 + ), + ) + + weights = ScoringWeights( + stock_pressure=_get_env_float("BAZARSYNC_WEIGHT_STOCK", 0.2), + competitor_pressure=_get_env_float("BAZARSYNC_WEIGHT_COMPETITOR", 0.15), + market_opportunity=_get_env_float("BAZARSYNC_WEIGHT_OPPORTUNITY", 0.15), + price_competitiveness=_get_env_float("BAZARSYNC_WEIGHT_PRICE", 0.15), + margin_safety=_get_env_float("BAZARSYNC_WEIGHT_MARGIN", 0.1), + demand_signal=_get_env_float("BAZARSYNC_WEIGHT_DEMAND", 0.15), + lead_time_risk=_get_env_float("BAZARSYNC_WEIGHT_LEADTIME", 0.1), + ) + + opportunity = OpportunityConfig( + demand_window_days=_get_env_int("BAZARSYNC_OPP_WINDOW_DAYS", 7), + base_daily_demand=_get_env_float("BAZARSYNC_OPP_BASE_DEMAND", 1.0), + opportunity_multiplier=_get_env_float( + "BAZARSYNC_OPP_MULTIPLIER", 0.6 + ), + out_of_stock_multiplier=_get_env_float( + "BAZARSYNC_OPP_OOS_MULTIPLIER", 0.5 + ), + demand_multiplier=_get_env_float("BAZARSYNC_OPP_DEMAND_MULTIPLIER", 0.4), + capture_rate=_get_env_float("BAZARSYNC_OPP_CAPTURE_RATE", 0.6), + profit_multiplier=_get_env_float("BAZARSYNC_OPP_PROFIT_MULTIPLIER", 1.0), + max_demand_boost=_get_env_float("BAZARSYNC_OPP_MAX_BOOST", 2.5), + ) + + alerts = AlertConfig( + notify_min_urgency_level=os.getenv( + "BAZARSYNC_ALERT_MIN_LEVEL", "MEDIUM" + ).upper(), + notify_min_score=_get_env_int("BAZARSYNC_ALERT_MIN_SCORE", 50), + ) + + return cls( + thresholds=thresholds, + confidence=confidence, + weights=weights, + opportunity=opportunity, + alerts=alerts, + ) + + +@dataclass(frozen=True) +class UrgencyInput: + product_id: str + seller_id: str + seller_price: float + seller_stock: int + supplier_lead_time_days: int + minimum_margin_percent: float + cost_price: float | None = None + recommended_price: float | None = None + competitor_prices: list[float] = field(default_factory=list) + competitor_stock_statuses: list[str] = field(default_factory=list) + competitor_oos_count: int | None = None + opportunity_score: float | None = None + demand_indicator: float | None = None + inventory_turnover_days: float | None = None + historical_prices: list[float] = field(default_factory=list) + ai_action: ActionType | None = None + ai_confidence: float | None = None + + @classmethod + def from_payload( + cls, + payload: Mapping[str, Any], + *, + config: UrgencyEngineConfig, + logger: logging.Logger, + ) -> tuple["UrgencyInput", "DataQualityReport"]: + if not isinstance(payload, Mapping): + raise InvalidUrgencyInputError("Urgency input must be a mapping.") + + product_id = _string_value(payload, "product_id", fallback="sku") or "UNKNOWN" + seller_id = _string_value(payload, "seller_id", fallback="merchant_id") or "UNKNOWN" + + seller_price_value = _value_with_fallback( + payload, "seller_price", "current_seller_price", "current_price" + ) + seller_stock_value = _value_with_fallback( + payload, "seller_stock", "stock_quantity", "inventory_count" + ) + lead_time_value = _value_with_fallback( + payload, "supplier_lead_time_days", "supplier_lead_time", "lead_time_days" + ) + missing_required = [ + name + for name, value in { + "seller_price": seller_price_value, + "seller_stock": seller_stock_value, + "supplier_lead_time_days": lead_time_value, + }.items() + if value is None + ] + if missing_required: + raise InvalidUrgencyInputError( + f"Missing required fields: {', '.join(missing_required)}." + ) + + seller_price = _require_float_value("seller_price", seller_price_value, positive=True) + seller_stock = _require_int_value("seller_stock", seller_stock_value, non_negative=True) + supplier_lead_time_days = _require_int_value( + "supplier_lead_time_days", lead_time_value, non_negative=True + ) + minimum_margin_percent = _optional_float( + _value_with_fallback( + payload, "minimum_margin_percent", "minimum_margin", "profit_margin_threshold" + ), + non_negative=True, + ) + if minimum_margin_percent is None: + minimum_margin_percent = config.thresholds.low_margin_threshold_percent + logger.info( + "urgency_input_default_margin minimum_margin_percent=%s product_id=%s", + minimum_margin_percent, + product_id, + ) + + cost_price = _optional_float( + _value_with_fallback(payload, "cost_price", "seller_cost", "unit_cost"), + non_negative=True, + ) + recommended_price = _optional_float( + _value_with_fallback(payload, "recommended_price", "ai_recommended_price"), + positive=True, + ) + + competitor_prices, competitor_statuses = _parse_competitor_payload( + payload.get("competitors"), + payload.get("competitor_prices"), + payload.get("competitor_stock_statuses"), + ) + competitor_oos_count = _optional_int( + _value_with_fallback( + payload, "competitor_oos_count", "competitor_out_of_stock_count" + ), + non_negative=True, + ) + + ai_action = _string_value(payload, "ai_action") + ai_confidence = _optional_float(payload.get("ai_confidence"), non_negative=True) + ai_reco = payload.get("ai_recommendation") + if isinstance(ai_reco, Mapping): + ai_action = _string_value(ai_reco, "action") or ai_action + ai_confidence = _optional_float(ai_reco.get("confidence"), non_negative=True) + + opportunity_score = _optional_float( + payload.get("opportunity_score"), min_value=0.0, max_value=1.0 + ) + demand_indicator = _optional_float( + payload.get("demand_indicator"), min_value=0.0, max_value=1.0 + ) + inventory_turnover_days = _optional_float( + payload.get("inventory_turnover_days"), non_negative=True + ) + historical_prices = _parse_float_list(payload.get("historical_prices")) + + optional_fields = { + "minimum_margin_percent": minimum_margin_percent, + "cost_price": cost_price, + "recommended_price": recommended_price, + "competitor_prices": competitor_prices or None, + "opportunity_score": opportunity_score, + "demand_indicator": demand_indicator, + "inventory_turnover_days": inventory_turnover_days, + "historical_prices": historical_prices or None, + "competitor_oos_count": competitor_oos_count, + } + quality = DataQualityReport.from_fields(optional_fields) + + return ( + cls( + product_id=product_id, + seller_id=seller_id, + seller_price=seller_price, + seller_stock=seller_stock, + supplier_lead_time_days=supplier_lead_time_days, + minimum_margin_percent=minimum_margin_percent, + cost_price=cost_price, + recommended_price=recommended_price, + competitor_prices=competitor_prices, + competitor_stock_statuses=competitor_statuses, + competitor_oos_count=competitor_oos_count, + opportunity_score=opportunity_score, + demand_indicator=demand_indicator, + inventory_turnover_days=inventory_turnover_days, + historical_prices=historical_prices, + ai_action=ai_action, + ai_confidence=ai_confidence, + ), + quality, + ) + + +@dataclass(frozen=True) +class DerivedMetrics: + competitor_count: int + competitor_oos_ratio: float + competitor_median_price: float | None + competitor_min_price: float | None + competitor_std_dev: float | None + price_gap_percent: float | None + recommended_gap_percent: float | None + margin_percent: float | None + estimated_days_left: float + market_position_score: float + pricing_pressure_score: float + competitor_consistency_score: float + + +@dataclass(frozen=True) +class DataQualityReport: + completeness_score: float + missing_fields: list[str] = field(default_factory=list) + + @classmethod + def from_fields(cls, fields: Mapping[str, Any]) -> "DataQualityReport": + missing = [key for key, value in fields.items() if value is None] + completeness = safe_divide( + float(len(fields) - len(missing)), float(len(fields)), default=0.0 + ) + return cls(completeness_score=round(completeness, 2), missing_fields=missing) + + +@dataclass(frozen=True) +class RuleResult: + rule_id: str + title: str + triggered: bool + severity: float + summary: str + action_hint: ActionType | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "rule_id": self.rule_id, + "title": self.title, + "triggered": self.triggered, + "severity": round(self.severity, 2), + "summary": self.summary, + "action_hint": self.action_hint, + } + + +@dataclass(frozen=True) +class UrgencyResult: + urgency_score: int + urgency_level: UrgencyLevel + action_type: ActionType + confidence: ConfidenceLevel + confidence_score: float + revenue_opportunity: float + stock_risk_level: StockRiskLevel + reasoning_summary: str + triggered_rules: list[str] + market_opportunity_score: int + rule_results: list[RuleResult] = field(default_factory=list) + data_quality: DataQualityReport | None = None + diagnostics: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "urgency_score": self.urgency_score, + "urgency_level": self.urgency_level, + "action_type": self.action_type, + "confidence": self.confidence, + "confidence_score": round(self.confidence_score, 2), + "revenue_opportunity": round(self.revenue_opportunity, 2), + "stock_risk_level": self.stock_risk_level, + "reasoning_summary": self.reasoning_summary, + "triggered_rules": self.triggered_rules, + "market_opportunity_score": self.market_opportunity_score, + "rule_results": [rule.to_dict() for rule in self.rule_results], + "data_quality": { + "completeness_score": self.data_quality.completeness_score, + "missing_fields": self.data_quality.missing_fields, + } + if self.data_quality + else None, + "diagnostics": self.diagnostics, + } + + +@dataclass(frozen=True) +class RuleContext: + payload: UrgencyInput + derived: DerivedMetrics + config: UrgencyEngineConfig + + +class UrgencyRule(Protocol): + rule_id: str + title: str + + def evaluate(self, context: RuleContext) -> RuleResult: + ... + + +class CompetitorOutOfStockRule: + rule_id = "COMPETITOR_OUT_OF_STOCK" + title = "Competitor stockouts detected" + + def evaluate(self, context: RuleContext) -> RuleResult: + ratio = context.derived.competitor_oos_ratio + triggered = ratio >= context.config.thresholds.competitor_oos_ratio_threshold + severity = clamp(ratio, 0.0, 1.0) + summary = ( + f"{int(ratio * 100)}% competitors out of stock" + if triggered + else "Competitor stock levels stable" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="PRICE_RAISE" if triggered else None, + ) + + +class SellerStockoutRiskRule: + rule_id = "SELLER_STOCKOUT_RISK" + title = "Seller stockout risk" + + def evaluate(self, context: RuleContext) -> RuleResult: + days_left = context.derived.estimated_days_left + stock_warning = context.payload.seller_stock <= context.config.thresholds.stock_warning_units + triggered = ( + days_left <= context.config.thresholds.stockout_days_threshold or stock_warning + ) + severity = clamp( + 1.0 + - safe_divide( + days_left, context.config.thresholds.stockout_days_threshold, default=1.0 + ), + 0.0, + 1.0, + ) + summary = ( + f"Stock risk: {days_left:.1f} days left" + if triggered + else "Stock level within safe range" + ) + action_hint = "REORDER" if days_left <= context.payload.supplier_lead_time_days else "STOCK_WARNING" + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint=action_hint if triggered else None, + ) + + +class PriceGapThresholdRule: + rule_id = "PRICE_GAP_THRESHOLD" + title = "Price gap exceeds threshold" + + def evaluate(self, context: RuleContext) -> RuleResult: + gap = context.derived.price_gap_percent + triggered = gap is not None and gap >= context.config.thresholds.price_gap_threshold_percent + severity = clamp( + safe_divide(gap or 0.0, context.config.thresholds.aggressive_competitor_gap_percent, default=0.0), + 0.0, + 1.0, + ) + summary = ( + f"Seller price {gap:.1f}% above market median" + if triggered and gap is not None + else "Price gap within acceptable range" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="PRICE_LOWER" if triggered else None, + ) + + +class LowMarginRiskRule: + rule_id = "LOW_MARGIN_RISK" + title = "Margin risk below threshold" + + def evaluate(self, context: RuleContext) -> RuleResult: + margin = context.derived.margin_percent + threshold = context.payload.minimum_margin_percent + triggered = margin is not None and margin < threshold + severity = clamp( + safe_divide(threshold - (margin or 0.0), threshold, default=0.0), 0.0, 1.0 + ) + summary = ( + f"Margin {margin:.1f}% below minimum {threshold:.1f}%" + if triggered and margin is not None + else "Margin within safe bounds" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="HOLD" if triggered else None, + ) + + +class MarketOpportunityRule: + rule_id = "MARKET_OPPORTUNITY" + title = "Market opportunity detected" + + def evaluate(self, context: RuleContext) -> RuleResult: + score = context.payload.opportunity_score or 0.0 + oos_ratio = context.derived.competitor_oos_ratio + triggered = ( + score >= context.config.thresholds.market_opportunity_threshold + and oos_ratio >= context.config.thresholds.competitor_oos_ratio_threshold + ) + severity = clamp((score + oos_ratio) / 2.0, 0.0, 1.0) + summary = ( + f"Opportunity score {score:.2f} with {int(oos_ratio * 100)}% competitor OOS" + if triggered + else "Market opportunity below threshold" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="OPPORTUNITY_CAPTURE" if triggered else None, + ) + + +class AggressiveCompetitorPricingRule: + rule_id = "AGGRESSIVE_COMPETITOR_PRICING" + title = "Aggressive competitor pricing detected" + + def evaluate(self, context: RuleContext) -> RuleResult: + min_price = context.derived.competitor_min_price + if min_price is None: + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=False, + severity=0.0, + summary="No competitor price baseline", + ) + gap_percent = compute_price_gap_percent(context.payload.seller_price, min_price) + triggered = ( + gap_percent is not None + and gap_percent >= context.config.thresholds.aggressive_competitor_gap_percent + ) + severity = clamp( + safe_divide( + gap_percent or 0.0, context.config.thresholds.aggressive_competitor_gap_percent, default=0.0 + ), + 0.0, + 1.0, + ) + summary = ( + f"Competitor undercuts by {gap_percent:.1f}%" + if triggered and gap_percent is not None + else "Competitor pricing within range" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="PRICE_LOWER" if triggered else None, + ) + + +class HighDemandOpportunityRule: + rule_id = "HIGH_DEMAND_SIGNAL" + title = "High demand signal detected" + + def evaluate(self, context: RuleContext) -> RuleResult: + demand = context.payload.demand_indicator or 0.0 + triggered = demand >= context.config.thresholds.high_demand_threshold + severity = clamp(demand, 0.0, 1.0) + summary = ( + f"Demand index {demand:.2f} indicates surge" + if triggered + else "Demand within normal range" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="OPPORTUNITY_CAPTURE" if triggered else None, + ) + + +class SupplierLeadTimeRiskRule: + rule_id = "SUPPLIER_LEAD_TIME_RISK" + title = "Supplier lead time risk" + + def evaluate(self, context: RuleContext) -> RuleResult: + lead_time = context.payload.supplier_lead_time_days + days_left = context.derived.estimated_days_left + triggered = ( + lead_time >= context.config.thresholds.lead_time_risk_days + or lead_time >= days_left + ) + severity = clamp( + safe_divide( + lead_time, max(context.config.thresholds.lead_time_risk_days, 1), default=0.0 + ), + 0.0, + 1.0, + ) + summary = ( + f"Lead time {lead_time} days risks stock gap" + if triggered + else "Lead time within acceptable range" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="REORDER" if triggered else None, + ) + + +class InventoryDepletionRule: + rule_id = "INVENTORY_DEPLETION_PREDICTION" + title = "Inventory depletion predicted" + + def evaluate(self, context: RuleContext) -> RuleResult: + days_left = context.derived.estimated_days_left + turnover_days = context.payload.inventory_turnover_days + threshold = context.config.thresholds.inventory_depletion_days + turnover_triggered = ( + turnover_days is not None + and turnover_days <= context.config.thresholds.inventory_turnover_threshold_days + ) + triggered = days_left <= threshold or turnover_triggered + severity = clamp( + safe_divide(threshold - days_left, threshold, default=0.0), 0.0, 1.0 + ) + summary = ( + f"Inventory depletion expected in {days_left:.1f} days" + if triggered + else "Inventory horizon stable" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="REORDER" if triggered else None, + ) + + +class ProfitProtectionRule: + rule_id = "PROFIT_PROTECTION" + title = "Profit protection required" + + def evaluate(self, context: RuleContext) -> RuleResult: + recommended_price = context.payload.recommended_price + cost_price = context.payload.cost_price + if recommended_price is None or cost_price is None: + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=False, + severity=0.0, + summary="Profit protection not evaluated", + ) + margin = _calculate_margin_percent(recommended_price, cost_price) + threshold = context.payload.minimum_margin_percent + triggered = margin < threshold + severity = clamp( + safe_divide(threshold - margin, threshold, default=0.0), 0.0, 1.0 + ) + summary = ( + f"Recommended price margin {margin:.1f}% below minimum {threshold:.1f}%" + if triggered + else "Recommended price preserves margin" + ) + return RuleResult( + rule_id=self.rule_id, + title=self.title, + triggered=triggered, + severity=severity, + summary=summary, + action_hint="HOLD" if triggered else None, + ) + + +RULE_SET: list[UrgencyRule] = [ + CompetitorOutOfStockRule(), + SellerStockoutRiskRule(), + PriceGapThresholdRule(), + LowMarginRiskRule(), + MarketOpportunityRule(), + AggressiveCompetitorPricingRule(), + HighDemandOpportunityRule(), + SupplierLeadTimeRiskRule(), + InventoryDepletionRule(), + ProfitProtectionRule(), +] + +# Example inputs/expected outputs for unit testing. +EXAMPLE_INPUT: dict[str, Any] = { + "product_id": "DBL200", + "seller_id": "SELLER-42", + "seller_price": 450, + "recommended_price": 430, + "seller_stock": 12, + "supplier_lead_time_days": 7, + "minimum_margin_percent": 12, + "cost_price": 320, + "competitors": [ + {"price": 420, "stock_status": "in_stock"}, + {"price": 410, "stock_status": "out_of_stock"}, + {"price": 415, "stock_status": "out_of_stock"}, + ], + "opportunity_score": 0.78, + "demand_indicator": 0.7, + "inventory_turnover_days": 8, + "ai_recommendation": {"action": "PRICE_LOWER", "confidence": 0.71}, +} + +EXAMPLE_EXPECTED_OUTPUT: dict[str, Any] = { + "urgency_level": "HIGH", + "action_type": "PRICE_LOWER", + "confidence": "MEDIUM", +} + +EXAMPLE_RULE_EVALUATIONS: list[str] = [ + "COMPETITOR_OUT_OF_STOCK", + "PRICE_GAP_THRESHOLD", + "HIGH_DEMAND_SIGNAL", +] + + +class UrgencyEngine: + """Deterministic urgency engine for competitor intelligence.""" + + def __init__( + self, + config: UrgencyEngineConfig | None = None, + *, + rules: Sequence[UrgencyRule] | None = None, + logger: logging.Logger | None = None, + opportunity_calculator: "OpportunityCalculator | None" = None, + ) -> None: + self._config = config or UrgencyEngineConfig.from_env() + self._rules = list(rules) if rules is not None else list(RULE_SET) + self._logger = logger or logging.getLogger(__name__) + self._opportunity_calculator = opportunity_calculator + + def evaluate(self, payload: Mapping[str, Any]) -> UrgencyResult: + start = time.monotonic() + input_data, quality = UrgencyInput.from_payload( + payload, config=self._config, logger=self._logger + ) + derived = self._derive_metrics(input_data) + if derived.competitor_count < self._config.thresholds.min_competitor_count: + self._logger.warning( + "urgency_competitor_data_sparse product_id=%s count=%s", + input_data.product_id, + derived.competitor_count, + ) + context = RuleContext(payload=input_data, derived=derived, config=self._config) + rule_results = [rule.evaluate(context) for rule in self._rules] + triggered_rules = [rule.rule_id for rule in rule_results if rule.triggered] + self._log_event( + "urgency_rule_evaluation", + product_id=input_data.product_id, + seller_id=input_data.seller_id, + triggered_rules=",".join(triggered_rules), + competitor_count=derived.competitor_count, + competitor_oos_ratio=round(derived.competitor_oos_ratio, 2), + ) + + urgency_score = self._compute_urgency_score(input_data, derived, rule_results) + urgency_level = self._resolve_urgency_level(urgency_score) + stock_risk_level = self._resolve_stock_risk_level(derived) + action_type = self._determine_action(input_data, derived, rule_results) + confidence_score, confidence_level = self._compute_confidence( + input_data, derived, rule_results, quality + ) + market_opportunity_score = self._compute_market_opportunity_score(input_data, derived) + revenue_opportunity = self._compute_revenue_opportunity(input_data, derived) + reasoning_summary = self._build_reasoning_summary( + urgency_level, rule_results, derived + ) + + duration_ms = int((time.monotonic() - start) * 1000) + self._log_event( + "urgency_engine_complete", + product_id=input_data.product_id, + seller_id=input_data.seller_id, + urgency_score=urgency_score, + urgency_level=urgency_level, + action_type=action_type, + confidence=confidence_level, + triggered_rules=len(triggered_rules), + duration_ms=duration_ms, + ) + + diagnostics = { + "price_gap_percent": _round_or_none(derived.price_gap_percent), + "margin_percent": _round_or_none(derived.margin_percent), + "estimated_days_left": round(derived.estimated_days_left, 2), + "competitor_oos_ratio": round(derived.competitor_oos_ratio, 2), + } + + return UrgencyResult( + urgency_score=urgency_score, + urgency_level=urgency_level, + action_type=action_type, + confidence=confidence_level, + confidence_score=confidence_score, + revenue_opportunity=revenue_opportunity, + stock_risk_level=stock_risk_level, + reasoning_summary=reasoning_summary, + triggered_rules=triggered_rules, + market_opportunity_score=market_opportunity_score, + rule_results=rule_results, + data_quality=quality, + diagnostics=diagnostics, + ) + + async def evaluate_async(self, payload: Mapping[str, Any]) -> UrgencyResult: + return self.evaluate(payload) + + def _derive_metrics(self, payload: UrgencyInput) -> DerivedMetrics: + competitor_stats = compute_competitor_price_stats(payload.competitor_prices) + competitor_count = len(payload.competitor_prices) + competitor_median = competitor_stats["median"] + competitor_min = competitor_stats["min"] + competitor_std_dev = competitor_stats["std_dev"] + price_gap = compute_price_gap_percent(payload.seller_price, competitor_median) + recommended_gap = compute_price_gap_percent( + payload.seller_price, payload.recommended_price + ) + + oos_ratio = self._compute_oos_ratio(payload, competitor_count) + margin_percent = ( + _calculate_margin_percent(payload.seller_price, payload.cost_price) + if payload.cost_price is not None + else None + ) + estimated_days_left = self._estimate_days_left(payload) + market_position_score = score_market_position(price_gap) + pricing_pressure = score_pricing_pressure( + price_gap, self._config.confidence.price_signal_reference_percent + ) + consistency_score = self._compute_competitor_consistency( + competitor_median, competitor_std_dev + ) + + return DerivedMetrics( + competitor_count=competitor_count, + competitor_oos_ratio=oos_ratio, + competitor_median_price=competitor_median, + competitor_min_price=competitor_min, + competitor_std_dev=competitor_std_dev, + price_gap_percent=price_gap, + recommended_gap_percent=recommended_gap, + margin_percent=margin_percent, + estimated_days_left=estimated_days_left, + market_position_score=market_position_score, + pricing_pressure_score=pricing_pressure, + competitor_consistency_score=consistency_score, + ) + + def _compute_oos_ratio(self, payload: UrgencyInput, competitor_count: int) -> float: + if competitor_count == 0: + return 0.0 + if payload.competitor_oos_count is not None: + return clamp( + safe_divide(payload.competitor_oos_count, competitor_count, default=0.0), + 0.0, + 1.0, + ) + if not payload.competitor_stock_statuses: + return 0.0 + oos_count = sum( + 1 + for status in payload.competitor_stock_statuses + if normalize_stock_status(status) == "out_of_stock" + ) + return clamp(safe_divide(oos_count, competitor_count, default=0.0), 0.0, 1.0) + + def _estimate_days_left(self, payload: UrgencyInput) -> float: + if payload.seller_stock <= 0: + return 0.0 + if payload.inventory_turnover_days is not None: + return max(payload.inventory_turnover_days, 0.0) + demand_indicator = payload.demand_indicator or 0.3 + base_velocity = 1.0 + demand_indicator + return payload.seller_stock / max(base_velocity, 0.1) + + def _compute_competitor_consistency( + self, competitor_median: float | None, std_dev: float | None + ) -> float: + if competitor_median is None or competitor_median <= 0 or std_dev is None: + return 0.5 + coefficient = safe_divide(std_dev, competitor_median, default=1.0) + return clamp( + 1.0 - safe_divide(coefficient, self._config.confidence.competitor_variance_reference, default=1.0), + 0.0, + 1.0, + ) + + def _compute_urgency_score( + self, + payload: UrgencyInput, + derived: DerivedMetrics, + rules: Sequence[RuleResult], + ) -> int: + thresholds = self._config.thresholds + weights = self._config.weights + + stock_pressure = score_stock_risk( + derived.estimated_days_left, + payload.supplier_lead_time_days, + thresholds.stockout_days_threshold, + ) + competitor_pressure = derived.pricing_pressure_score + market_opportunity = clamp( + (payload.opportunity_score or 0.0) * 0.6 + + derived.competitor_oos_ratio * 0.4, + 0.0, + 1.0, + ) + price_competitiveness = 1.0 - derived.market_position_score + margin_safety = clamp( + safe_divide( + thresholds.low_margin_threshold_percent - (derived.margin_percent or thresholds.low_margin_threshold_percent), + thresholds.low_margin_threshold_percent, + default=0.0, + ), + 0.0, + 1.0, + ) + demand_signal = payload.demand_indicator or 0.3 + lead_time_risk = clamp( + safe_divide(payload.supplier_lead_time_days, thresholds.lead_time_risk_days, default=0.0), + 0.0, + 1.0, + ) + + weighted_score = ( + stock_pressure * weights.stock_pressure + + competitor_pressure * weights.competitor_pressure + + market_opportunity * weights.market_opportunity + + price_competitiveness * weights.price_competitiveness + + margin_safety * weights.margin_safety + + demand_signal * weights.demand_signal + + lead_time_risk * weights.lead_time_risk + ) + rule_boost = clamp( + safe_divide(sum(rule.severity for rule in rules if rule.triggered), 5.0, default=0.0), + 0.0, + 0.15, + ) + urgency_score = int(round(clamp(weighted_score + rule_boost, 0.0, 1.0) * 100)) + return min(urgency_score, thresholds.critical_max) + + def _resolve_urgency_level(self, score: int) -> UrgencyLevel: + thresholds = self._config.thresholds + if score <= thresholds.low_max: + return "LOW" + if score <= thresholds.medium_max: + return "MEDIUM" + if score <= thresholds.high_max: + return "HIGH" + return "CRITICAL" + + def _resolve_stock_risk_level(self, derived: DerivedMetrics) -> StockRiskLevel: + days_left = derived.estimated_days_left + if days_left <= 0: + return "CRITICAL" + if days_left <= self._config.thresholds.stockout_days_threshold: + return "HIGH" + if days_left <= self._config.thresholds.inventory_depletion_days: + return "MEDIUM" + return "LOW" + + def _determine_action( + self, + payload: UrgencyInput, + derived: DerivedMetrics, + rules: Sequence[RuleResult], + ) -> ActionType: + rule_actions = [rule.action_hint for rule in rules if rule.triggered and rule.action_hint] + + # Priority order keeps decisions deterministic. + priority = [ + "REORDER", + "STOCK_WARNING", + "PRICE_LOWER", + "PRICE_RAISE", + "OPPORTUNITY_CAPTURE", + "HOLD", + ] + for action in priority: + if action in rule_actions: + if action == "PRICE_LOWER" and not self._is_margin_safe(payload, derived): + return "HOLD" + return action + + if payload.ai_action and payload.ai_action in priority: + return payload.ai_action + + return "HOLD" + + def _is_margin_safe(self, payload: UrgencyInput, derived: DerivedMetrics) -> bool: + if derived.margin_percent is None: + return True + return derived.margin_percent >= payload.minimum_margin_percent + + def _compute_confidence( + self, + payload: UrgencyInput, + derived: DerivedMetrics, + rules: Sequence[RuleResult], + quality: DataQualityReport, + ) -> tuple[float, ConfidenceLevel]: + config = self._config.confidence + triggered = sum(1 for rule in rules if rule.triggered) + rule_score = clamp( + safe_divide(triggered, config.rules_for_high_confidence, default=0.0), + 0.0, + 1.0, + ) + data_score = clamp(quality.completeness_score, 0.0, 1.0) + competitor_score = derived.competitor_consistency_score + recommendation_score = self._recommendation_stability_score(payload) + pricing_signal_score = clamp( + safe_divide(abs(derived.price_gap_percent or 0.0), config.price_signal_reference_percent, default=0.0), + 0.0, + 1.0, + ) + + weighted = ( + rule_score * config.weight_rules + + data_score * config.weight_data + + competitor_score * config.weight_competitor_consistency + + recommendation_score * config.weight_recommendation_stability + + pricing_signal_score * config.weight_pricing_signal + ) + weighted = clamp(weighted, 0.0, 1.0) + if quality.completeness_score < config.min_data_completeness: + weighted *= safe_divide( + quality.completeness_score, + config.min_data_completeness, + default=0.5, + ) + + if weighted >= config.high_confidence_threshold: + return round(weighted, 2), "HIGH" + if weighted >= config.medium_confidence_threshold: + return round(weighted, 2), "MEDIUM" + return round(weighted, 2), "LOW" + + def _recommendation_stability_score(self, payload: UrgencyInput) -> float: + if payload.recommended_price is None: + return 0.5 + delta_percent = abs(payload.recommended_price - payload.seller_price) / payload.seller_price * 100.0 + return clamp( + 1.0 - safe_divide(delta_percent, self._config.confidence.max_recommendation_delta_percent, default=1.0), + 0.0, + 1.0, + ) + + def _compute_market_opportunity_score( + self, payload: UrgencyInput, derived: DerivedMetrics + ) -> int: + opportunity = payload.opportunity_score or 0.0 + demand = payload.demand_indicator or 0.0 + score = clamp( + (opportunity * 0.4) + (demand * 0.3) + (derived.competitor_oos_ratio * 0.3), + 0.0, + 1.0, + ) + return int(round(score * 100)) + + def _compute_revenue_opportunity( + self, payload: UrgencyInput, derived: DerivedMetrics + ) -> float: + from app.recommendations.opportunity_calculator import ( + OpportunityCalculationError, + OpportunityCalculator, + OpportunityInput, + ) + + calculator = self._opportunity_calculator or OpportunityCalculator( + config=self._config.opportunity, logger=self._logger + ) + opportunity_input = OpportunityInput( + seller_price=payload.seller_price, + recommended_price=payload.recommended_price, + current_stock=payload.seller_stock, + margin_percent=derived.margin_percent or 0.0, + opportunity_score=payload.opportunity_score or 0.0, + demand_indicator=payload.demand_indicator or 0.0, + competitor_oos_ratio=derived.competitor_oos_ratio, + inventory_turnover_days=payload.inventory_turnover_days, + ) + try: + result = calculator.calculate(opportunity_input) + except OpportunityCalculationError as exc: + self._logger.warning( + "urgency_opportunity_error product_id=%s error=%s", + payload.product_id, + exc, + ) + return 0.0 + self._logger.info( + "urgency_opportunity_complete product_id=%s revenue=%s", + payload.product_id, + round(result.additional_revenue, 2), + ) + return result.additional_revenue + + def _build_reasoning_summary( + self, + urgency_level: UrgencyLevel, + rules: Sequence[RuleResult], + derived: DerivedMetrics, + ) -> str: + triggered = [rule for rule in rules if rule.triggered] + if not triggered: + return "No critical market or inventory risks detected." + + top = sorted(triggered, key=lambda rule: rule.severity, reverse=True)[:3] + summary_parts = [rule.summary for rule in top] + if derived.price_gap_percent is not None: + summary_parts.append(f"Price gap {derived.price_gap_percent:.1f}% vs market.") + return f"{urgency_level} urgency: " + " ".join(summary_parts) + + def _log_event(self, event: str, **fields: Any) -> None: + safe_fields = {key: value for key, value in fields.items() if value is not None} + payload = " ".join(f"{key}={value}" for key, value in safe_fields.items()) + self._logger.info(f"{event} {payload}".strip()) + + +def _parse_competitor_payload( + competitors: Any, + competitor_prices: Any, + competitor_statuses: Any, +) -> tuple[list[float], list[str]]: + prices: list[float] = [] + statuses: list[str] = [] + if isinstance(competitors, Iterable) and not isinstance(competitors, (str, bytes, Mapping)): + for entry in competitors: + if not isinstance(entry, Mapping): + continue + price = _optional_float(entry.get("price") or entry.get("competitor_price"), positive=True) + if price is not None: + prices.append(price) + status = _string_value(entry, "stock_status", fallback="availability") + if status: + statuses.append(status) + if isinstance(competitor_prices, Sequence) and not isinstance(competitor_prices, (str, bytes)): + prices.extend(_parse_float_list(competitor_prices)) + if isinstance(competitor_statuses, Sequence) and not isinstance( + competitor_statuses, (str, bytes) + ): + statuses.extend([status for status in competitor_statuses if isinstance(status, str)]) + return prices, statuses + + +def _parse_float_list(value: Any) -> list[float]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return [] + parsed: list[float] = [] + for item in value: + parsed_value = _optional_float(item, non_negative=True) + if parsed_value is not None: + parsed.append(parsed_value) + return parsed + + +def _calculate_margin_percent(selling_price: float, cost_price: float) -> float: + if selling_price <= 0: + return 0.0 + return ((selling_price - cost_price) / selling_price) * 100.0 + + +def _round_or_none(value: float | None) -> float | None: + if value is None: + return None + return round(value, 2) + + +def _require_float( + payload: Mapping[str, Any], + field: str, + *, + positive: bool = False, + non_negative: bool = False, +) -> float: + value = payload.get(field) + if value is None or isinstance(value, bool): + raise InvalidUrgencyInputError(f"{field} is required and must be a number.") + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise InvalidUrgencyInputError(f"{field} must be a number.") from exc + if not isfinite(numeric): + raise InvalidUrgencyInputError(f"{field} must be finite.") + if positive and numeric <= 0: + raise InvalidUrgencyInputError(f"{field} must be greater than 0.") + if non_negative and numeric < 0: + raise InvalidUrgencyInputError(f"{field} cannot be negative.") + return numeric + + +def _require_float_value( + field: str, + value: Any, + *, + positive: bool = False, + non_negative: bool = False, +) -> float: + if value is None or isinstance(value, bool): + raise InvalidUrgencyInputError(f"{field} is required and must be a number.") + try: + numeric = float(value) + except (TypeError, ValueError) as exc: + raise InvalidUrgencyInputError(f"{field} must be a number.") from exc + if not isfinite(numeric): + raise InvalidUrgencyInputError(f"{field} must be finite.") + if positive and numeric <= 0: + raise InvalidUrgencyInputError(f"{field} must be greater than 0.") + if non_negative and numeric < 0: + raise InvalidUrgencyInputError(f"{field} cannot be negative.") + return numeric + + +def _require_int( + payload: Mapping[str, Any], + field: str, + *, + non_negative: bool = False, +) -> int: + value = payload.get(field) + if value is None or isinstance(value, bool): + raise InvalidUrgencyInputError(f"{field} is required and must be an integer.") + try: + numeric = int(value) + except (TypeError, ValueError) as exc: + raise InvalidUrgencyInputError(f"{field} must be an integer.") from exc + if non_negative and numeric < 0: + raise InvalidUrgencyInputError(f"{field} cannot be negative.") + return numeric + + +def _require_int_value( + field: str, + value: Any, + *, + non_negative: bool = False, +) -> int: + if value is None or isinstance(value, bool): + raise InvalidUrgencyInputError(f"{field} is required and must be an integer.") + try: + numeric = int(value) + except (TypeError, ValueError) as exc: + raise InvalidUrgencyInputError(f"{field} must be an integer.") from exc + if non_negative and numeric < 0: + raise InvalidUrgencyInputError(f"{field} cannot be negative.") + return numeric + + +def _optional_float( + value: Any, + *, + positive: bool = False, + non_negative: bool = False, + min_value: float | None = None, + max_value: float | None = None, +) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not isfinite(numeric): + return None + if positive and numeric <= 0: + return None + if non_negative and numeric < 0: + return None + if min_value is not None and numeric < min_value: + return None + if max_value is not None and numeric > max_value: + return None + return numeric + + +def _optional_int(value: Any, *, non_negative: bool = False) -> int | None: + if value is None or isinstance(value, bool): + return None + try: + numeric = int(value) + except (TypeError, ValueError): + return None + if non_negative and numeric < 0: + return None + return numeric + + +def _string_value( + payload: Mapping[str, Any], + field: str, + *, + fallback: str | None = None, +) -> str | None: + value = payload.get(field) + if value is None and fallback: + value = payload.get(fallback) + if isinstance(value, str): + trimmed = value.strip() + return trimmed or None + return None + + +def _value_with_fallback(payload: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + if key in payload and payload.get(key) is not None: + return payload.get(key) + return None + + +def _get_env_float(key: str, default: float) -> float: + value = os.getenv(key) + if value is None: + return default + try: + numeric = float(value) + except ValueError: + return default + return numeric + + +def _get_env_int(key: str, default: int) -> int: + value = os.getenv(key) + if value is None: + return default + try: + numeric = int(value) + except ValueError: + return default + return numeric + + +if __name__ == "__main__": + sample_payload = { + "product_id": "DBL200", + "seller_id": "SELLER-42", + "seller_price": 450, + "recommended_price": 430, + "seller_stock": 12, + "supplier_lead_time_days": 7, + "minimum_margin_percent": 12, + "cost_price": 320, + "competitors": [ + {"price": 420, "stock_status": "in_stock"}, + {"price": 410, "stock_status": "out_of_stock"}, + {"price": 415, "stock_status": "out_of_stock"}, + ], + "opportunity_score": 0.78, + "demand_indicator": 0.7, + "inventory_turnover_days": 8, + "ai_recommendation": {"action": "PRICE_LOWER", "confidence": 0.71}, + } + + engine = UrgencyEngine() + output = engine.evaluate(sample_payload) + print(output.to_dict()) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..06cbb90 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from app.repositories.alert_repository import AlertRepository +from app.repositories.competitor_repository import CompetitorRepository +from app.repositories.recommendation_repository import RecommendationRepository + +__all__ = [ + "AlertRepository", + "CompetitorRepository", + "RecommendationRepository", +] diff --git a/backend/app/repositories/alert_repository.py b/backend/app/repositories/alert_repository.py new file mode 100644 index 0000000..0a87919 --- /dev/null +++ b/backend/app/repositories/alert_repository.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Mapping + +from sqlalchemy import case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.alert import Alert +from app.models.product import Product +from app.repositories.query_utils import PaginationParams, SortDirection, apply_date_range, apply_sorting + + +class AlertRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_alerts( + self, + *, + seller_id: str | None = None, + product_id: str | None = None, + urgency_level: str | None = None, + action_type: str | None = None, + delivery_status: str | None = None, + is_read: bool | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> tuple[list[Mapping[str, Any]], int]: + base_stmt = ( + select( + Alert.id.label("alert_id"), + Alert.product_id.label("product_id"), + Product.title.label("product_title"), + Alert.alert_type.label("alert_type"), + Alert.urgency_level.label("urgency_level"), + Alert.action_type.label("action_type"), + Alert.confidence.label("confidence"), + Alert.delivery_status.label("delivery_status"), + Alert.delivered_at.label("delivered_at"), + Alert.read_at.label("read_at"), + Alert.telegram_chat_id.label("telegram_chat_id"), + Alert.telegram_message_id.label("telegram_message_id"), + Alert.created_at.label("created_at"), + ) + .join(Product, Alert.product_id == Product.id) + ) + + if seller_id: + base_stmt = base_stmt.where(Alert.seller_id == seller_id) + if product_id: + base_stmt = base_stmt.where(Alert.product_id == product_id) + if urgency_level: + base_stmt = base_stmt.where(Alert.urgency_level == urgency_level) + if action_type: + base_stmt = base_stmt.where(Alert.action_type == action_type) + if delivery_status: + base_stmt = base_stmt.where(Alert.delivery_status == delivery_status) + if is_read is True: + base_stmt = base_stmt.where(Alert.read_at.is_not(None)) + if is_read is False: + base_stmt = base_stmt.where(Alert.read_at.is_(None)) + + base_stmt = apply_date_range( + base_stmt, + column=Alert.created_at, + start=date_from, + end=date_to, + ) + + urgency_rank = case( + (Alert.urgency_level == "CRITICAL", 4), + (Alert.urgency_level == "HIGH", 3), + (Alert.urgency_level == "MEDIUM", 2), + (Alert.urgency_level == "LOW", 1), + else_=0, + ) + allowed_sort = { + "created_at": Alert.created_at, + "urgency_score": urgency_rank, + "delivery_status": Alert.delivery_status, + } + base_stmt = apply_sorting( + base_stmt, + sort_by=sort_by, + sort_direction=sort_direction, + allowed=allowed_sort, + ) + + count_stmt = select(func.count()).select_from(base_stmt.subquery()) + total = await self._session.scalar(count_stmt) or 0 + + page_stmt = base_stmt.limit(pagination.limit).offset(pagination.offset) + result = await self._session.execute(page_stmt) + rows = result.mappings().all() + return rows, total diff --git a/backend/app/repositories/competitor_repository.py b/backend/app/repositories/competitor_repository.py new file mode 100644 index 0000000..55ae42a --- /dev/null +++ b/backend/app/repositories/competitor_repository.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Mapping + +from sqlalchemy import Float, and_, case, cast, func, literal, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.competitor import Competitor +from app.models.price_history import PriceHistory +from app.models.product import Product +from app.repositories.query_utils import PaginationParams, SortDirection, apply_search, apply_sorting + + +class CompetitorRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_competitors( + self, + *, + seller_id: str | None = None, + product_id: str | None = None, + stock_status: str | None = None, + search: str | None = None, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> tuple[list[Mapping[str, Any]], int]: + price_gap_expr = case( + ( + and_(Competitor.competitor_price.is_not(None), Competitor.competitor_price > 0), + ( + (cast(Product.selling_price, Float) - cast(Competitor.competitor_price, Float)) + / cast(Competitor.competitor_price, Float) + * 100.0 + ), + ), + else_=None, + ).label("price_gap_percent") + + trend_subquery = self._price_trend_subquery() + trend_expr = case( + (trend_subquery.c.latest_price.is_(None), literal("stable")), + (trend_subquery.c.previous_price.is_(None), literal("stable")), + (trend_subquery.c.latest_price > trend_subquery.c.previous_price, literal("up")), + (trend_subquery.c.latest_price < trend_subquery.c.previous_price, literal("down")), + else_=literal("flat"), + ).label("price_trend") + + base_stmt = ( + select( + Competitor.id.label("competitor_id"), + Competitor.product_id.label("product_id"), + Product.title.label("product_title"), + Product.selling_price.label("seller_price"), + Competitor.competitor_title.label("competitor_title"), + Competitor.competitor_store.label("competitor_store"), + Competitor.competitor_price.label("competitor_price"), + Competitor.stock_status.label("stock_status"), + Competitor.search_position.label("ranking_position"), + Competitor.similarity_score.label("similarity_score"), + Competitor.marketplace.label("marketplace"), + Competitor.product_url.label("product_url"), + Competitor.created_at.label("created_at"), + price_gap_expr, + trend_expr, + ) + .join(Product, Competitor.product_id == Product.id) + .outerjoin(trend_subquery, trend_subquery.c.competitor_id == Competitor.id) + ) + + if seller_id: + base_stmt = base_stmt.where(Product.seller_id == seller_id) + if product_id: + base_stmt = base_stmt.where(Competitor.product_id == product_id) + if stock_status: + base_stmt = base_stmt.where(Competitor.stock_status == stock_status) + + base_stmt = apply_search( + base_stmt, + search=search, + columns=[Competitor.competitor_title, Competitor.competitor_store, Product.title], + ) + + allowed_sort = { + "created_at": Competitor.created_at, + "price_gap": price_gap_expr, + "ranking_position": Competitor.search_position, + "similarity_score": Competitor.similarity_score, + "competitor_price": Competitor.competitor_price, + } + base_stmt = apply_sorting( + base_stmt, + sort_by=sort_by, + sort_direction=sort_direction, + allowed=allowed_sort, + ) + + count_stmt = select(func.count()).select_from(base_stmt.subquery()) + total = await self._session.scalar(count_stmt) or 0 + + page_stmt = base_stmt.limit(pagination.limit).offset(pagination.offset) + result = await self._session.execute(page_stmt) + rows = result.mappings().all() + + return rows, total + + def _price_trend_subquery(self): + ranked = select( + PriceHistory.competitor_id.label("competitor_id"), + PriceHistory.observed_price.label("observed_price"), + func.row_number() + .over( + partition_by=PriceHistory.competitor_id, + order_by=PriceHistory.observed_at.desc(), + ) + .label("rn"), + ).subquery() + + latest_price = func.max( + case((ranked.c.rn == 1, ranked.c.observed_price), else_=None) + ).label("latest_price") + previous_price = func.max( + case((ranked.c.rn == 2, ranked.c.observed_price), else_=None) + ).label("previous_price") + + return ( + select( + ranked.c.competitor_id, + latest_price, + previous_price, + ) + .group_by(ranked.c.competitor_id) + .subquery() + ) diff --git a/backend/app/repositories/query_utils.py b/backend/app/repositories/query_utils.py new file mode 100644 index 0000000..8882211 --- /dev/null +++ b/backend/app/repositories/query_utils.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from math import ceil +from typing import Any, Iterable, Mapping + +from sqlalchemy import ColumnElement +from sqlalchemy.sql import Select + +from app.services.dashboard.exceptions import InvalidSortFieldError, PaginationOverflowError + + +class SortDirection(str, Enum): + asc = "asc" + desc = "desc" + + +@dataclass(frozen=True) +class PaginationParams: + limit: int + offset: int + page: int + page_size: int + max_page_size: int = 100 + + @classmethod + def from_request( + cls, + *, + limit: int | None = None, + offset: int | None = None, + page: int | None = None, + page_size: int | None = None, + max_page_size: int = 100, + default_page_size: int = 20, + ) -> "PaginationParams": + if page is not None or page_size is not None: + resolved_page = max(1, page or 1) + resolved_size = max(1, page_size or default_page_size) + resolved_size = min(resolved_size, max_page_size) + resolved_offset = (resolved_page - 1) * resolved_size + return cls( + limit=resolved_size, + offset=resolved_offset, + page=resolved_page, + page_size=resolved_size, + max_page_size=max_page_size, + ) + + resolved_limit = max(1, limit or default_page_size) + resolved_limit = min(resolved_limit, max_page_size) + resolved_offset = max(0, offset or 0) + resolved_page = (resolved_offset // resolved_limit) + 1 + return cls( + limit=resolved_limit, + offset=resolved_offset, + page=resolved_page, + page_size=resolved_limit, + max_page_size=max_page_size, + ) + + +def build_pagination_meta(total: int, params: PaginationParams) -> dict[str, Any]: + total_pages = ceil(total / params.page_size) if params.page_size else 0 + if total_pages and params.page > total_pages: + raise PaginationOverflowError("Requested page exceeds total pages.") + return { + "total": total, + "page": params.page, + "page_size": params.page_size, + "total_pages": total_pages, + "has_next": (params.offset + params.limit) < total, + "has_prev": params.page > 1, + } + + +def apply_sorting( + statement: Select, + *, + sort_by: str, + sort_direction: SortDirection, + allowed: Mapping[str, ColumnElement[Any]], +) -> Select: + if sort_by not in allowed: + raise InvalidSortFieldError(f"Sort field '{sort_by}' is not supported.") + column = allowed[sort_by] + return statement.order_by(column.asc() if sort_direction == SortDirection.asc else column.desc()) + + +def apply_search( + statement: Select, + *, + search: str | None, + columns: Iterable[ColumnElement[str]], +) -> Select: + if not search: + return statement + sanitized = f"%{search.strip()}%" + conditions = [column.ilike(sanitized) for column in columns] + if not conditions: + return statement + return statement.where(_or_(*conditions)) + + +def apply_date_range( + statement: Select, + *, + column: ColumnElement[datetime], + start: datetime | None, + end: datetime | None, +) -> Select: + if start is not None: + statement = statement.where(column >= start) + if end is not None: + statement = statement.where(column <= end) + return statement + + +def _or_(*conditions: ColumnElement[bool]) -> ColumnElement[bool]: + from sqlalchemy import or_ + + return or_(*conditions) diff --git a/backend/app/repositories/recommendation_repository.py b/backend/app/repositories/recommendation_repository.py new file mode 100644 index 0000000..5c21f5f --- /dev/null +++ b/backend/app/repositories/recommendation_repository.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Mapping + +from sqlalchemy import Float, cast, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.product import Product +from app.models.recommendation import Recommendation +from app.repositories.query_utils import ( + PaginationParams, + SortDirection, + apply_date_range, + apply_search, + apply_sorting, +) + + +class RecommendationRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_recommendations( + self, + *, + seller_id: str | None = None, + product_id: str | None = None, + action_type: str | None = None, + confidence: str | None = None, + search: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> tuple[list[Mapping[str, Any]], int]: + analytics = Recommendation.recommendation_context["analytics"] + urgency_score_expr = cast(analytics["urgency_score"].astext, Float).label( + "urgency_score" + ) + price_gap_expr = cast(analytics["price_gap_percent"].astext, Float).label( + "price_gap_percent" + ) + stock_risk_expr = cast(analytics["stock_risk_score"].astext, Float).label( + "stock_risk_score" + ) + + base_stmt = ( + select( + Recommendation.id.label("recommendation_id"), + Recommendation.product_id.label("product_id"), + Product.title.label("product_title"), + Recommendation.action.label("action_type"), + Recommendation.confidence.label("confidence"), + Recommendation.reasoning_bn.label("reasoning"), + Recommendation.revenue_opportunity.label("revenue_opportunity"), + Recommendation.created_at.label("created_at"), + urgency_score_expr, + price_gap_expr, + stock_risk_expr, + ) + .join(Product, Recommendation.product_id == Product.id) + ) + + if seller_id: + base_stmt = base_stmt.where(Product.seller_id == seller_id) + if product_id: + base_stmt = base_stmt.where(Recommendation.product_id == product_id) + if action_type: + base_stmt = base_stmt.where(Recommendation.action == action_type) + if confidence: + base_stmt = base_stmt.where(Recommendation.confidence == confidence) + + base_stmt = apply_search( + base_stmt, + search=search, + columns=[Product.title, Recommendation.reasoning_bn], + ) + base_stmt = apply_date_range( + base_stmt, + column=Recommendation.created_at, + start=date_from, + end=date_to, + ) + + allowed_sort = { + "created_at": Recommendation.created_at, + "urgency_score": urgency_score_expr, + "confidence": Recommendation.confidence, + "opportunity": Recommendation.revenue_opportunity, + } + base_stmt = apply_sorting( + base_stmt, + sort_by=sort_by, + sort_direction=sort_direction, + allowed=allowed_sort, + ) + + count_stmt = select(func.count()).select_from(base_stmt.subquery()) + total = await self._session.scalar(count_stmt) or 0 + + page_stmt = base_stmt.limit(pagination.limit).offset(pagination.offset) + result = await self._session.execute(page_stmt) + rows = result.mappings().all() + return rows, total + + async def list_opportunities( + self, + *, + seller_id: str | None = None, + min_revenue: float | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> tuple[list[Mapping[str, Any]], int]: + analytics = Recommendation.recommendation_context["analytics"] + base_stmt = ( + select( + Recommendation.id.label("recommendation_id"), + Recommendation.product_id.label("product_id"), + Product.title.label("product_title"), + Recommendation.revenue_opportunity.label("revenue_opportunity"), + Recommendation.recommendation_context.label("recommendation_context"), + Recommendation.created_at.label("created_at"), + Product.selling_price.label("seller_price"), + Product.cost_price.label("cost_price"), + Product.stock_quantity.label("stock_quantity"), + ) + .join(Product, Recommendation.product_id == Product.id) + .where(Recommendation.revenue_opportunity > 0) + ) + + if seller_id: + base_stmt = base_stmt.where(Product.seller_id == seller_id) + if min_revenue is not None: + base_stmt = base_stmt.where(Recommendation.revenue_opportunity >= min_revenue) + + base_stmt = apply_date_range( + base_stmt, + column=Recommendation.created_at, + start=date_from, + end=date_to, + ) + + allowed_sort = { + "revenue_opportunity": Recommendation.revenue_opportunity, + "created_at": Recommendation.created_at, + "market_opportunity_score": cast( + analytics["market_opportunity_score"].astext, Float + ), + } + base_stmt = apply_sorting( + base_stmt, + sort_by=sort_by, + sort_direction=sort_direction, + allowed=allowed_sort, + ) + + count_stmt = select(func.count()).select_from(base_stmt.subquery()) + total = await self._session.scalar(count_stmt) or 0 + + page_stmt = base_stmt.limit(pagination.limit).offset(pagination.offset) + result = await self._session.execute(page_stmt) + rows = result.mappings().all() + return rows, total diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 2ca6e33..e07354d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -46,6 +46,10 @@ class SellerProfileUpdate(BaseModel): country: str | None = Field(None, max_length=120, description="Business country") +class SellerDeleteRequest(BaseModel): + password: str = Field(..., min_length=8, description="Seller password") + + class SellerResponse(SellerBase): id: UUID created_at: datetime @@ -64,3 +68,13 @@ class LoginResponse(BaseModel): access_token: str token_type: str = "bearer" seller: SellerResponse + + +class DeleteAccountResponse(BaseModel): + message: str = "Seller account deleted successfully." + + model_config = ConfigDict( + json_schema_extra={ + "examples": [{"message": "Seller account deleted successfully."}] + } + ) diff --git a/backend/app/schemas/dashboard/__init__.py b/backend/app/schemas/dashboard/__init__.py new file mode 100644 index 0000000..69566d8 --- /dev/null +++ b/backend/app/schemas/dashboard/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from app.schemas.dashboard.alert_schema import AlertListResponse, AlertRecord +from app.schemas.dashboard.competitor_schema import CompetitorListResponse, CompetitorRecord +from app.schemas.dashboard.error_schema import DashboardErrorResponse, ErrorDetail +from app.schemas.dashboard.opportunity_schema import OpportunityListResponse, OpportunityRecord +from app.schemas.dashboard.pagination_schema import PaginatedResponse, PaginationMeta +from app.schemas.dashboard.recommendation_schema import ( + RecommendationListResponse, + RecommendationRecord, +) +from app.schemas.dashboard.summary_schema import DashboardSummaryResponse, RealtimeActivityCounters + +__all__ = [ + "AlertListResponse", + "AlertRecord", + "CompetitorListResponse", + "CompetitorRecord", + "DashboardErrorResponse", + "ErrorDetail", + "OpportunityListResponse", + "OpportunityRecord", + "PaginatedResponse", + "PaginationMeta", + "RecommendationListResponse", + "RecommendationRecord", + "DashboardSummaryResponse", + "RealtimeActivityCounters", +] diff --git a/backend/app/schemas/dashboard/alert_schema.py b/backend/app/schemas/dashboard/alert_schema.py new file mode 100644 index 0000000..5b6209a --- /dev/null +++ b/backend/app/schemas/dashboard/alert_schema.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.dashboard.pagination_schema import PaginatedResponse + + +class AlertSortField(str, Enum): + created_at = "created_at" + urgency_score = "urgency_score" + delivery_status = "delivery_status" + + +class AlertRecord(BaseModel): + alert_id: str + product_id: str + product_title: str + alert_type: str + urgency_level: str + action_type: str + confidence: str + delivery_status: str + delivery_timestamp: datetime | None = None + telegram_reference: str | None = None + is_read: bool + read_at: datetime | None = None + created_at: datetime + + +class AlertListResponse(PaginatedResponse[AlertRecord]): + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "items": [ + { + "alert_id": "a3ed1ac6-2470-4a7b-91b1-447f0f2a60de", + "product_id": "1b244d3b-1fbe-4e8c-aea6-47ad97527ac7", + "product_title": "Dove Body Lotion 200ml", + "alert_type": "urgency_alert", + "urgency_level": "HIGH", + "action_type": "PRICE_LOWER", + "confidence": "MEDIUM", + "delivery_status": "delivered", + "delivery_timestamp": "2026-05-26T22:02:00Z", + "telegram_reference": "chat:12345 msg:67890", + "is_read": False, + "read_at": None, + "created_at": "2026-05-26T22:01:30Z", + } + ], + "meta": { + "total": 1, + "page": 1, + "page_size": 20, + "total_pages": 1, + "has_next": False, + "has_prev": False, + }, + } + ] + } + ) diff --git a/backend/app/schemas/dashboard/competitor_schema.py b/backend/app/schemas/dashboard/competitor_schema.py new file mode 100644 index 0000000..6e6c898 --- /dev/null +++ b/backend/app/schemas/dashboard/competitor_schema.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.dashboard.pagination_schema import PaginatedResponse + + +class CompetitorSortField(str, Enum): + created_at = "created_at" + price_gap = "price_gap" + ranking_position = "ranking_position" + similarity_score = "similarity_score" + competitor_price = "competitor_price" + + +class CompetitorRecord(BaseModel): + competitor_id: str + product_id: str + product_title: str + competitor_title: str + competitor_store: str | None = None + competitor_price: float | None = None + seller_price: float | None = None + price_gap_percent: float | None = None + stock_status: str | None = None + out_of_stock: bool + ranking_position: int | None = None + similarity_score: float | None = None + marketplace: str + product_url: str | None = None + price_trend: str + created_at: datetime + + +class CompetitorListResponse(PaginatedResponse[CompetitorRecord]): + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "items": [ + { + "competitor_id": "15ed1ac6-2470-4a7b-91b1-447f0f2a60de", + "product_id": "1b244d3b-1fbe-4e8c-aea6-47ad97527ac7", + "product_title": "Dove Body Lotion 200ml", + "competitor_title": "Dove Body Lotion 200ml", + "competitor_store": "Official Store", + "competitor_price": 420.0, + "seller_price": 450.0, + "price_gap_percent": 7.14, + "stock_status": "out_of_stock", + "out_of_stock": True, + "ranking_position": 3, + "similarity_score": 0.91, + "marketplace": "Daraz Bangladesh", + "product_url": "https://www.daraz.com.bd/products/...", + "price_trend": "down", + "created_at": "2026-05-26T22:00:00Z", + } + ], + "meta": { + "total": 1, + "page": 1, + "page_size": 20, + "total_pages": 1, + "has_next": False, + "has_prev": False, + }, + } + ] + } + ) diff --git a/backend/app/schemas/dashboard/error_schema.py b/backend/app/schemas/dashboard/error_schema.py new file mode 100644 index 0000000..a95e581 --- /dev/null +++ b/backend/app/schemas/dashboard/error_schema.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ErrorDetail(BaseModel): + code: str = Field(..., description="Machine readable error code") + message: str = Field(..., description="Human readable error detail") + + +class DashboardErrorResponse(BaseModel): + error: ErrorDetail + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "error": { + "code": "invalid_sort_field", + "message": "Sort field 'unknown' is not supported.", + } + } + ] + } + ) diff --git a/backend/app/schemas/dashboard/opportunity_schema.py b/backend/app/schemas/dashboard/opportunity_schema.py new file mode 100644 index 0000000..e962b52 --- /dev/null +++ b/backend/app/schemas/dashboard/opportunity_schema.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.dashboard.pagination_schema import PaginatedResponse + + +class OpportunitySortField(str, Enum): + revenue_opportunity = "revenue_opportunity" + market_opportunity_score = "market_opportunity_score" + created_at = "created_at" + + +class OpportunityRecord(BaseModel): + recommendation_id: str + product_id: str + product_title: str + revenue_opportunity: float + inventory_capture_units: float + projected_demand_shift: float + estimated_profit_impact: float + competitor_oos_ratio: float + market_opportunity_score: int + created_at: datetime + + +class OpportunityListResponse(PaginatedResponse[OpportunityRecord]): + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "items": [ + { + "recommendation_id": "5f7d1ac6-2470-4a7b-91b1-447f0f2a60de", + "product_id": "1b244d3b-1fbe-4e8c-aea6-47ad97527ac7", + "product_title": "Dove Body Lotion 200ml", + "revenue_opportunity": 4200.0, + "inventory_capture_units": 12.0, + "projected_demand_shift": 18.0, + "estimated_profit_impact": 756.0, + "competitor_oos_ratio": 0.5, + "market_opportunity_score": 71, + "created_at": "2026-05-26T22:05:00Z", + } + ], + "meta": { + "total": 1, + "page": 1, + "page_size": 20, + "total_pages": 1, + "has_next": False, + "has_prev": False, + }, + } + ] + } + ) diff --git a/backend/app/schemas/dashboard/pagination_schema.py b/backend/app/schemas/dashboard/pagination_schema.py new file mode 100644 index 0000000..5c8db73 --- /dev/null +++ b/backend/app/schemas/dashboard/pagination_schema.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +ItemT = TypeVar("ItemT") + + +class PaginationMeta(BaseModel): + total: int = Field(..., ge=0) + page: int = Field(..., ge=1) + page_size: int = Field(..., ge=1) + total_pages: int = Field(..., ge=0) + has_next: bool + has_prev: bool + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "total": 120, + "page": 2, + "page_size": 20, + "total_pages": 6, + "has_next": True, + "has_prev": True, + } + ] + } + ) + + +class PaginatedResponse(BaseModel, Generic[ItemT]): + items: list[ItemT] + meta: PaginationMeta + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "items": [], + "meta": { + "total": 0, + "page": 1, + "page_size": 20, + "total_pages": 0, + "has_next": False, + "has_prev": False, + }, + } + ] + } + ) diff --git a/backend/app/schemas/dashboard/recommendation_schema.py b/backend/app/schemas/dashboard/recommendation_schema.py new file mode 100644 index 0000000..1711cd5 --- /dev/null +++ b/backend/app/schemas/dashboard/recommendation_schema.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.dashboard.pagination_schema import PaginatedResponse + + +class RecommendationSortField(str, Enum): + created_at = "created_at" + urgency_score = "urgency_score" + confidence = "confidence" + opportunity = "opportunity" + + +class RecommendationRecord(BaseModel): + recommendation_id: str + product_id: str + product_title: str + action_type: str + confidence: str + urgency_score: float | None = None + price_gap_percent: float | None = None + stock_risk_score: float | None = None + reasoning: str + revenue_opportunity: float + created_at: datetime + + +class RecommendationListResponse(PaginatedResponse[RecommendationRecord]): + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "items": [ + { + "recommendation_id": "5f7d1ac6-2470-4a7b-91b1-447f0f2a60de", + "product_id": "1b244d3b-1fbe-4e8c-aea6-47ad97527ac7", + "product_title": "Dove Body Lotion 200ml", + "action_type": "LOWER", + "confidence": "HIGH", + "urgency_score": 74.0, + "price_gap_percent": 7.1, + "stock_risk_score": 0.62, + "reasoning": "Competitor median price lower and stock risk rising.", + "revenue_opportunity": 4200.0, + "created_at": "2026-05-26T22:05:00Z", + } + ], + "meta": { + "total": 1, + "page": 1, + "page_size": 20, + "total_pages": 1, + "has_next": False, + "has_prev": False, + }, + } + ] + } + ) diff --git a/backend/app/schemas/dashboard/summary_schema.py b/backend/app/schemas/dashboard/summary_schema.py new file mode 100644 index 0000000..0e7969f --- /dev/null +++ b/backend/app/schemas/dashboard/summary_schema.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class RealtimeActivityCounters(BaseModel): + recommendations_last_hour: int = Field(..., ge=0) + alerts_last_hour: int = Field(..., ge=0) + competitor_updates_last_hour: int = Field(..., ge=0) + stock_risk_events_last_hour: int = Field(..., ge=0) + + +class DashboardSummaryResponse(BaseModel): + total_monitored_products: int = Field(..., ge=0) + active_alerts: int = Field(..., ge=0) + opportunity_count: int = Field(..., ge=0) + high_risk_stock_count: int = Field(..., ge=0) + average_urgency_score: float = Field(..., ge=0, le=100) + average_competitor_price_gap: float = Field(..., ge=-100, le=100) + market_trend_summary: str + realtime_activity: RealtimeActivityCounters + generated_at: datetime + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "total_monitored_products": 125, + "active_alerts": 6, + "opportunity_count": 22, + "high_risk_stock_count": 4, + "average_urgency_score": 58.3, + "average_competitor_price_gap": 6.2, + "market_trend_summary": "Moderate volatility with rising competitor pressure.", + "realtime_activity": { + "recommendations_last_hour": 12, + "alerts_last_hour": 4, + "competitor_updates_last_hour": 31, + "stock_risk_events_last_hour": 3, + }, + "generated_at": "2026-05-26T22:00:00Z", + } + ] + } + ) diff --git a/backend/app/schemas/product_snapshot.py b/backend/app/schemas/product_snapshot.py new file mode 100644 index 0000000..c589e9a --- /dev/null +++ b/backend/app/schemas/product_snapshot.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class ProductSnapshotRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + product_id: UUID + + +class ProductSnapshot(BaseModel): + model_config = ConfigDict(extra="forbid") + + seller_id: UUID + product_id: UUID + product_name: str + current_price: float + current_stock: int + supplier_lead_time: int | None = None + competitor_median_price: float | None = None + price_gap_percent: float | None = None + recommendation_action: str + ai_reason: str + confidence: str + updated_at: datetime + currency: str = Field(default="৳", min_length=1) + + +class ProductSnapshotResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + success: bool = True + message: str + sent_at: datetime | None = None + delivery_status: str | None = None + + +class ProductSnapshotErrorResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + success: bool = False + detail: str + error_code: str | None = None diff --git a/backend/app/schemas/telegram.py b/backend/app/schemas/telegram.py new file mode 100644 index 0000000..fbfcea4 --- /dev/null +++ b/backend/app/schemas/telegram.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class TelegramConnectTokenResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + token: str + expires_at: datetime + bot_username: str | None = None + + +class TelegramConnectionStatus(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_connected: bool + connected_at: datetime | None = None + telegram_username: str | None = None + telegram_first_name: str | None = None + telegram_last_name: str | None = None + last_alert_sent_at: datetime | None = None + bot_username: str | None = None + + +class TelegramDisconnectResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: str = Field(default="disconnected") + + +class TelegramHealthResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: str + bot_username: str | None = None + bot_id: int | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 29c1adb..e5a843a 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -11,6 +11,7 @@ ScrapeRunNotFoundError, ScrapeService, ) +from app.services.realtime import RealtimeService __all__ = [ "AuthService", @@ -25,4 +26,5 @@ "ScrapePipelineError", "ScrapeRunNotFoundError", "ScrapeService", + "RealtimeService", ] diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 01f783c..7993297 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -64,3 +64,9 @@ def update_seller_profile(self, seller: Seller, payload: SellerProfileUpdate) -> self._db.commit() self._db.refresh(seller) return seller + + def delete_seller_account(self, seller: Seller, password: str) -> None: + if not verify_password(password, seller.password_hash): + raise InvalidCredentialsError + self._db.delete(seller) + self._db.commit() diff --git a/backend/app/services/dashboard/__init__.py b/backend/app/services/dashboard/__init__.py new file mode 100644 index 0000000..1f45498 --- /dev/null +++ b/backend/app/services/dashboard/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from app.services.dashboard.alert_service import AlertDashboardService +from app.services.dashboard.competitor_service import CompetitorDashboardService +from app.services.dashboard.dashboard_service import DashboardService +from app.services.dashboard.opportunity_service import OpportunityDashboardService +from app.services.dashboard.recommendation_service import RecommendationDashboardService + +__all__ = [ + "AlertDashboardService", + "CompetitorDashboardService", + "DashboardService", + "OpportunityDashboardService", + "RecommendationDashboardService", +] diff --git a/backend/app/services/dashboard/alert_service.py b/backend/app/services/dashboard/alert_service.py new file mode 100644 index 0000000..e13a774 --- /dev/null +++ b/backend/app/services/dashboard/alert_service.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import logging +import time +from typing import Any + +from sqlalchemy.exc import SQLAlchemyError + +from app.repositories.alert_repository import AlertRepository +from app.repositories.query_utils import PaginationParams, SortDirection, build_pagination_meta +from app.schemas.dashboard.alert_schema import AlertListResponse, AlertRecord +from app.services.dashboard.events import DashboardEventEmitter, NoopEventEmitter +from app.services.dashboard.exceptions import DashboardDatabaseError + + +class AlertDashboardService: + def __init__( + self, + repository: AlertRepository, + *, + logger: logging.Logger | None = None, + event_emitter: DashboardEventEmitter | None = None, + ) -> None: + self._repo = repository + self._logger = logger or logging.getLogger(__name__) + self._events = event_emitter or NoopEventEmitter() + + async def list_alerts( + self, + *, + seller_id: str | None, + product_id: str | None, + urgency_level: str | None, + action_type: str | None, + delivery_status: str | None, + is_read: bool | None, + date_from: Any, + date_to: Any, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> AlertListResponse: + start = time.monotonic() + try: + rows, total = await self._repo.list_alerts( + seller_id=seller_id, + product_id=product_id, + urgency_level=urgency_level, + action_type=action_type, + delivery_status=delivery_status, + is_read=is_read, + date_from=date_from, + date_to=date_to, + sort_by=sort_by, + sort_direction=sort_direction, + pagination=pagination, + ) + except SQLAlchemyError as exc: + raise DashboardDatabaseError("Failed to load alerts.") from exc + items = [self._to_record(row) for row in rows] + meta = build_pagination_meta(total, pagination) + duration_ms = int((time.monotonic() - start) * 1000) + self._logger.info( + "dashboard_alerts_list total=%s duration_ms=%s seller_id=%s product_id=%s status=%s page=%s page_size=%s", + total, + duration_ms, + seller_id, + product_id, + delivery_status, + pagination.page, + pagination.page_size, + ) + return AlertListResponse(items=items, meta=meta) + + def _to_record(self, row: Any) -> AlertRecord: + reference = None + if row.get("telegram_chat_id") or row.get("telegram_message_id"): + reference = f"chat:{row.get('telegram_chat_id')} msg:{row.get('telegram_message_id')}" + return AlertRecord( + alert_id=str(row["alert_id"]), + product_id=str(row["product_id"]), + product_title=row["product_title"], + alert_type=str(row["alert_type"]), + urgency_level=str(row["urgency_level"]), + action_type=str(row["action_type"]), + confidence=str(row["confidence"]), + delivery_status=str(row["delivery_status"]), + delivery_timestamp=row.get("delivered_at"), + telegram_reference=reference, + is_read=row.get("read_at") is not None, + read_at=row.get("read_at"), + created_at=row.get("created_at"), + ) diff --git a/backend/app/services/dashboard/competitor_service.py b/backend/app/services/dashboard/competitor_service.py new file mode 100644 index 0000000..2c37210 --- /dev/null +++ b/backend/app/services/dashboard/competitor_service.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import logging +import time +from typing import Any + +from sqlalchemy.exc import SQLAlchemyError + +from app.analytics.metrics import normalize_stock_status +from app.repositories.competitor_repository import CompetitorRepository +from app.repositories.query_utils import PaginationParams, SortDirection, build_pagination_meta +from app.schemas.dashboard.competitor_schema import CompetitorListResponse, CompetitorRecord +from app.services.dashboard.exceptions import DashboardDatabaseError +from app.services.dashboard.events import DashboardEventEmitter, NoopEventEmitter + + +class CompetitorDashboardService: + def __init__( + self, + repository: CompetitorRepository, + *, + logger: logging.Logger | None = None, + event_emitter: DashboardEventEmitter | None = None, + ) -> None: + self._repo = repository + self._logger = logger or logging.getLogger(__name__) + self._events = event_emitter or NoopEventEmitter() + + async def list_competitors( + self, + *, + seller_id: str | None, + product_id: str | None, + stock_status: str | None, + search: str | None, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> CompetitorListResponse: + start = time.monotonic() + try: + rows, total = await self._repo.list_competitors( + seller_id=seller_id, + product_id=product_id, + stock_status=stock_status, + search=search, + sort_by=sort_by, + sort_direction=sort_direction, + pagination=pagination, + ) + except SQLAlchemyError as exc: + raise DashboardDatabaseError("Failed to load competitor data.") from exc + items = [self._to_record(row) for row in rows] + meta = build_pagination_meta(total, pagination) + duration_ms = int((time.monotonic() - start) * 1000) + self._logger.info( + "dashboard_competitors_list total=%s duration_ms=%s seller_id=%s product_id=%s search=%s page=%s page_size=%s", + total, + duration_ms, + seller_id, + product_id, + search, + pagination.page, + pagination.page_size, + ) + return CompetitorListResponse( + items=items, + meta=meta, + ) + + def _to_record(self, row: Any) -> CompetitorRecord: + status = normalize_stock_status(row.get("stock_status")) + out_of_stock = status == "out_of_stock" + return CompetitorRecord( + competitor_id=str(row["competitor_id"]), + product_id=str(row["product_id"]), + product_title=row["product_title"], + competitor_title=row["competitor_title"], + competitor_store=row.get("competitor_store"), + competitor_price=_to_float(row.get("competitor_price")), + seller_price=_to_float(row.get("seller_price")), + price_gap_percent=_to_float(row.get("price_gap_percent")), + stock_status=row.get("stock_status"), + out_of_stock=out_of_stock, + ranking_position=row.get("ranking_position"), + similarity_score=_to_float(row.get("similarity_score")), + marketplace=row.get("marketplace"), + product_url=row.get("product_url"), + price_trend=row.get("price_trend") or "stable", + created_at=row.get("created_at"), + ) + + +def _to_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/backend/app/services/dashboard/dashboard_service.py b/backend/app/services/dashboard/dashboard_service.py new file mode 100644 index 0000000..5be2e09 --- /dev/null +++ b/backend/app/services/dashboard/dashboard_service.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import logging +import os +import time +from typing import Any + +from sqlalchemy.exc import SQLAlchemyError + +from sqlalchemy import Float, and_, cast, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.alert import Alert +from app.models.competitor import Competitor +from app.models.product import Product +from app.models.recommendation import Recommendation +from app.schemas.dashboard.summary_schema import DashboardSummaryResponse, RealtimeActivityCounters +from app.services.dashboard.exceptions import DashboardDatabaseError +from app.services.dashboard.events import DashboardEventEmitter, NoopEventEmitter + + +@dataclass(frozen=True) +class DashboardSummaryConfig: + activity_window_minutes: int = 60 + alert_activity_window_hours: int = 24 + high_stock_risk_threshold: float = 0.7 + + @classmethod + def from_env(cls) -> "DashboardSummaryConfig": + return cls( + activity_window_minutes=_get_env_int("BAZARSYNC_ACTIVITY_WINDOW_MINUTES", 60), + alert_activity_window_hours=_get_env_int( + "BAZARSYNC_ALERT_ACTIVITY_HOURS", 24 + ), + high_stock_risk_threshold=_get_env_float( + "BAZARSYNC_HIGH_STOCK_RISK_THRESHOLD", 0.7 + ), + ) + + +class DashboardService: + def __init__( + self, + session: AsyncSession, + *, + config: DashboardSummaryConfig | None = None, + logger: logging.Logger | None = None, + event_emitter: DashboardEventEmitter | None = None, + ) -> None: + self._session = session + self._config = config or DashboardSummaryConfig.from_env() + self._logger = logger or logging.getLogger(__name__) + self._events = event_emitter or NoopEventEmitter() + + async def get_summary(self, *, seller_id: str | None = None) -> DashboardSummaryResponse: + start = time.monotonic() + now = datetime.now(timezone.utc) + activity_start = now - timedelta(minutes=self._config.activity_window_minutes) + alert_window_start = now - timedelta(hours=self._config.alert_activity_window_hours) + + urgency_score_expr = _json_float("urgency_score") + price_gap_expr = _json_float("price_gap_percent") + stock_risk_expr = _json_float("stock_risk_score") + try: + total_products = await self._session.scalar( + _count_query(Product.id, seller_id=seller_id) + ) + active_alerts = await self._session.scalar( + _count_query( + Alert.id, + seller_id=seller_id, + extra_filter=and_( + Alert.created_at >= alert_window_start, + Alert.delivery_status != "failed", + ), + ) + ) + opportunity_count = await self._session.scalar( + _count_query( + Recommendation.id, + seller_id=seller_id, + extra_filter=Recommendation.revenue_opportunity > 0, + ) + ) + high_risk_stock_count = await self._session.scalar( + _count_query( + Recommendation.id, + seller_id=seller_id, + extra_filter=stock_risk_expr >= self._config.high_stock_risk_threshold, + ) + ) + + average_urgency_score = await self._session.scalar( + _avg_query(urgency_score_expr, seller_id=seller_id) + ) + average_price_gap = await self._session.scalar( + _avg_query(price_gap_expr, seller_id=seller_id) + ) + + recommendations_last_hour = await self._session.scalar( + _count_query( + Recommendation.id, + seller_id=seller_id, + extra_filter=Recommendation.created_at >= activity_start, + ) + ) + alerts_last_hour = await self._session.scalar( + _count_query( + Alert.id, + seller_id=seller_id, + extra_filter=Alert.created_at >= activity_start, + ) + ) + competitor_updates_last_hour = await self._session.scalar( + _count_query( + Competitor.id, + seller_id=seller_id, + extra_filter=Competitor.updated_at >= activity_start, + join_product=True, + ) + ) + stock_risk_events_last_hour = await self._session.scalar( + _count_query( + Recommendation.id, + seller_id=seller_id, + extra_filter=and_( + Recommendation.created_at >= activity_start, + stock_risk_expr >= self._config.high_stock_risk_threshold, + ), + ) + ) + except SQLAlchemyError as exc: + raise DashboardDatabaseError("Failed to load dashboard summary.") from exc + + normalized_urgency = _normalize_score(average_urgency_score) + normalized_gap = float(average_price_gap or 0.0) + trend_summary = _build_market_trend_summary(normalized_urgency, normalized_gap) + + duration_ms = int((time.monotonic() - start) * 1000) + self._logger.info( + "dashboard_summary duration_ms=%s seller_id=%s total_products=%s", + duration_ms, + seller_id, + total_products, + ) + + return DashboardSummaryResponse( + total_monitored_products=total_products or 0, + active_alerts=active_alerts or 0, + opportunity_count=opportunity_count or 0, + high_risk_stock_count=high_risk_stock_count or 0, + average_urgency_score=round(normalized_urgency, 2), + average_competitor_price_gap=round(normalized_gap, 2), + market_trend_summary=trend_summary, + realtime_activity=RealtimeActivityCounters( + recommendations_last_hour=recommendations_last_hour or 0, + alerts_last_hour=alerts_last_hour or 0, + competitor_updates_last_hour=competitor_updates_last_hour or 0, + stock_risk_events_last_hour=stock_risk_events_last_hour or 0, + ), + generated_at=now, + ) + + +def _json_float(key: str): + analytics = Recommendation.recommendation_context["analytics"] + return cast(analytics[key].astext, Float) + + +def _count_query( + column, + *, + seller_id: str | None, + extra_filter=None, + join_product: bool = False, +): + stmt = select(func.count(column)).select_from(column.table) + if join_product: + stmt = stmt.select_from(Competitor).join(Product, Competitor.product_id == Product.id) + if seller_id and not join_product: + if column.table.name == "products": + stmt = stmt.where(Product.seller_id == seller_id) + elif column.table.name == "alerts": + stmt = stmt.where(Alert.seller_id == seller_id) + elif column.table.name == "recommendations": + stmt = stmt.join(Product, Recommendation.product_id == Product.id).where( + Product.seller_id == seller_id + ) + if seller_id and join_product: + stmt = stmt.where(Product.seller_id == seller_id) + if extra_filter is not None: + stmt = stmt.where(extra_filter) + return stmt + + +def _avg_query(expression, *, seller_id: str | None): + stmt = select(func.avg(expression)).select_from(Recommendation) + if seller_id: + stmt = stmt.join(Product, Recommendation.product_id == Product.id).where( + Product.seller_id == seller_id + ) + return stmt + + +def _build_market_trend_summary(avg_urgency: float, avg_gap: float) -> str: + if avg_urgency >= 75: + return "High volatility with aggressive competitor pressure." + if avg_urgency >= 55 and avg_gap >= 5: + return "Moderate volatility with rising competitor pressure." + if avg_urgency >= 55: + return "Moderate volatility with stable pricing." + if avg_gap >= 5: + return "Stable demand but widening price gaps." + return "Stable market conditions with balanced pricing." + + +def _normalize_score(value: float | None) -> float: + if value is None: + return 0.0 + numeric = float(value) + if 0 <= numeric <= 1: + return numeric * 100.0 + return numeric + + +def _get_env_int(key: str, default: int) -> int: + value = os.getenv(key) + if not value: + return default + try: + return int(value) + except ValueError: + return default + + +def _get_env_float(key: str, default: float) -> float: + value = os.getenv(key) + if not value: + return default + try: + return float(value) + except ValueError: + return default diff --git a/backend/app/services/dashboard/events.py b/backend/app/services/dashboard/events.py new file mode 100644 index 0000000..b6f2ed7 --- /dev/null +++ b/backend/app/services/dashboard/events.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Protocol + +EventEmitter = Callable[[str, dict[str, Any]], Awaitable[None]] + + +class DashboardEventEmitter(Protocol): + async def emit(self, event: str, payload: dict[str, Any]) -> None: + ... + + +@dataclass(frozen=True) +class NoopEventEmitter: + async def emit(self, event: str, payload: dict[str, Any]) -> None: + return None + + +class DashboardEvent(str): + recommendation_created = "recommendation_created" + alert_sent = "alert_sent" + competitor_updated = "competitor_updated" + stock_risk_detected = "stock_risk_detected" diff --git a/backend/app/services/dashboard/exceptions.py b/backend/app/services/dashboard/exceptions.py new file mode 100644 index 0000000..3465d1c --- /dev/null +++ b/backend/app/services/dashboard/exceptions.py @@ -0,0 +1,25 @@ +from __future__ import annotations + + +class DashboardServiceError(Exception): + status_code = 400 + error_code = "dashboard_error" + + def __init__(self, detail: str) -> None: + super().__init__(detail) + self.detail = detail + + +class InvalidSortFieldError(DashboardServiceError): + status_code = 400 + error_code = "invalid_sort_field" + + +class PaginationOverflowError(DashboardServiceError): + status_code = 400 + error_code = "pagination_overflow" + + +class DashboardDatabaseError(DashboardServiceError): + status_code = 500 + error_code = "dashboard_database_error" diff --git a/backend/app/services/dashboard/opportunity_service.py b/backend/app/services/dashboard/opportunity_service.py new file mode 100644 index 0000000..7a5e1e3 --- /dev/null +++ b/backend/app/services/dashboard/opportunity_service.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import logging +import time +from typing import Any, Mapping + +from sqlalchemy.exc import SQLAlchemyError + +from app.recommendations.opportunity_calculator import OpportunityCalculator, OpportunityInput +from app.repositories.query_utils import PaginationParams, SortDirection, build_pagination_meta +from app.repositories.recommendation_repository import RecommendationRepository +from app.schemas.dashboard.opportunity_schema import OpportunityListResponse, OpportunityRecord +from app.services.dashboard.events import DashboardEventEmitter, NoopEventEmitter +from app.services.dashboard.exceptions import DashboardDatabaseError + + +class OpportunityDashboardService: + def __init__( + self, + repository: RecommendationRepository, + *, + calculator: OpportunityCalculator | None = None, + logger: logging.Logger | None = None, + event_emitter: DashboardEventEmitter | None = None, + ) -> None: + self._repo = repository + self._calculator = calculator or OpportunityCalculator() + self._logger = logger or logging.getLogger(__name__) + self._events = event_emitter or NoopEventEmitter() + + async def list_opportunities( + self, + *, + seller_id: str | None, + min_revenue: float | None, + date_from: Any, + date_to: Any, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> OpportunityListResponse: + start = time.monotonic() + try: + rows, total = await self._repo.list_opportunities( + seller_id=seller_id, + min_revenue=min_revenue, + date_from=date_from, + date_to=date_to, + sort_by=sort_by, + sort_direction=sort_direction, + pagination=pagination, + ) + except SQLAlchemyError as exc: + raise DashboardDatabaseError("Failed to load opportunity analytics.") from exc + items = [self._to_record(row) for row in rows] + meta = build_pagination_meta(total, pagination) + duration_ms = int((time.monotonic() - start) * 1000) + self._logger.info( + "dashboard_opportunities_list total=%s duration_ms=%s seller_id=%s min_revenue=%s page=%s page_size=%s", + total, + duration_ms, + seller_id, + min_revenue, + pagination.page, + pagination.page_size, + ) + return OpportunityListResponse(items=items, meta=meta) + + def _to_record(self, row: Mapping[str, Any]) -> OpportunityRecord: + context, analytics = _split_context_snapshot(row.get("recommendation_context")) + seller_price = _to_float(context.get("seller_price")) or _to_float(row.get("seller_price")) or 0.0 + recommended_price = _to_float(context.get("recommended_price")) + stock_quantity = _to_int(context.get("seller_stock")) or _to_int(row.get("stock_quantity")) or 0 + margin_percent = _to_float(analytics.get("estimated_margin_percent")) + if margin_percent is None: + margin_percent = _compute_margin_percent( + _to_float(row.get("seller_price")) or 0.0, + _to_float(row.get("cost_price")) or 0.0, + ) + opportunity_score = _to_float(analytics.get("opportunity_score")) + if opportunity_score is None: + opportunity_score = _to_float(analytics.get("market_competitiveness_score")) or 0.0 + demand_indicator = _to_float(analytics.get("demand_indicator")) or 0.0 + competitor_oos_ratio = _compute_oos_ratio(analytics) + inventory_turnover_days = _to_float(context.get("inventory_turnover_days")) + + opportunity_input = OpportunityInput( + seller_price=seller_price, + recommended_price=recommended_price, + current_stock=stock_quantity, + margin_percent=margin_percent, + opportunity_score=opportunity_score, + demand_indicator=demand_indicator, + competitor_oos_ratio=competitor_oos_ratio, + inventory_turnover_days=inventory_turnover_days, + ) + result = self._calculator.calculate(opportunity_input) + return OpportunityRecord( + recommendation_id=str(row["recommendation_id"]), + product_id=str(row["product_id"]), + product_title=row["product_title"], + revenue_opportunity=_to_float(row.get("revenue_opportunity")) or 0.0, + inventory_capture_units=result.inventory_capture_units, + projected_demand_shift=result.projected_demand_shift, + estimated_profit_impact=result.estimated_profit_impact, + competitor_oos_ratio=competitor_oos_ratio, + market_opportunity_score=result.revenue_opportunity_score, + created_at=row.get("created_at"), + ) + + +def _split_context_snapshot(payload: Mapping[str, Any] | None) -> tuple[Mapping[str, Any], Mapping[str, Any]]: + if not isinstance(payload, Mapping): + return {}, {} + if "context" in payload or "analytics" in payload: + context = payload.get("context") + analytics = payload.get("analytics") + return ( + context if isinstance(context, Mapping) else {}, + analytics if isinstance(analytics, Mapping) else {}, + ) + return payload, {} + + +def _compute_oos_ratio(analytics: Mapping[str, Any]) -> float: + out_of_stock = _to_int(analytics.get("out_of_stock_competitor_count")) or 0 + in_stock = _to_int(analytics.get("in_stock_competitor_count")) or 0 + total = out_of_stock + in_stock + if total == 0: + return 0.0 + return out_of_stock / total + + +def _compute_margin_percent(selling_price: float, cost_price: float) -> float: + if selling_price <= 0: + return 0.0 + return ((selling_price - cost_price) / selling_price) * 100.0 + + +def _to_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _to_int(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/backend/app/services/dashboard/recommendation_service.py b/backend/app/services/dashboard/recommendation_service.py new file mode 100644 index 0000000..1597b75 --- /dev/null +++ b/backend/app/services/dashboard/recommendation_service.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import logging +import time +from typing import Any + +from sqlalchemy.exc import SQLAlchemyError + +from app.repositories.query_utils import PaginationParams, SortDirection, build_pagination_meta +from app.repositories.recommendation_repository import RecommendationRepository +from app.schemas.dashboard.recommendation_schema import ( + RecommendationListResponse, + RecommendationRecord, +) +from app.services.dashboard.events import DashboardEventEmitter, NoopEventEmitter +from app.services.dashboard.exceptions import DashboardDatabaseError + + +class RecommendationDashboardService: + def __init__( + self, + repository: RecommendationRepository, + *, + logger: logging.Logger | None = None, + event_emitter: DashboardEventEmitter | None = None, + ) -> None: + self._repo = repository + self._logger = logger or logging.getLogger(__name__) + self._events = event_emitter or NoopEventEmitter() + + async def list_recommendations( + self, + *, + seller_id: str | None, + product_id: str | None, + action_type: str | None, + confidence: str | None, + search: str | None, + date_from: Any, + date_to: Any, + sort_by: str, + sort_direction: SortDirection, + pagination: PaginationParams, + ) -> RecommendationListResponse: + start = time.monotonic() + try: + rows, total = await self._repo.list_recommendations( + seller_id=seller_id, + product_id=product_id, + action_type=action_type, + confidence=confidence, + search=search, + date_from=date_from, + date_to=date_to, + sort_by=sort_by, + sort_direction=sort_direction, + pagination=pagination, + ) + except SQLAlchemyError as exc: + raise DashboardDatabaseError("Failed to load recommendations.") from exc + items = [self._to_record(row) for row in rows] + meta = build_pagination_meta(total, pagination) + duration_ms = int((time.monotonic() - start) * 1000) + self._logger.info( + "dashboard_recommendations_list total=%s duration_ms=%s seller_id=%s product_id=%s action=%s confidence=%s page=%s page_size=%s", + total, + duration_ms, + seller_id, + product_id, + action_type, + confidence, + pagination.page, + pagination.page_size, + ) + return RecommendationListResponse(items=items, meta=meta) + + def _to_record(self, row: Any) -> RecommendationRecord: + return RecommendationRecord( + recommendation_id=str(row["recommendation_id"]), + product_id=str(row["product_id"]), + product_title=row["product_title"], + action_type=str(row["action_type"]), + confidence=str(row["confidence"]), + urgency_score=_normalize_score(row.get("urgency_score")), + price_gap_percent=_to_float(row.get("price_gap_percent")), + stock_risk_score=_to_float(row.get("stock_risk_score")), + reasoning=row.get("reasoning") or "", + revenue_opportunity=_to_float(row.get("revenue_opportunity")) or 0.0, + created_at=row.get("created_at"), + ) + + +def _to_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _normalize_score(value: Any) -> float | None: + numeric = _to_float(value) + if numeric is None: + return None + if 0 <= numeric <= 1: + return numeric * 100.0 + return numeric diff --git a/backend/app/services/realtime/__init__.py b/backend/app/services/realtime/__init__.py new file mode 100644 index 0000000..02b4a6e --- /dev/null +++ b/backend/app/services/realtime/__init__.py @@ -0,0 +1,3 @@ +from app.services.realtime.realtime_service import RealtimeService + +__all__ = ["RealtimeService"] diff --git a/backend/app/services/realtime/realtime_service.py b/backend/app/services/realtime/realtime_service.py new file mode 100644 index 0000000..72f2fe2 --- /dev/null +++ b/backend/app/services/realtime/realtime_service.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Iterable +from uuid import UUID, uuid4 + +from app.websocket.socket_emitter import SocketEmitter +from app.websocket.socket_events import ( + AlertFailedEvent, + AlertFailedPayload, + AlertSentEvent, + AlertSentPayload, + AlertSnapshot, + CompetitorUpdatedEvent, + CompetitorUpdatedPayload, + CompetitorSnapshot, + DashboardSummaryPayload, + DashboardSummarySnapshot, + DashboardSummaryUpdatedEvent, + MarketOpportunityDetectedEvent, + MarketOpportunityPayload, + MarketOpportunitySnapshot, + RecommendationCreatedEvent, + RecommendationCreatedPayload, + RecommendationSnapshot, + StockRiskDetectedEvent, + StockRiskDetectedPayload, + UrgencyScoreUpdatedEvent, + UrgencyScoreUpdatedPayload, +) +from app.websocket.socket_rooms import ( + ACTIVITY_ROOM, + ALERTS_ROOM, + DASHBOARD_ROOM, + product_room, + seller_room, +) + + +class RealtimeService: + """High-level realtime emitters for the War Room dashboard.""" + + def __init__(self, emitter: SocketEmitter, logger: logging.Logger | None = None) -> None: + self._emitter = emitter + self._logger = logger or logging.getLogger(__name__) + + async def emit_recommendation_created( + self, + payload: RecommendationCreatedPayload, + *, + seller_id: UUID | None = None, + product_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = RecommendationCreatedEvent( + payload=payload, + seller_id=seller_id, + product_id=product_id, + ) + await self._emit(event, rooms=rooms) + + async def emit_alert_sent( + self, + payload: AlertSentPayload, + *, + seller_id: UUID | None = None, + product_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = AlertSentEvent(payload=payload, seller_id=seller_id, product_id=product_id) + await self._emit(event, rooms=rooms or self._default_alert_rooms(seller_id, product_id)) + + async def emit_alert_failed( + self, + payload: AlertFailedPayload, + *, + seller_id: UUID | None = None, + product_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = AlertFailedEvent(payload=payload, seller_id=seller_id, product_id=product_id) + await self._emit(event, rooms=rooms or self._default_alert_rooms(seller_id, product_id)) + + async def emit_competitor_updated( + self, + payload: CompetitorUpdatedPayload, + *, + seller_id: UUID | None = None, + product_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = CompetitorUpdatedEvent( + payload=payload, + seller_id=seller_id, + product_id=product_id, + ) + await self._emit(event, rooms=rooms) + + async def emit_stock_risk_detected( + self, + payload: StockRiskDetectedPayload, + *, + seller_id: UUID | None = None, + product_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = StockRiskDetectedEvent( + payload=payload, + seller_id=seller_id, + product_id=product_id, + ) + await self._emit(event, rooms=rooms or self._default_alert_rooms(seller_id, product_id)) + + async def emit_urgency_score_updated( + self, + payload: UrgencyScoreUpdatedPayload, + *, + seller_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = UrgencyScoreUpdatedEvent(payload=payload, seller_id=seller_id) + await self._emit(event, rooms=rooms or self._default_dashboard_rooms(seller_id, None)) + + async def emit_dashboard_summary_updated( + self, + payload: DashboardSummaryPayload, + *, + seller_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = DashboardSummaryUpdatedEvent(payload=payload, seller_id=seller_id) + await self._emit(event, rooms=rooms or self._default_dashboard_rooms(seller_id, None)) + + async def emit_market_opportunity_detected( + self, + payload: MarketOpportunityPayload, + *, + seller_id: UUID | None = None, + product_id: UUID | None = None, + rooms: Iterable[str] | None = None, + ) -> None: + event = MarketOpportunityDetectedEvent( + payload=payload, + seller_id=seller_id, + product_id=product_id, + ) + await self._emit(event, rooms=rooms) + + @staticmethod + def example_payloads() -> dict[str, object]: + now = datetime.now(timezone.utc) + return { + "recommendation_created": RecommendationCreatedPayload( + recommendation=RecommendationSnapshot( + id=uuid4(), + title="Win back buy box", + summary="Lower price by 4% to regain buy box.", + action="undercut", + status="new", + priority="high", + confidence=0.82, + expected_lift=0.18, + product="SKU-9911", + competitor="Daraz", + created_at=now, + ), + urgency_score=82, + confidence="HIGH", + opportunity_score=4200, + ), + "alert_sent": AlertSentPayload( + alert=AlertSnapshot( + id=uuid4(), + title="Stock risk detected", + message="Competitor stock moved below 10 units.", + severity="critical", + status="new", + category="stock_risk", + channel="dashboard", + urgency_score=91, + created_at=now, + product="SKU-9911", + competitor="Daraz", + ) + ), + "competitor_updated": CompetitorUpdatedPayload( + competitor=CompetitorSnapshot( + id=uuid4(), + product="SKU-9911", + competitor="Daraz", + competitor_price=14.2, + seller_price=15.9, + price_gap=-1.7, + stock_status="low_stock", + urgency_score=78, + recommendation_action="undercut", + trend="down", + last_updated=now, + status="active", + ) + ), + "stock_risk_detected": StockRiskDetectedPayload( + alert=AlertSnapshot( + id=uuid4(), + title="Stock risk detected", + message="Competitor inventory dropped below safety threshold.", + severity="critical", + status="new", + category="stock_risk", + channel="dashboard", + urgency_score=88, + created_at=now, + product="SKU-9911", + competitor="Daraz", + ), + risk_score=88, + ), + "urgency_score_updated": UrgencyScoreUpdatedPayload( + urgency_score=74, + previous_score=68, + delta=6, + updated_at=now, + reason="Competitor price dropped 3%.", + ), + "dashboard_summary_updated": DashboardSummaryPayload( + summary=DashboardSummarySnapshot( + kpis=[], + insights=[], + active_alerts=5, + live_competitors=24, + ai_recommendations=12, + revenue_opportunities=9, + urgency_score=74, + system_status="healthy", + last_synced_at=now, + ) + ), + "market_opportunity_detected": MarketOpportunityPayload( + opportunity=MarketOpportunitySnapshot( + id=uuid4(), + title="Weekend demand spike", + description="Category demand is trending upward.", + potential_revenue=12000, + probability=0.64, + urgency_score=71, + time_window="48h", + category="promotion", + ) + ), + } + + async def _emit( + self, + event: RecommendationCreatedEvent + | AlertSentEvent + | AlertFailedEvent + | CompetitorUpdatedEvent + | StockRiskDetectedEvent + | UrgencyScoreUpdatedEvent + | DashboardSummaryUpdatedEvent + | MarketOpportunityDetectedEvent, + *, + rooms: Iterable[str] | None = None, + ) -> None: + await self._emitter.emit_event(event, rooms=rooms or self._default_rooms(event)) + + def _default_rooms( + self, + event: RecommendationCreatedEvent + | AlertSentEvent + | AlertFailedEvent + | CompetitorUpdatedEvent + | StockRiskDetectedEvent + | UrgencyScoreUpdatedEvent + | DashboardSummaryUpdatedEvent + | MarketOpportunityDetectedEvent, + ) -> set[str]: + if isinstance(event, (AlertSentEvent, AlertFailedEvent, StockRiskDetectedEvent)): + return self._default_alert_rooms(event.seller_id, event.product_id) + if isinstance(event, (UrgencyScoreUpdatedEvent, DashboardSummaryUpdatedEvent)): + return self._default_dashboard_rooms(event.seller_id, event.product_id) + return self._default_dashboard_rooms(event.seller_id, event.product_id) + + def _default_dashboard_rooms( + self, seller_id: UUID | None, product_id: UUID | None + ) -> set[str]: + rooms = {DASHBOARD_ROOM, ACTIVITY_ROOM} + if seller_id: + rooms.add(seller_room(str(seller_id))) + if product_id: + rooms.add(product_room(str(product_id))) + return rooms + + def _default_alert_rooms( + self, seller_id: UUID | None, product_id: UUID | None + ) -> set[str]: + rooms = {ALERTS_ROOM, ACTIVITY_ROOM, DASHBOARD_ROOM} + if seller_id: + rooms.add(seller_room(str(seller_id))) + if product_id: + rooms.add(product_room(str(product_id))) + return rooms diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index a55149f..d9c6457 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from decimal import Decimal import logging +import os import time from typing import Any, Mapping from uuid import UUID @@ -29,6 +30,8 @@ RecommendationGenerateResponse, RecommendationResponse, ) +from app.services.dashboard.events import DashboardEvent, DashboardEventEmitter +from app.websocket.socket_manager import socket_manager class RecommendationServiceError(Exception): @@ -79,13 +82,26 @@ class RecommendationServiceResult: class RecommendationService: """Orchestrates analytics, AI generation, and persistence.""" - def __init__(self, db: Session, logger: logging.Logger | None = None) -> None: + def __init__( + self, + db: Session, + logger: logging.Logger | None = None, + *, + event_emitter: DashboardEventEmitter | None = None, + stock_risk_threshold: float | None = None, + ) -> None: self._db = db self._logger = logger or logging.getLogger(__name__) self._pricing_engine = PricingEngine() self._stock_engine = StockEngine() self._context_builder = RecommendationContextBuilder() self._ai_generator = RecommendationGenerator() + self._event_emitter = event_emitter or socket_manager + self._stock_risk_threshold = ( + stock_risk_threshold + if stock_risk_threshold is not None + else _get_env_float("BAZARSYNC_STOCK_RISK_EVENT_THRESHOLD", 0.7) + ) async def generate_recommendation( self, *, seller_id: UUID, product_id: UUID @@ -105,6 +121,11 @@ async def generate_recommendation( "recommendation_analytics_complete", product_id=str(product_id), ) + await self._emit_stock_risk_event( + seller_id=str(seller_id), + product=product, + analytics=analytics, + ) context = self.build_context(product, competitors, analytics) self._log_event( logging.INFO, @@ -132,6 +153,11 @@ async def generate_recommendation( recommendation_id=str(recommendation.id), product_id=str(product_id), ) + await self._emit_recommendation_created( + seller_id=str(seller_id), + recommendation=recommendation, + analytics=analytics, + ) response = self.build_response(recommendation, analytics) self._log_event( @@ -360,6 +386,49 @@ def _log_event(self, level: int, event: str, **fields: Any) -> None: payload = " ".join(f"{key}={value}" for key, value in safe_fields.items()) self._logger.log(level, f"{event} {payload}".strip()) + async def _emit_recommendation_created( + self, + *, + seller_id: str, + recommendation: Recommendation, + analytics: Mapping[str, Any], + ) -> None: + payload = { + "recommendation_id": str(recommendation.id), + "product_id": str(recommendation.product_id), + "seller_id": seller_id, + "action": recommendation.action.value, + "confidence": recommendation.confidence.value, + "revenue_opportunity": float(recommendation.revenue_opportunity), + "urgency_score": analytics.get("urgency_score"), + "stock_risk_score": analytics.get("stock_risk_score"), + "created_at": recommendation.created_at.isoformat() + if recommendation.created_at + else None, + } + await self._event_emitter.emit(DashboardEvent.recommendation_created, payload) + + async def _emit_stock_risk_event( + self, + *, + seller_id: str, + product: Product, + analytics: Mapping[str, Any], + ) -> None: + stock_risk_score = self._to_float(analytics.get("stock_risk_score")) + if stock_risk_score is None: + return + if stock_risk_score < self._stock_risk_threshold: + return + payload = { + "product_id": str(product.id), + "seller_id": seller_id, + "stock_risk_score": stock_risk_score, + "estimated_days_of_stock_left": analytics.get("estimated_days_of_stock_left"), + "stock_quantity": product.stock_quantity, + } + await self._event_emitter.emit(DashboardEvent.stock_risk_detected, payload) + def _build_analytics_response( self, analytics: Mapping[str, Any] | None, @@ -428,3 +497,13 @@ def _to_int(value: Any) -> int | None: return int(value) except (TypeError, ValueError): return None + + +def _get_env_float(key: str, default: float) -> float: + value = os.getenv(key) + if not value: + return default + try: + return float(value) + except ValueError: + return default diff --git a/backend/app/services/scrape_service.py b/backend/app/services/scrape_service.py index 1abc95b..1a602fd 100644 --- a/backend/app/services/scrape_service.py +++ b/backend/app/services/scrape_service.py @@ -19,6 +19,8 @@ from app.schemas.scrape import ScrapeRunResponse from app.schemas.competitor import CompetitorSnapshot from app.services.competitor_service import CompetitorService +from app.services.dashboard.events import DashboardEvent, DashboardEventEmitter +from app.websocket.socket_manager import socket_manager class ScrapeProductNotFoundError(Exception): @@ -38,9 +40,15 @@ class ScrapeRunNotFoundError(Exception): class ScrapeService: - def __init__(self, db: Session) -> None: + def __init__( + self, + db: Session, + *, + event_emitter: DashboardEventEmitter | None = None, + ) -> None: self._db = db self._competitor_service = CompetitorService(db) + self._event_emitter = event_emitter or socket_manager async def run_manual_scrape( self, @@ -102,6 +110,14 @@ async def run_manual_scrape( self._db.commit() self._db.refresh(scrape_run) + await self._emit_competitor_updated( + scrape_run=scrape_run, + product=product, + matched_results=matched_results, + marketplace=scrape_response.marketplace, + scraped_at=scrape_response.scraped_at, + ) + processing_time = time.monotonic() - start return ScrapeRunResponse( success=True, @@ -198,9 +214,29 @@ def mark_run_failed(self, scrape_run_id: UUID, message: str) -> None: def _to_snapshot(item: dict) -> CompetitorSnapshot: return CompetitorSnapshot( competitor_title=item.get("competitor_title") or item.get("product_title") or "", - competitor_price=item.get("competitor_price") or item.get("product_price"), + competitor_price=item.get("competitor_price") + or item.get("product_price"), competitor_store=item.get("competitor_store") or item.get("store_name"), similarity_score=item.get("similarity_score"), stock_status=item.get("stock_status"), product_url=item.get("product_url") or item.get("daraz_product_url"), ) + + async def _emit_competitor_updated( + self, + *, + scrape_run: ScrapeRun, + product: Product, + matched_results: int, + marketplace: str, + scraped_at: datetime | None, + ) -> None: + payload = { + "scrape_run_id": str(scrape_run.id), + "product_id": str(product.id), + "seller_id": str(product.seller_id), + "marketplace": marketplace, + "matched_results": matched_results, + "scraped_at": scraped_at.isoformat() if scraped_at else None, + } + await self._event_emitter.emit(DashboardEvent.competitor_updated, payload) diff --git a/backend/app/services/telegram/product_snapshot_service.py b/backend/app/services/telegram/product_snapshot_service.py new file mode 100644 index 0000000..db3f1c2 --- /dev/null +++ b/backend/app/services/telegram/product_snapshot_service.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.telegram_connection import SellerTelegramConnection +from app.models.telegram_snapshot_log import TelegramProductSnapshotLog +from app.schemas.product_snapshot import ProductSnapshot +from app.services.recommendation_service import ( + CompetitorDataNotFoundError as AnalyticsCompetitorDataNotFoundError, + ProductNotFoundError as AnalyticsProductNotFoundError, + RecommendationNotFoundError, + RecommendationService, +) +from app.services.telegram.snapshot_formatter import format_product_snapshot +from app.services.telegram.telegram_client import TelegramClientError +from app.services.telegram.telegram_service import DeliveryStatus, TelegramDeliveryResult, TelegramService +from app.websocket.socket_events import ProductSnapshotSentEvent, ProductSnapshotSentPayload +from app.websocket.socket_manager import get_socket_emitter +from app.websocket.socket_rooms import seller_room + + +class ProductSnapshotServiceError(Exception): + status_code = 500 + error_code = "snapshot_error" + + def __init__(self, detail: str) -> None: + super().__init__(detail) + self.detail = detail + + +class TelegramNotConnectedError(ProductSnapshotServiceError): + status_code = 400 + error_code = "telegram_not_connected" + + +class ProductNotFoundError(ProductSnapshotServiceError): + status_code = 404 + error_code = "product_not_found" + + +class CompetitorDataNotFoundError(ProductSnapshotServiceError): + status_code = 404 + error_code = "competitor_data_missing" + + +class SnapshotRecommendationNotFoundError(ProductSnapshotServiceError): + status_code = 404 + error_code = "recommendation_not_found" + + +class TelegramDeliveryFailedError(ProductSnapshotServiceError): + status_code = 502 + error_code = "telegram_delivery_failed" + + def __init__(self, detail: str, *, error_code: str | None = None) -> None: + super().__init__(detail) + if error_code: + self.error_code = error_code + + +@dataclass(frozen=True) +class ProductSnapshotDeliveryResult: + sent_at: datetime + delivery_status: str + + +class ProductSnapshotService: + def __init__( + self, + db: Session, + *, + telegram_service: TelegramService | None = None, + logger: logging.Logger | None = None, + ) -> None: + self._db = db + self._logger = logger or logging.getLogger(__name__) + self._telegram = telegram_service or TelegramService(logger=self._logger) + self._recommendations = RecommendationService(db, logger=self._logger) + + async def send_snapshot( + self, + *, + seller_id: UUID, + product_id: UUID, + ) -> ProductSnapshotDeliveryResult: + connection = self._get_connection(seller_id) + if not connection or not connection.is_connected or not connection.telegram_chat_id: + raise TelegramNotConnectedError( + "Connect your Telegram account first to send product snapshots." + ) + + try: + product = self._recommendations.load_product(seller_id, product_id) + except AnalyticsProductNotFoundError as exc: + raise ProductNotFoundError("Product not found.") from exc + + try: + competitors = self._recommendations.load_competitors(seller_id, product_id) + analytics = self._recommendations.run_analytics(product, competitors) + except AnalyticsCompetitorDataNotFoundError as exc: + raise CompetitorDataNotFoundError( + "No competitor data found for this product yet." + ) from exc + + try: + latest = self._recommendations.get_latest_recommendation( + seller_id=seller_id, product_id=product_id + ) + except RecommendationNotFoundError as exc: + raise SnapshotRecommendationNotFoundError( + "No AI recommendation found for this product yet." + ) from exc + + snapshot = ProductSnapshot( + seller_id=seller_id, + product_id=product_id, + product_name=product.title, + current_price=float(product.selling_price), + current_stock=product.stock_quantity, + supplier_lead_time=product.supplier_lead_time, + competitor_median_price=_to_float(analytics.get("competitor_median_price")), + price_gap_percent=_to_float(analytics.get("price_gap_percent")), + recommendation_action=latest.recommendation.action, + ai_reason=latest.recommendation.reasoning_bn, + confidence=latest.recommendation.confidence, + updated_at=latest.created_at, + ) + message = format_product_snapshot(snapshot) + + try: + result = await self._telegram.send_text_message( + message, chat_id=connection.telegram_chat_id + ) + except TelegramClientError as exc: + await self._record_log( + seller_id=seller_id, + product_id=product_id, + chat_id=connection.telegram_chat_id, + status=DeliveryStatus.failed.value, + sent_at=self._now(), + error_message=str(exc), + ) + detail, error_code = _map_delivery_error(str(exc)) + raise TelegramDeliveryFailedError(detail, error_code=error_code) from exc + else: + await self._record_delivery(connection, product_id, result) + + if result.status is not DeliveryStatus.delivered: + detail, error_code = _map_delivery_error(result.detail or "") + raise TelegramDeliveryFailedError(detail, error_code=error_code) + + await self._emit_snapshot_sent( + seller_id=seller_id, + product_id=product_id, + product_title=product.title, + sent_at=result.timestamp, + ) + + return ProductSnapshotDeliveryResult( + sent_at=result.timestamp, + delivery_status=result.status.value, + ) + + def _get_connection(self, seller_id: UUID) -> SellerTelegramConnection | None: + statement = select(SellerTelegramConnection).where( + SellerTelegramConnection.seller_id == seller_id + ) + return self._db.execute(statement).scalar_one_or_none() + + async def _record_delivery( + self, + connection: SellerTelegramConnection, + product_id: UUID, + result: TelegramDeliveryResult, + ) -> None: + if result.status is DeliveryStatus.delivered: + connection.last_alert_sent_at = result.timestamp + + await self._record_log( + seller_id=connection.seller_id, + product_id=product_id, + chat_id=str(result.chat_id or connection.telegram_chat_id), + status=result.status.value, + sent_at=result.timestamp, + error_message=result.detail if result.status is not DeliveryStatus.delivered else None, + ) + + async def _record_log( + self, + *, + seller_id: UUID, + product_id: UUID, + chat_id: str, + status: str, + sent_at: datetime, + error_message: str | None, + ) -> None: + log = TelegramProductSnapshotLog( + seller_id=seller_id, + product_id=product_id, + telegram_chat_id=chat_id, + status=status, + sent_at=sent_at, + error_message=error_message, + ) + self._db.add(log) + self._db.commit() + + async def _emit_snapshot_sent( + self, + *, + seller_id: UUID, + product_id: UUID, + product_title: str, + sent_at: datetime, + ) -> None: + try: + emitter = get_socket_emitter() + except RuntimeError: + self._logger.debug("socket_emitter_unavailable") + return + event = ProductSnapshotSentEvent( + payload=ProductSnapshotSentPayload( + seller_id=seller_id, + product_id=product_id, + timestamp=sent_at, + product_title=product_title, + ), + seller_id=seller_id, + product_id=product_id, + ) + await emitter.emit_event(event, rooms=[seller_room(str(seller_id))]) + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _to_float(value) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _map_delivery_error(detail: str) -> tuple[str, str]: + lowered = detail.lower() + if any( + phrase in lowered + for phrase in ( + "chat not found", + "chat id is invalid", + "chat id format is invalid", + "does not have access to the chat", + "user not found", + "invalid user id", + ) + ): + return ( + "Your Telegram connection expired. Please reconnect @bazarsync_bot.", + "telegram_connection_expired", + ) + if "bot token" in lowered or "unauthorized" in lowered: + return ( + "Telegram bot authorization failed. Please contact support.", + "telegram_unauthorized", + ) + if "rate limit" in lowered: + return ( + "Telegram rate limit exceeded. Please try again shortly.", + "telegram_rate_limited", + ) + if "timeout" in lowered: + return ( + "Telegram delivery timed out. Please try again.", + "telegram_timeout", + ) + if "network" in lowered: + return ( + "Telegram network error. Please try again.", + "telegram_network_error", + ) + if detail: + return (detail, "telegram_delivery_failed") + return ("Telegram delivery failed. Please try again.", "telegram_delivery_failed") diff --git a/backend/app/services/telegram/snapshot_formatter.py b/backend/app/services/telegram/snapshot_formatter.py new file mode 100644 index 0000000..2d12fce --- /dev/null +++ b/backend/app/services/telegram/snapshot_formatter.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import timezone + +from app.schemas.product_snapshot import ProductSnapshot + + +def format_product_snapshot(snapshot: ProductSnapshot) -> str: + return "\n".join( + [ + "📊 BazarSync AI Product Intelligence", + "", + f"🛒 Product:\n{snapshot.product_name}", + "", + f"💰 Current Price:\n{_format_currency(snapshot.current_price, snapshot.currency)}", + "", + f"📦 Current Stock:\n{snapshot.current_stock} units", + "", + f"🚚 Supplier Lead Time:\n{_format_lead_time(snapshot.supplier_lead_time)}", + "", + f"📉 Competitor Median Price:\n" + f"{_format_currency(snapshot.competitor_median_price, snapshot.currency)}", + "", + f"⚠️ Price Gap:\n{_format_percent(snapshot.price_gap_percent)}", + "", + f"🤖 Latest AI Recommendation:\n{_format_action(snapshot.recommendation_action)}", + "", + f"🧠 AI Reason:\n{_format_reason(snapshot.ai_reason)}", + "", + f"📈 Confidence:\n{snapshot.confidence}", + "", + f"⏰ Updated:\n{_format_timestamp(snapshot.updated_at)}", + ] + ) + + +def _format_currency(value: float | None, currency: str) -> str: + if value is None: + return "N/A" + return f"{currency}{value:,.0f}" + + +def _format_lead_time(value: int | None) -> str: + if value is None: + return "N/A" + return f"{value} days" + + +def _format_percent(value: float | None) -> str: + if value is None: + return "N/A" + return f"{value:.1f}%" + + +def _format_action(action: str) -> str: + normalized = action.strip().upper() + mapping = { + "LOWER": "PRICE LOWER", + "RAISE": "PRICE RAISE", + "HOLD": "PRICE HOLD", + "REORDER": "REORDER STOCK", + } + return mapping.get(normalized, normalized.replace("_", " ")) + + +def _format_reason(reason: str | None) -> str: + if not reason: + return "N/A" + return reason.strip() + + +def _format_timestamp(value) -> str: + timestamp = value.astimezone(timezone.utc) + return timestamp.strftime("%Y-%m-%d %I:%M %p") diff --git a/backend/app/services/telegram/telegram_alert_service.py b/backend/app/services/telegram/telegram_alert_service.py new file mode 100644 index 0000000..06fa1ef --- /dev/null +++ b/backend/app/services/telegram/telegram_alert_service.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.alert import Alert, AlertDeliveryStatus, AlertUrgencyLevel +from app.models.alert_delivery_log import AlertDeliveryLog +from app.services.telegram.telegram_connection_service import ( + TelegramConnectionService, + TelegramConnectionSettings, +) +from app.services.telegram.telegram_formatter import AlertMessageContext +from app.services.telegram.telegram_service import DeliveryStatus, TelegramDeliveryResult, TelegramService +from app.websocket.socket_events import ( + AlertFailedEvent, + AlertFailedPayload, + AlertSentEvent, + AlertSentPayload, + AlertSnapshot, +) +from app.websocket.socket_manager import get_socket_emitter +from app.websocket.socket_rooms import seller_room + + +class AlertDeliveryError(Exception): + """Base error for alert delivery failures.""" + + +class AlertNotFoundError(AlertDeliveryError): + """Raised when an alert cannot be located.""" + + +@dataclass(frozen=True) +class AlertDeliveryOutcome: + alert_id: str + status: AlertDeliveryStatus + detail: str | None = None + telegram_message_id: str | None = None + telegram_chat_id: str | None = None + delivered_at: datetime | None = None + + +class TelegramAlertDeliveryService: + """Routes alerts to seller-specific Telegram chats with cooldown + logging.""" + + def __init__( + self, + session: AsyncSession, + *, + settings: TelegramConnectionSettings | None = None, + telegram_service: TelegramService | None = None, + logger: logging.Logger | None = None, + ) -> None: + self._session = session + self._settings = settings or TelegramConnectionSettings() + self._telegram = telegram_service or TelegramService(logger=logger) + self._logger = logger or logging.getLogger(__name__) + self._connections = TelegramConnectionService( + session, + settings=self._settings, + logger=self._logger, + ) + + async def deliver_alert_by_id(self, alert_id: str | UUID) -> AlertDeliveryOutcome: + alert = await self._get_alert(alert_id) + if not alert: + raise AlertNotFoundError("Alert not found.") + return await self.deliver_alert(alert) + + async def deliver_alert(self, alert: Alert) -> AlertDeliveryOutcome: + connection = await self._connections.get_status(str(alert.seller_id)) + if not connection or not connection.is_connected or not connection.telegram_chat_id: + return await self._record_failure( + alert, + detail="seller_not_connected", + chat_id=None, + ) + + if await self._is_in_cooldown(alert): + return await self._record_duplicate(alert, detail="cooldown_active") + + context = self._build_message_context(alert) + result = await self._telegram.send_alert_message( + context, + chat_id=connection.telegram_chat_id, + idempotency_key=f"alert:{alert.id}", + ) + return await self._apply_delivery_result(alert, connection.telegram_chat_id, result) + + async def _get_alert(self, alert_id: str | UUID) -> Alert | None: + statement = ( + select(Alert) + .where(Alert.id == alert_id) + .options(selectinload(Alert.product)) + ) + return (await self._session.execute(statement)).scalar_one_or_none() + + async def _is_in_cooldown(self, alert: Alert) -> bool: + cooldown = self._settings.alert_cooldown_seconds + if cooldown <= 0: + return False + cutoff = self._now() - timedelta(seconds=cooldown) + statement = ( + select(AlertDeliveryLog.sent_at) + .join(Alert, AlertDeliveryLog.alert_id == Alert.id) + .where( + AlertDeliveryLog.seller_id == alert.seller_id, + AlertDeliveryLog.delivery_status == AlertDeliveryStatus.delivered, + AlertDeliveryLog.sent_at.is_not(None), + AlertDeliveryLog.sent_at >= cutoff, + Alert.action_type == alert.action_type, + Alert.product_id == alert.product_id, + Alert.alert_type == alert.alert_type, + ) + .order_by(AlertDeliveryLog.sent_at.desc()) + .limit(1) + ) + last_sent = await self._session.scalar(statement) + return last_sent is not None + + async def _apply_delivery_result( + self, + alert: Alert, + chat_id: str, + result: TelegramDeliveryResult, + ) -> AlertDeliveryOutcome: + status = self._map_delivery_status(result.status) + now = result.timestamp + if status is AlertDeliveryStatus.delivered: + alert.delivery_status = AlertDeliveryStatus.delivered + alert.telegram_chat_id = chat_id + alert.telegram_message_id = str(result.message_id) if result.message_id else None + alert.delivery_error = None + alert.delivered_at = now + elif status is AlertDeliveryStatus.duplicate: + alert.delivery_status = AlertDeliveryStatus.duplicate + alert.telegram_chat_id = chat_id + alert.delivery_error = result.detail or "duplicate_alert_skipped" + alert.telegram_message_id = None + alert.delivered_at = None + else: + alert.delivery_status = AlertDeliveryStatus.failed + alert.telegram_chat_id = chat_id + alert.delivery_error = result.detail + alert.telegram_message_id = None + alert.delivered_at = None + + if status is AlertDeliveryStatus.delivered: + connection = await self._connections.get_status(str(alert.seller_id)) + if connection: + connection.last_alert_sent_at = now + + log = AlertDeliveryLog( + seller_id=alert.seller_id, + alert_id=alert.id, + telegram_chat_id=chat_id, + delivery_status=status, + error_message=result.detail if status is not AlertDeliveryStatus.delivered else None, + sent_at=now if status is AlertDeliveryStatus.delivered else None, + ) + self._session.add(log) + await self._session.commit() + + if status is AlertDeliveryStatus.delivered: + await self._emit_alert_sent(alert) + else: + await self._emit_alert_failed(alert, status, result.detail, chat_id) + + self._logger.info( + "telegram_alert_delivery alert_id=%s status=%s chat_id=%s", + alert.id, + status.value, + chat_id, + ) + return AlertDeliveryOutcome( + alert_id=str(alert.id), + status=status, + detail=result.detail, + telegram_message_id=str(result.message_id) if result.message_id else None, + telegram_chat_id=chat_id, + delivered_at=now if status is AlertDeliveryStatus.delivered else None, + ) + + async def _record_failure( + self, + alert: Alert, + *, + detail: str, + chat_id: str | None, + ) -> AlertDeliveryOutcome: + alert.delivery_status = AlertDeliveryStatus.failed + alert.delivery_error = detail + alert.telegram_chat_id = chat_id + alert.telegram_message_id = None + alert.delivered_at = None + log = AlertDeliveryLog( + seller_id=alert.seller_id, + alert_id=alert.id, + telegram_chat_id=chat_id, + delivery_status=AlertDeliveryStatus.failed, + error_message=detail, + ) + self._session.add(log) + await self._session.commit() + await self._emit_alert_failed(alert, AlertDeliveryStatus.failed, detail, chat_id) + return AlertDeliveryOutcome( + alert_id=str(alert.id), + status=AlertDeliveryStatus.failed, + detail=detail, + telegram_chat_id=chat_id, + ) + + async def _record_duplicate( + self, + alert: Alert, + *, + detail: str, + ) -> AlertDeliveryOutcome: + alert.delivery_status = AlertDeliveryStatus.duplicate + alert.delivery_error = detail + alert.telegram_message_id = None + alert.delivered_at = None + log = AlertDeliveryLog( + seller_id=alert.seller_id, + alert_id=alert.id, + telegram_chat_id=alert.telegram_chat_id, + delivery_status=AlertDeliveryStatus.duplicate, + error_message=detail, + ) + self._session.add(log) + await self._session.commit() + await self._emit_alert_failed( + alert, AlertDeliveryStatus.duplicate, detail, alert.telegram_chat_id + ) + return AlertDeliveryOutcome( + alert_id=str(alert.id), + status=AlertDeliveryStatus.duplicate, + detail=detail, + telegram_chat_id=alert.telegram_chat_id, + ) + + def _build_message_context(self, alert: Alert) -> AlertMessageContext: + payload = alert.payload or {} + return AlertMessageContext( + product_name=( + alert.product.title if alert.product else str(alert.product_id) + ), + action=self._format_action(alert.action_type), + suggested_price_min=_optional_float( + payload.get("suggested_price_min") + or payload.get("recommended_price_min") + or payload.get("recommended_price") + ), + suggested_price_max=_optional_float( + payload.get("suggested_price_max") + or payload.get("recommended_price_max") + ), + competitor_insight=_normalize_text( + payload.get("reasoning_summary") + or payload.get("reasoning") + or payload.get("triggered_rules") + ), + opportunity=_normalize_text( + payload.get("market_opportunity") + or payload.get("opportunity") + or payload.get("reasoning_summary") + ), + confidence=self._format_confidence(alert.confidence), + revenue_opportunity=_optional_float(payload.get("revenue_opportunity")), + product_id=str(alert.product_id), + recommendation_id=str(alert.recommendation_id) + if alert.recommendation_id + else None, + alert_type=str(alert.alert_type), + ) + + async def _emit_alert_sent(self, alert: Alert) -> None: + emitter = self._resolve_emitter() + if not emitter: + return + snapshot = self._build_alert_snapshot(alert) + event = AlertSentEvent( + payload=AlertSentPayload(alert=snapshot), + seller_id=alert.seller_id, + product_id=alert.product_id, + ) + await emitter.emit_event(event, rooms=[seller_room(str(alert.seller_id))]) + + async def _emit_alert_failed( + self, + alert: Alert, + status: AlertDeliveryStatus, + reason: str | None, + chat_id: str | None, + ) -> None: + emitter = self._resolve_emitter() + if not emitter: + return + event = AlertFailedEvent( + payload=AlertFailedPayload( + alert_id=alert.id, + status=status.value, + reason=reason, + telegram_chat_id=chat_id, + ), + seller_id=alert.seller_id, + product_id=alert.product_id, + ) + await emitter.emit_event(event, rooms=[seller_room(str(alert.seller_id))]) + + def _build_alert_snapshot(self, alert: Alert) -> AlertSnapshot: + product_title = alert.product.title if alert.product else None + urgency_level = self._format_urgency(alert.urgency_level) + return AlertSnapshot( + id=alert.id, + title=f"{self._format_label(alert.action_type).title()} for " + f"{product_title or 'product'}", + message=f"{self._format_label(alert.alert_type)} alert, " + f"{self._format_label(urgency_level)} urgency.", + severity=self._severity_from_urgency(urgency_level), + status="new", + category=self._category_from_action(alert.action_type, alert.alert_type), + channel="telegram", + product=product_title, + competitor=None, + urgency_score=self._urgency_score_from_level(urgency_level), + created_at=alert.created_at, + ) + + def _map_delivery_status(self, status: DeliveryStatus) -> AlertDeliveryStatus: + if status is DeliveryStatus.duplicate: + return AlertDeliveryStatus.duplicate + if status is DeliveryStatus.delivered: + return AlertDeliveryStatus.delivered + return AlertDeliveryStatus.failed + + def _resolve_emitter(self): + try: + return get_socket_emitter() + except RuntimeError: + self._logger.debug("socket_emitter_unavailable") + return None + + @staticmethod + def _format_action(value: Any) -> str: + return TelegramAlertDeliveryService._format_label(value).replace("_", " ").title() + + @staticmethod + def _format_confidence(value: Any) -> str | None: + if isinstance(value, Enum): + return value.value + if value is None: + return None + return str(value) + + @staticmethod + def _format_urgency(value: Any) -> str: + if isinstance(value, Enum): + return value.value + if value is None: + return AlertUrgencyLevel.MEDIUM.value + return str(value) + + @staticmethod + def _format_label(value: Any) -> str: + if value is None: + return "alert" + if isinstance(value, Enum): + value = value.value + return str(value).replace("_", " ").lower() + + @staticmethod + def _severity_from_urgency(level: str) -> str: + normalized = level.upper() + if normalized == "CRITICAL": + return "critical" + if normalized == "HIGH": + return "high" + if normalized == "MEDIUM": + return "medium" + return "low" + + @staticmethod + def _urgency_score_from_level(level: str) -> int: + normalized = level.upper() + if normalized == "CRITICAL": + return 90 + if normalized == "HIGH": + return 75 + if normalized == "MEDIUM": + return 55 + return 30 + + @staticmethod + def _category_from_action(action: Any, alert_type: Any) -> str: + action_value = ( + action.value if isinstance(action, Enum) else str(action or "") + ).upper() + if action_value in {"STOCK_WARNING", "REORDER"}: + return "stock_risk" + if action_value.startswith("PRICE"): + return "price_war" + if action_value == "OPPORTUNITY_CAPTURE": + return "opportunity" + if "system" in str(alert_type or "").lower(): + return "system" + return "competitor_move" + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _optional_float(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _normalize_text(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, (list, tuple, set)): + return ", ".join(str(item) for item in value) + return str(value) diff --git a/backend/app/services/telegram/telegram_client.py b/backend/app/services/telegram/telegram_client.py new file mode 100644 index 0000000..c3c6292 --- /dev/null +++ b/backend/app/services/telegram/telegram_client.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from datetime import datetime +from typing import Any + +from pydantic import AliasChoices, BaseModel, Field, ValidationError +from pydantic_settings import BaseSettings, SettingsConfigDict +from telegram import Bot, Message, User +from telegram.error import ( + BadRequest, + Forbidden, + InvalidToken, + NetworkError, + RetryAfter, + TelegramError, + TimedOut, +) +from telegram.request import HTTPXRequest + + +class TelegramClientError(Exception): + """Base exception for Telegram client failures.""" + + +class TelegramConfigError(TelegramClientError): + """Raised when configuration is missing or invalid.""" + + +class TelegramPayloadError(TelegramClientError): + """Raised when the request payload is malformed.""" + + +class TelegramInvalidChatError(TelegramClientError): + """Raised when the chat id is invalid or inaccessible.""" + + +class TelegramUnauthorizedError(TelegramClientError): + """Raised when the bot token is invalid or unauthorized.""" + + +class TelegramTimeoutError(TelegramClientError): + """Raised when the Telegram API times out.""" + + +class TelegramNetworkError(TelegramClientError): + """Raised when Telegram API is unreachable.""" + + +class TelegramRateLimitError(TelegramClientError): + """Raised when Telegram rate limits the bot.""" + + def __init__(self, message: str, *, retry_after: float | None = None) -> None: + super().__init__(message) + self.retry_after = retry_after + + +class TelegramApiError(TelegramClientError): + """Raised for non-retryable Telegram API errors.""" + + +_CURRENT_DIR = Path(__file__).resolve() +_BACKEND_ENV = _CURRENT_DIR.parents[3] / ".env" +_ROOT_ENV = _CURRENT_DIR.parents[4] / ".env" + + +class TelegramSettings(BaseSettings): + """Environment-driven configuration for Telegram delivery.""" + + model_config = SettingsConfigDict( + env_prefix="TELEGRAM_", + env_file=(str(_BACKEND_ENV), str(_ROOT_ENV), ".env"), + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + bot_token: str = Field(min_length=10) + default_chat_id: str | None = Field( + default=None, + validation_alias=AliasChoices("DEFAULT_CHAT_ID", "CHAT_ID"), + ) + timeout_seconds: float = Field(default=10.0, ge=1.0) + max_retries: int = Field(default=3, ge=0) + + +class TelegramMessagePayload(BaseModel): + """Validated response data from Telegram API.""" + + message_id: int + chat_id: int | str + date: datetime + text: str | None = None + raw: dict[str, Any] + + @classmethod + def from_message(cls, message: Message) -> "TelegramMessagePayload": + if message is None: + raise TelegramApiError("Telegram returned an empty response.") + raw = message.to_dict() + chat_id = message.chat.id if message.chat else raw.get("chat", {}).get("id") + if message.message_id is None or chat_id is None: + raise TelegramApiError("Telegram response is missing required fields.") + return cls( + message_id=message.message_id, + chat_id=chat_id, + date=message.date, + text=message.text, + raw=raw, + ) + + +class TelegramClient: + """Async Telegram client wrapper with normalized error handling.""" + + def __init__( + self, + settings: TelegramSettings | None = None, + logger: Any | None = None, + ) -> None: + if settings is None: + try: + settings = TelegramSettings() + except ValidationError as exc: + raise TelegramConfigError("Telegram settings are invalid.") from exc + self._settings = settings + self._logger = logger + self._bot = Bot(token=self._settings.bot_token, request=self._build_request()) + + @property + def settings(self) -> TelegramSettings: + return self._settings + + def _build_request(self) -> HTTPXRequest: + timeout = self._settings.timeout_seconds + return HTTPXRequest( + connect_timeout=timeout, + read_timeout=timeout, + write_timeout=timeout, + pool_timeout=timeout, + ) + + @staticmethod + def validate_chat_id(chat_id: str | int | None) -> str | int: + if chat_id is None: + raise TelegramInvalidChatError("Chat id is required.") + if isinstance(chat_id, int): + return chat_id + if isinstance(chat_id, str): + cleaned = chat_id.strip() + if cleaned.startswith("@"): + return cleaned + if cleaned.isdigit(): + return int(cleaned) + raise TelegramInvalidChatError("Chat id format is invalid.") + + async def send_message( + self, + *, + chat_id: str | int, + text: str, + parse_mode: str | None = None, + disable_web_page_preview: bool = True, + ) -> Message: + if not text or not text.strip(): + raise TelegramPayloadError("Message text cannot be empty.") + + try: + return await asyncio.wait_for( + self._bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + ), + timeout=self._settings.timeout_seconds, + ) + except RetryAfter as exc: + raise TelegramRateLimitError( + "Telegram rate limit exceeded.", retry_after=exc.retry_after + ) from exc + except TimedOut as exc: + raise TelegramTimeoutError("Telegram request timed out.") from exc + except NetworkError as exc: + raise TelegramNetworkError("Telegram network failure.") from exc + except InvalidToken as exc: + raise TelegramUnauthorizedError("Telegram bot token is invalid.") from exc + except Forbidden as exc: + raise TelegramInvalidChatError("Bot does not have access to the chat.") from exc + except BadRequest as exc: + detail = str(exc) + if _is_invalid_chat_message(detail): + raise TelegramInvalidChatError("Telegram chat id is invalid.") from exc + raise TelegramApiError(detail) from exc + except TelegramError as exc: + raise TelegramApiError(str(exc)) from exc + + async def get_me(self) -> User: + try: + return await asyncio.wait_for( + self._bot.get_me(), + timeout=self._settings.timeout_seconds, + ) + except RetryAfter as exc: + raise TelegramRateLimitError( + "Telegram rate limit exceeded.", retry_after=exc.retry_after + ) from exc + except TimedOut as exc: + raise TelegramTimeoutError("Telegram request timed out.") from exc + except NetworkError as exc: + raise TelegramNetworkError("Telegram network failure.") from exc + except InvalidToken as exc: + raise TelegramUnauthorizedError("Telegram bot token is invalid.") from exc + except TelegramError as exc: + raise TelegramApiError(str(exc)) from exc + + +def _is_invalid_chat_message(detail: str) -> bool: + lowered = detail.lower() + return any( + phrase in lowered + for phrase in ( + "chat not found", + "user not found", + "invalid user id", + "chat_id", + "chat id", + ) + ) \ No newline at end of file diff --git a/backend/app/services/telegram/telegram_connection_service.py b/backend/app/services/telegram/telegram_connection_service.py new file mode 100644 index 0000000..d2cbf63 --- /dev/null +++ b/backend/app/services/telegram/telegram_connection_service.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings as core_settings +from app.models.telegram_connection import SellerTelegramConnection +from app.websocket.socket_emitter import SocketEmitter +from app.websocket.socket_events import ( + TelegramConnectionPayload, + TelegramConnectionSnapshot, + TelegramConnectedEvent, + TelegramDisconnectedEvent, +) +from app.websocket.socket_rooms import seller_room +from app.websocket.socket_manager import get_socket_emitter + +_CURRENT_DIR = Path(__file__).resolve() +_BACKEND_ENV = _CURRENT_DIR.parents[3] / ".env" +_ROOT_ENV = _CURRENT_DIR.parents[4] / ".env" + + +class TelegramConnectionSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="TELEGRAM_", + env_file=(str(_BACKEND_ENV), str(_ROOT_ENV), ".env"), + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + connect_token_ttl_minutes: int = Field(default=10, ge=1) + connect_token_secret: str | None = None + webhook_secret: str | None = None + alert_cooldown_seconds: int = Field(default=7200, ge=0) + bot_username: str | None = None + + @property + def resolved_token_secret(self) -> str: + return self.connect_token_secret or core_settings.jwt_secret_key + + +class TelegramConnectionError(Exception): + """Base error for Telegram connection operations.""" + + +class ConnectionTokenInvalidError(TelegramConnectionError): + """Raised when a connection token is invalid.""" + + +class ConnectionTokenExpiredError(TelegramConnectionError): + """Raised when a connection token has expired.""" + + +class ConnectionNotFoundError(TelegramConnectionError): + """Raised when a seller connection is missing.""" + + +class TelegramUserSnapshot(BaseModel): + telegram_chat_id: str + telegram_username: str | None + telegram_first_name: str | None + telegram_last_name: str | None + + +@dataclass(frozen=True) +class ConnectionToken: + token: str + expires_at: datetime + + +class TelegramConnectionService: + def __init__( + self, + session: AsyncSession, + *, + settings: TelegramConnectionSettings | None = None, + logger: logging.Logger | None = None, + emitter: SocketEmitter | None = None, + ) -> None: + self._session = session + self._settings = settings or TelegramConnectionSettings() + self._logger = logger or logging.getLogger(__name__) + self._emitter = emitter + + @property + def settings(self) -> TelegramConnectionSettings: + return self._settings + + async def generate_connect_token(self, seller_id: str) -> ConnectionToken: + token = secrets.token_urlsafe(32) + hashed = self._hash_token(token) + expires_at = self._now() + timedelta(minutes=self._settings.connect_token_ttl_minutes) + connection = await self._get_or_create_connection(seller_id) + connection.connection_token = hashed + connection.token_expires_at = expires_at + await self._session.commit() + await self._session.refresh(connection) + self._logger.info( + "telegram_connect_token_issued seller_id=%s expires_at=%s", + seller_id, + expires_at.isoformat(), + ) + return ConnectionToken(token=token, expires_at=expires_at) + + async def get_status(self, seller_id: str) -> SellerTelegramConnection | None: + statement = select(SellerTelegramConnection).where( + SellerTelegramConnection.seller_id == seller_id + ) + return (await self._session.execute(statement)).scalar_one_or_none() + + async def connect_with_token( + self, + token: str, + *, + chat_id: int | str, + user: TelegramUserSnapshot, + ) -> SellerTelegramConnection: + hashed = self._hash_token(token) + statement = select(SellerTelegramConnection).where( + SellerTelegramConnection.connection_token == hashed + ) + connection = (await self._session.execute(statement)).scalar_one_or_none() + if not connection: + self._logger.warning("telegram_connect_invalid token_hash=%s", hashed) + raise ConnectionTokenInvalidError("Connection token is invalid.") + + if connection.token_expires_at and connection.token_expires_at <= self._now(): + connection.connection_token = None + connection.token_expires_at = None + await self._session.commit() + self._logger.info("telegram_connect_expired seller_id=%s", connection.seller_id) + raise ConnectionTokenExpiredError("Connection token has expired.") + + connection.telegram_chat_id = str(chat_id) + connection.telegram_username = user.telegram_username + connection.telegram_first_name = user.telegram_first_name + connection.telegram_last_name = user.telegram_last_name + connection.is_connected = True + connection.connected_at = self._now() + connection.connection_token = None + connection.token_expires_at = None + await self._session.commit() + await self._session.refresh(connection) + await self._emit_connection_event(connection, connected=True) + self._logger.info( + "telegram_connected seller_id=%s chat_id=%s username=%s", + connection.seller_id, + connection.telegram_chat_id, + connection.telegram_username, + ) + return connection + + async def disconnect_seller(self, seller_id: str) -> SellerTelegramConnection: + statement = select(SellerTelegramConnection).where( + SellerTelegramConnection.seller_id == seller_id + ) + connection = (await self._session.execute(statement)).scalar_one_or_none() + if not connection: + raise ConnectionNotFoundError("Seller telegram connection not found.") + await self._clear_connection(connection) + return connection + + async def disconnect_by_chat_id(self, chat_id: int | str) -> SellerTelegramConnection: + statement = select(SellerTelegramConnection).where( + SellerTelegramConnection.telegram_chat_id == str(chat_id) + ) + connection = (await self._session.execute(statement)).scalar_one_or_none() + if not connection: + raise ConnectionNotFoundError("Telegram chat is not linked.") + await self._clear_connection(connection) + return connection + + async def get_by_chat_id(self, chat_id: int | str) -> SellerTelegramConnection | None: + statement = select(SellerTelegramConnection).where( + SellerTelegramConnection.telegram_chat_id == str(chat_id) + ) + return (await self._session.execute(statement)).scalar_one_or_none() + + async def mark_alert_sent(self, seller_id: str, sent_at: datetime | None = None) -> None: + statement = select(SellerTelegramConnection).where( + SellerTelegramConnection.seller_id == seller_id + ) + connection = (await self._session.execute(statement)).scalar_one_or_none() + if not connection: + return + connection.last_alert_sent_at = sent_at or self._now() + await self._session.commit() + + async def _get_or_create_connection(self, seller_id: str) -> SellerTelegramConnection: + existing = await self.get_status(seller_id) + if existing: + return existing + connection = SellerTelegramConnection(seller_id=seller_id, is_connected=False) + self._session.add(connection) + await self._session.commit() + await self._session.refresh(connection) + return connection + + async def _clear_connection(self, connection: SellerTelegramConnection) -> None: + connection.telegram_chat_id = None + connection.telegram_username = None + connection.telegram_first_name = None + connection.telegram_last_name = None + connection.is_connected = False + connection.connected_at = None + connection.connection_token = None + connection.token_expires_at = None + await self._session.commit() + await self._session.refresh(connection) + await self._emit_connection_event(connection, connected=False) + self._logger.info("telegram_disconnected seller_id=%s", connection.seller_id) + + def _hash_token(self, token: str) -> str: + secret = self._settings.resolved_token_secret.encode("utf-8") + return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest() + + def _now(self) -> datetime: + return datetime.now(timezone.utc) + + async def _emit_connection_event( + self, + connection: SellerTelegramConnection, + *, + connected: bool, + ) -> None: + emitter = self._resolve_emitter() + if not emitter: + return + snapshot = TelegramConnectionSnapshot( + id=connection.id, + is_connected=connection.is_connected, + telegram_username=connection.telegram_username, + telegram_first_name=connection.telegram_first_name, + telegram_last_name=connection.telegram_last_name, + connected_at=connection.connected_at, + last_alert_sent_at=connection.last_alert_sent_at, + ) + payload = TelegramConnectionPayload(connection=snapshot) + event = ( + TelegramConnectedEvent(payload=payload, seller_id=connection.seller_id) + if connected + else TelegramDisconnectedEvent(payload=payload, seller_id=connection.seller_id) + ) + await emitter.emit_event(event, rooms=[seller_room(str(connection.seller_id))]) + + def _resolve_emitter(self) -> SocketEmitter | None: + if self._emitter is not None: + return self._emitter + try: + self._emitter = get_socket_emitter() + except RuntimeError: + self._logger.debug("socket_emitter_unavailable") + return None + return self._emitter diff --git a/backend/app/services/telegram/telegram_formatter.py b/backend/app/services/telegram/telegram_formatter.py new file mode 100644 index 0000000..8e8e7bf --- /dev/null +++ b/backend/app/services/telegram/telegram_formatter.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import re +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.services.telegram.telegram_templates import DEFAULT_ALERT_TEMPLATE + +_MARKDOWN_V2_SPECIAL_CHARS = r"_*[]()~`>#+-=|{}.!" +_MARKDOWN_V2_PATTERN = re.compile(rf"([\\{re.escape(_MARKDOWN_V2_SPECIAL_CHARS)}])") + + +class AlertMessageContext(BaseModel): + """Structured alert data used to generate Telegram-ready messages.""" + + model_config = ConfigDict(extra="allow") + + product_name: str + action: str + suggested_price_min: float | None = None + suggested_price_max: float | None = None + competitor_insight: str | None = None + opportunity: str | None = None + confidence: str | None = None + revenue_opportunity: float | None = None + product_id: str | None = None + recommendation_id: str | None = None + alert_type: str = "product_alert" + currency: str = Field(default="৳", min_length=1) + custom_message: str | None = None + + +def escape_markdown(text: str) -> str: + """Escape text for Telegram MarkdownV2 parsing.""" + if not text: + return "" + return _MARKDOWN_V2_PATTERN.sub(r"\\\1", text) + + +def format_alert_message( + context: AlertMessageContext, + template: str | None = None, + *, + markdown_safe: bool = True, +) -> str: + """Format a Telegram alert message using the configured template.""" + resolved_template = template or DEFAULT_ALERT_TEMPLATE + suggested_price = _format_price_range( + context.suggested_price_min, context.suggested_price_max, context.currency + ) + revenue_opportunity = _format_currency(context.revenue_opportunity, context.currency) + + payload: dict[str, Any] = { + "product_name": context.product_name, + "action": context.action, + "suggested_price": suggested_price, + "competitor_insight": context.competitor_insight or "N/A", + "opportunity": context.opportunity or "N/A", + "confidence": context.confidence or "N/A", + "revenue_opportunity": revenue_opportunity, + } + + if markdown_safe: + payload = {key: escape_markdown(str(value)) for key, value in payload.items()} + + message = resolved_template.format_map(payload) + if context.custom_message: + suffix = escape_markdown(context.custom_message) if markdown_safe else context.custom_message + message = f"{message}\n\n{suffix}" + return message + + +def _format_price_range( + minimum: float | None, + maximum: float | None, + currency: str, +) -> str: + if minimum is None and maximum is None: + return "N/A" + if minimum is not None and maximum is not None: + return f"{currency}{minimum:,.0f}-{maximum:,.0f}" + value = minimum if minimum is not None else maximum + return f"{currency}{value:,.0f}" + + +def _format_currency(value: float | None, currency: str) -> str: + if value is None: + return "N/A" + return f"{currency}{value:,.0f}" \ No newline at end of file diff --git a/backend/app/services/telegram/telegram_router.py b/backend/app/services/telegram/telegram_router.py new file mode 100644 index 0000000..fa71dcd --- /dev/null +++ b/backend/app/services/telegram/telegram_router.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies.async_db import get_async_db +from app.dependencies.auth import get_current_seller_async +from app.models.seller import Seller +from app.schemas.telegram import ( + TelegramConnectTokenResponse, + TelegramConnectionStatus, + TelegramDisconnectResponse, + TelegramHealthResponse, +) +from app.services.telegram.telegram_client import TelegramClient, TelegramClientError, TelegramConfigError +from app.services.telegram.telegram_connection_service import ( + ConnectionNotFoundError, + TelegramConnectionService, + TelegramConnectionSettings, +) +from app.services.telegram.telegram_service import TelegramService +from app.services.telegram.telegram_webhook import TelegramWebhookHandler + +router = APIRouter(prefix="/telegram", tags=["telegram"]) +logger = logging.getLogger(__name__) + + +@router.post("/connect-token", response_model=TelegramConnectTokenResponse) +async def create_connect_token( + current_seller: Seller = Depends(get_current_seller_async), + db: AsyncSession = Depends(get_async_db), +) -> TelegramConnectTokenResponse: + service = TelegramConnectionService(db, logger=logger) + token = await service.generate_connect_token(str(current_seller.id)) + return TelegramConnectTokenResponse( + token=token.token, + expires_at=token.expires_at, + bot_username=service.settings.bot_username, + ) + + +@router.get("/status", response_model=TelegramConnectionStatus) +async def get_connection_status( + current_seller: Seller = Depends(get_current_seller_async), + db: AsyncSession = Depends(get_async_db), +) -> TelegramConnectionStatus: + service = TelegramConnectionService(db, logger=logger) + connection = await service.get_status(str(current_seller.id)) + return TelegramConnectionStatus( + is_connected=bool(connection and connection.is_connected), + connected_at=connection.connected_at if connection else None, + telegram_username=connection.telegram_username if connection else None, + telegram_first_name=connection.telegram_first_name if connection else None, + telegram_last_name=connection.telegram_last_name if connection else None, + last_alert_sent_at=connection.last_alert_sent_at if connection else None, + bot_username=service.settings.bot_username, + ) + + +@router.post("/disconnect", response_model=TelegramDisconnectResponse) +async def disconnect_telegram( + current_seller: Seller = Depends(get_current_seller_async), + db: AsyncSession = Depends(get_async_db), +) -> TelegramDisconnectResponse: + service = TelegramConnectionService(db, logger=logger) + try: + await service.disconnect_seller(str(current_seller.id)) + except ConnectionNotFoundError: + return TelegramDisconnectResponse() + return TelegramDisconnectResponse() + + +@router.post("/webhook", status_code=status.HTTP_200_OK) +async def telegram_webhook( + request: Request, + db: AsyncSession = Depends(get_async_db), +) -> dict[str, str]: + settings = TelegramConnectionSettings() + secret = settings.webhook_secret + if secret: + header = request.headers.get("X-Telegram-Bot-Api-Secret-Token") + if header != secret: + raise HTTPException(status_code=403, detail="Invalid webhook token.") + + payload = await request.json() + service = TelegramConnectionService(db, settings=settings, logger=logger) + handler = TelegramWebhookHandler(service, TelegramService(logger=logger), logger=logger) + try: + await handler.handle(payload) + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"status": "ok"} + + +@router.get("/health", response_model=TelegramHealthResponse) +async def telegram_health_check() -> TelegramHealthResponse: + try: + client = TelegramClient() + me = await client.get_me() + except TelegramConfigError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except TelegramClientError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return TelegramHealthResponse( + status="ok", + bot_username=me.username, + bot_id=me.id, + ) diff --git a/backend/app/services/telegram/telegram_service.py b/backend/app/services/telegram/telegram_service.py new file mode 100644 index 0000000..03a2bfb --- /dev/null +++ b/backend/app/services/telegram/telegram_service.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import time +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Awaitable, Callable, Protocol, Sequence + +from pydantic import BaseModel, ConfigDict, Field +from telegram.constants import ParseMode + +from app.services.telegram.telegram_client import ( + TelegramApiError, + TelegramClient, + TelegramClientError, + TelegramInvalidChatError, + TelegramMessagePayload, + TelegramNetworkError, + TelegramPayloadError, + TelegramRateLimitError, + TelegramSettings, + TelegramTimeoutError, + TelegramUnauthorizedError, +) +from app.services.telegram.telegram_formatter import AlertMessageContext, format_alert_message +from app.services.telegram.telegram_templates import DEFAULT_ALERT_TEMPLATE_KEY, get_template +from app.services.dashboard.events import DashboardEvent +from app.websocket.socket_manager import socket_manager + +EventEmitter = Callable[[str, dict[str, Any]], Awaitable[None]] + + +class DeliveryStatus(str, Enum): + delivered = "delivered" + failed = "failed" + duplicate = "duplicate" + + +class TelegramSendRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + chat_id: int | str + text: str + parse_mode: str | None = None + disable_web_page_preview: bool = True + alert_type: str | None = None + product_id: str | None = None + recommendation_id: str | None = None + idempotency_key: str | None = None + + +class TelegramDeliveryResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + success: bool + status: DeliveryStatus + detail: str | None = None + message_id: int | None = None + chat_id: int | str | None = None + response_time_ms: int | None = None + retry_count: int = 0 + telegram_response: dict[str, Any] | None = None + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + alert_type: str | None = None + product_id: str | None = None + recommendation_id: str | None = None + idempotency_key: str | None = None + + +class IdempotencyStore(Protocol): + async def is_duplicate(self, key: str) -> bool: ... + + async def record(self, key: str, ttl_seconds: int) -> None: ... + + +class InMemoryIdempotencyStore: + """Process-local idempotency store for duplicate prevention.""" + + def __init__(self) -> None: + self._entries: dict[str, float] = {} + self._lock = asyncio.Lock() + + async def is_duplicate(self, key: str) -> bool: + async with self._lock: + self._prune() + return key in self._entries + + async def record(self, key: str, ttl_seconds: int) -> None: + async with self._lock: + self._entries[key] = time.monotonic() + ttl_seconds + + def _prune(self) -> None: + now = time.monotonic() + expired = [key for key, expires_at in self._entries.items() if expires_at <= now] + for key in expired: + self._entries.pop(key, None) + + +class TelegramService: + """Orchestrates formatting, idempotency, retries, and delivery logging.""" + + def __init__( + self, + settings: TelegramSettings | None = None, + *, + client: TelegramClient | None = None, + idempotency_store: IdempotencyStore | None = None, + logger: logging.Logger | None = None, + event_emitter: EventEmitter | None = None, + ) -> None: + self._settings = settings or TelegramSettings() + self._logger = logger or logging.getLogger(__name__) + self._client = client or TelegramClient(self._settings, logger=self._logger) + self._idempotency_store = idempotency_store or InMemoryIdempotencyStore() + self._event_emitter = event_emitter or socket_manager.emit + + async def send_text_message( + self, + text: str, + *, + chat_id: int | str | None = None, + parse_mode: str | None = None, + prevent_duplicate: bool = False, + idempotency_key: str | None = None, + alert_type: str | None = None, + product_id: str | None = None, + recommendation_id: str | None = None, + ) -> TelegramDeliveryResult: + resolved_chat_id = self._resolve_chat_id(chat_id) + request = TelegramSendRequest( + chat_id=resolved_chat_id, + text=text, + parse_mode=parse_mode, + alert_type=alert_type, + product_id=product_id, + recommendation_id=recommendation_id, + idempotency_key=idempotency_key, + ) + return await self.retry_failed_delivery(request, prevent_duplicate=prevent_duplicate) + + async def send_alert_message( + self, + alert: AlertMessageContext, + *, + chat_id: int | str | None = None, + template_key: str | None = DEFAULT_ALERT_TEMPLATE_KEY, + idempotency_key: str | None = None, + ) -> TelegramDeliveryResult: + template = get_template(template_key) + message = format_alert_message(alert, template) + return await self.send_text_message( + message, + chat_id=chat_id, + parse_mode=ParseMode.MARKDOWN_V2, + prevent_duplicate=True, + idempotency_key=idempotency_key, + alert_type=alert.alert_type, + product_id=alert.product_id, + recommendation_id=alert.recommendation_id, + ) + + async def send_bulk_alerts( + self, + alerts: Sequence[AlertMessageContext], + *, + chat_id: int | str | None = None, + template_key: str | None = DEFAULT_ALERT_TEMPLATE_KEY, + concurrency_limit: int = 10, + ) -> list[TelegramDeliveryResult]: + semaphore = asyncio.Semaphore(concurrency_limit) + + async def send_one(alert: AlertMessageContext) -> TelegramDeliveryResult: + async with semaphore: + return await self.send_alert_message( + alert, + chat_id=chat_id, + template_key=template_key, + ) + + tasks = [send_one(alert) for alert in alerts] + return list(await asyncio.gather(*tasks)) + + async def retry_failed_delivery( + self, + request: TelegramSendRequest, + *, + prevent_duplicate: bool = False, + max_retries: int | None = None, + idempotency_ttl_seconds: int = 3600, + ) -> TelegramDeliveryResult: + if prevent_duplicate: + idempotency_key = request.idempotency_key or self._build_idempotency_key(request) + request = request.model_copy(update={"idempotency_key": idempotency_key}) + if await self._idempotency_store.is_duplicate(idempotency_key): + result = TelegramDeliveryResult( + success=True, + status=DeliveryStatus.duplicate, + detail="duplicate_alert_skipped", + chat_id=request.chat_id, + alert_type=request.alert_type, + product_id=request.product_id, + recommendation_id=request.recommendation_id, + idempotency_key=idempotency_key, + ) + self.structured_delivery_logging(result) + return result + + retries = max_retries if max_retries is not None else self._settings.max_retries + retry_count = 0 + + while True: + start_time = time.monotonic() + try: + payload = await self._send_once(request) + response_time_ms = int((time.monotonic() - start_time) * 1000) + result = TelegramDeliveryResult( + success=True, + status=DeliveryStatus.delivered, + message_id=payload.message_id, + chat_id=payload.chat_id, + response_time_ms=response_time_ms, + retry_count=retry_count, + telegram_response=payload.raw, + alert_type=request.alert_type, + product_id=request.product_id, + recommendation_id=request.recommendation_id, + idempotency_key=request.idempotency_key, + ) + if request.idempotency_key: + await self._idempotency_store.record( + request.idempotency_key, + ttl_seconds=idempotency_ttl_seconds, + ) + self.structured_delivery_logging(result) + await self._emit_event(DashboardEvent.alert_sent, result) + return result + except TelegramClientError as exc: + response_time_ms = int((time.monotonic() - start_time) * 1000) + if not self._should_retry(exc) or retry_count >= retries: + result = self._build_failure_result( + request=request, + error=exc, + response_time_ms=response_time_ms, + retry_count=retry_count, + ) + self.structured_delivery_logging(result) + await self._emit_event("telegram.delivery_failed", result) + return result + + retry_count += 1 + await asyncio.sleep(self._compute_backoff_seconds(retry_count, exc)) + + def structured_delivery_logging(self, result: TelegramDeliveryResult) -> None: + payload = { + "alert_type": result.alert_type, + "delivery_status": result.status.value, + "response_time_ms": result.response_time_ms, + "retry_count": result.retry_count, + "telegram_response": result.telegram_response, + "timestamp": result.timestamp.isoformat(), + "product_id": result.product_id, + "recommendation_id": result.recommendation_id, + "chat_id": result.chat_id, + "idempotency_key": result.idempotency_key, + } + safe_payload = {key: value for key, value in payload.items() if value is not None} + serialized = json.dumps(safe_payload, ensure_ascii=False, default=str) + level = logging.INFO if result.success else logging.WARNING + self._logger.log(level, f"telegram_delivery {serialized}") + + def _resolve_chat_id(self, chat_id: int | str | None) -> int | str: + resolved_chat_id = chat_id or self._settings.default_chat_id + if resolved_chat_id is None: + raise TelegramInvalidChatError("Chat id must be provided for Telegram delivery.") + return TelegramClient.validate_chat_id(resolved_chat_id) + + async def _send_once(self, request: TelegramSendRequest) -> TelegramMessagePayload: + if not request.text or not request.text.strip(): + raise TelegramPayloadError("Telegram message content is empty.") + message = await self._client.send_message( + chat_id=request.chat_id, + text=request.text, + parse_mode=request.parse_mode, + disable_web_page_preview=request.disable_web_page_preview, + ) + return TelegramMessagePayload.from_message(message) + + def _should_retry(self, error: TelegramClientError) -> bool: + if isinstance(error, (TelegramUnauthorizedError, TelegramInvalidChatError, TelegramPayloadError)): + return False + return isinstance( + error, + (TelegramTimeoutError, TelegramNetworkError, TelegramRateLimitError, TelegramApiError), + ) + + def _compute_backoff_seconds(self, retry_count: int, error: TelegramClientError) -> float: + base_delay = 1.0 + wait_seconds = base_delay * (2 ** (retry_count - 1)) + if isinstance(error, TelegramRateLimitError) and error.retry_after: + wait_seconds = max(wait_seconds, float(error.retry_after)) + return wait_seconds + + def _build_idempotency_key(self, request: TelegramSendRequest) -> str: + payload = { + "chat_id": str(request.chat_id), + "text": request.text, + "alert_type": request.alert_type, + "product_id": request.product_id, + "recommendation_id": request.recommendation_id, + } + encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _build_failure_result( + self, + *, + request: TelegramSendRequest, + error: TelegramClientError, + response_time_ms: int | None, + retry_count: int, + ) -> TelegramDeliveryResult: + return TelegramDeliveryResult( + success=False, + status=DeliveryStatus.failed, + detail=str(error), + chat_id=request.chat_id, + response_time_ms=response_time_ms, + retry_count=retry_count, + alert_type=request.alert_type, + product_id=request.product_id, + recommendation_id=request.recommendation_id, + idempotency_key=request.idempotency_key, + ) + + async def _emit_event(self, event: str, result: TelegramDeliveryResult) -> None: + if not self._event_emitter: + return + await self._event_emitter(event, result.model_dump()) + + +def run_async(coro: Awaitable[TelegramDeliveryResult]) -> TelegramDeliveryResult: + """Run async Telegram delivery from sync contexts (e.g., Celery workers).""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + raise RuntimeError("run_async cannot be used inside a running event loop.") + + +async def example_usage() -> None: + """Example: send a formatted Telegram alert.""" + logger = logging.getLogger("app.telegram") + service = TelegramService(logger=logger) + alert = AlertMessageContext( + product_name="Dove Body Lotion 200ml", + action="PRICE LOWER করুন", + suggested_price_min=295, + suggested_price_max=305, + competitor_insight="2 competitors are currently out-of-stock.", + opportunity="Lowering price may capture market demand.", + confidence="HIGH", + revenue_opportunity=4200, + product_id="prod_123", + recommendation_id="rec_456", + ) + await service.send_alert_message(alert) \ No newline at end of file diff --git a/backend/app/services/telegram/telegram_templates.py b/backend/app/services/telegram/telegram_templates.py new file mode 100644 index 0000000..8b04655 --- /dev/null +++ b/backend/app/services/telegram/telegram_templates.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Mapping + +DEFAULT_ALERT_TEMPLATE_KEY = "product_alert" + +DEFAULT_ALERT_TEMPLATE = ( + "🚨 BazarSync Product Alert\n\n" + "📦 Product: {product_name}\n\n" + "⚡ Action: {action}\n\n" + "💰 Suggested Price: {suggested_price}\n\n" + "📊 Competitor Insight:\n{competitor_insight}\n\n" + "🎯 Opportunity:\n{opportunity}\n\n" + "📈 Confidence: {confidence}\n\n" + "💵 Revenue Opportunity: {revenue_opportunity}\n" +) + +ALERT_TEMPLATES: Mapping[str, str] = { + DEFAULT_ALERT_TEMPLATE_KEY: DEFAULT_ALERT_TEMPLATE, +} + + +def get_template(key: str | None) -> str: + if not key: + return DEFAULT_ALERT_TEMPLATE + return ALERT_TEMPLATES.get(key, DEFAULT_ALERT_TEMPLATE) \ No newline at end of file diff --git a/backend/app/services/telegram/telegram_utils.py b/backend/app/services/telegram/telegram_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/telegram/telegram_webhook.py b/backend/app/services/telegram/telegram_webhook.py new file mode 100644 index 0000000..af29d34 --- /dev/null +++ b/backend/app/services/telegram/telegram_webhook.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import logging +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from app.services.telegram.telegram_connection_service import ( + ConnectionNotFoundError, + ConnectionTokenExpiredError, + ConnectionTokenInvalidError, + TelegramConnectionService, + TelegramUserSnapshot, +) +from app.services.telegram.telegram_service import TelegramService + + +class TelegramChat(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: int + type: str + + +class TelegramUser(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: int + is_bot: bool | None = None + first_name: str | None = None + last_name: str | None = None + username: str | None = None + + +class TelegramMessage(BaseModel): + model_config = ConfigDict(extra="ignore") + + message_id: int + date: int + chat: TelegramChat + from_user: TelegramUser = Field(alias="from") + text: str | None = None + + +class TelegramUpdate(BaseModel): + model_config = ConfigDict(extra="ignore") + + update_id: int + message: TelegramMessage | None = None + + +class TelegramWebhookHandler: + def __init__( + self, + connection_service: TelegramConnectionService, + telegram_service: TelegramService, + *, + logger: logging.Logger | None = None, + ) -> None: + self._connections = connection_service + self._telegram = telegram_service + self._logger = logger or logging.getLogger(__name__) + + async def handle(self, payload: dict[str, Any]) -> None: + try: + update = TelegramUpdate.model_validate(payload) + except ValidationError as exc: + self._logger.warning("telegram_webhook_invalid_payload error=%s", exc) + return + + message = update.message + if not message or not message.text: + return + + chat_id = message.chat.id + command, argument = self._parse_command(message.text) + user_snapshot = TelegramUserSnapshot( + telegram_chat_id=str(chat_id), + telegram_username=message.from_user.username, + telegram_first_name=message.from_user.first_name, + telegram_last_name=message.from_user.last_name, + ) + + if command == "/start": + await self._send_message(chat_id, self._start_message()) + return + if command == "/help": + await self._send_message(chat_id, self._help_message()) + return + if command == "/connect": + await self._handle_connect(chat_id, argument, user_snapshot) + return + if command == "/disconnect": + await self._handle_disconnect(chat_id) + return + if command == "/status": + await self._handle_status(chat_id) + return + + await self._send_message(chat_id, self._unknown_command_message()) + + async def _handle_connect( + self, + chat_id: int, + token: str | None, + user: TelegramUserSnapshot, + ) -> None: + if not token: + await self._send_message(chat_id, "Please provide your connect token.") + return + try: + connection = await self._connections.connect_with_token( + token, + chat_id=chat_id, + user=user, + ) + except ConnectionTokenExpiredError: + await self._send_message( + chat_id, + "⚠️ That token has expired. Please generate a new token from the dashboard.", + ) + return + except ConnectionTokenInvalidError: + await self._send_message( + chat_id, + "❌ Token not recognized. Please copy the latest token from the dashboard.", + ) + return + + await self._send_message( + chat_id, + "✅ Your Telegram account is now connected to SMEBoost War Room Alerts.", + ) + self._logger.info( + "telegram_connect_success seller_id=%s chat_id=%s", + connection.seller_id, + connection.telegram_chat_id, + ) + + async def _handle_disconnect(self, chat_id: int) -> None: + try: + await self._connections.disconnect_by_chat_id(chat_id) + except ConnectionNotFoundError: + await self._send_message(chat_id, "You are not connected yet.") + return + await self._send_message(chat_id, "✅ Telegram alerts have been disconnected.") + + async def _handle_status(self, chat_id: int) -> None: + connection = await self._connections.get_by_chat_id(chat_id) + if not connection or not connection.is_connected: + await self._send_message(chat_id, "You are not connected to any seller yet.") + return + username = connection.telegram_username or connection.telegram_first_name or "seller" + await self._send_message( + chat_id, + f"✅ Connected to {username}. Alerts will arrive here.", + ) + + async def _send_message(self, chat_id: int, text: str) -> None: + await self._telegram.send_text_message(text, chat_id=chat_id) + + @staticmethod + def _parse_command(text: str) -> tuple[str, str | None]: + cleaned = text.strip() + if not cleaned.startswith("/"): + return "", None + parts = cleaned.split() + command = parts[0].split("@")[0].lower() + argument = parts[1] if len(parts) > 1 else None + return command, argument + + @staticmethod + def _start_message() -> str: + return ( + "👋 Welcome to SMEBoost War Room Alerts.\n\n" + "To connect your account:\n" + "1. Open your dashboard\n" + "2. Click Connect Telegram\n" + "3. Send /connect here\n\n" + "Need help? Use /help." + ) + + @staticmethod + def _help_message() -> str: + return ( + "Commands:\n" + "/connect — link your seller account\n" + "/disconnect — unlink alerts\n" + "/status — view connection status\n" + "/help — show this message" + ) + + @staticmethod + def _unknown_command_message() -> str: + return "Command not recognized. Use /help to see available commands." diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py index 665e1fc..885d8d5 100644 --- a/backend/app/tasks/__init__.py +++ b/backend/app/tasks/__init__.py @@ -1,3 +1,4 @@ from __future__ import annotations +from app.tasks import alert_tasks # noqa: F401 from app.tasks import scraper_tasks # noqa: F401 diff --git a/backend/app/tasks/alert_tasks.py b/backend/app/tasks/alert_tasks.py new file mode 100644 index 0000000..9d08223 --- /dev/null +++ b/backend/app/tasks/alert_tasks.py @@ -0,0 +1,31 @@ +"""Background tasks for alerts.""" + +from __future__ import annotations + +import asyncio +import logging + +from app.core.celery_app import celery_app +from app.db.async_session import AsyncSessionLocal +from app.services.telegram.telegram_alert_service import ( + AlertDeliveryOutcome, + TelegramAlertDeliveryService, +) + +logger = logging.getLogger(__name__) + + +async def _deliver_alert(alert_id: str) -> AlertDeliveryOutcome: + async with AsyncSessionLocal() as session: + service = TelegramAlertDeliveryService(session, logger=logger) + return await service.deliver_alert_by_id(alert_id) + + +@celery_app.task(name="alerts.send_telegram_alert") +def send_telegram_alert(alert_id: str) -> dict[str, str | None]: + outcome = asyncio.run(_deliver_alert(alert_id)) + return { + "alert_id": outcome.alert_id, + "status": outcome.status.value, + "detail": outcome.detail, + } diff --git a/backend/app/tasks/whatsapp_tasks.py b/backend/app/tasks/whatsapp_tasks.py new file mode 100644 index 0000000..ea48439 --- /dev/null +++ b/backend/app/tasks/whatsapp_tasks.py @@ -0,0 +1 @@ +"""Background tasks for WhatsApp.""" diff --git a/backend/app/websocket/__init__.py b/backend/app/websocket/__init__.py new file mode 100644 index 0000000..b9407fe --- /dev/null +++ b/backend/app/websocket/__init__.py @@ -0,0 +1,3 @@ +from app.websocket.socket_manager import init_socketio, get_socket_emitter + +__all__ = ["get_socket_emitter", "init_socketio"] diff --git a/backend/app/websocket/connection_manager.py b/backend/app/websocket/connection_manager.py new file mode 100644 index 0000000..cf2ddcc --- /dev/null +++ b/backend/app/websocket/connection_manager.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass +class ConnectionState: + sid: str + session_id: str + rooms: set[str] = field(default_factory=set) + connected_at: datetime = field(default_factory=_utcnow) + last_seen: datetime = field(default_factory=_utcnow) + seller_id: str | None = None + + +@dataclass +class SessionState: + session_id: str + rooms: set[str] + last_seen: datetime + expires_at: datetime + seller_id: str | None = None + + +class ConnectionManager: + """Tracks active Socket.IO connections and reconnectable session state.""" + + def __init__( + self, + *, + session_ttl_seconds: int, + heartbeat_timeout_seconds: int, + logger: logging.Logger | None = None, + ) -> None: + self._session_ttl_seconds = session_ttl_seconds + self._heartbeat_timeout_seconds = heartbeat_timeout_seconds + self._logger = logger or logging.getLogger(__name__) + self._connections: dict[str, ConnectionState] = {} + self._sessions: dict[str, SessionState] = {} + self._lock = asyncio.Lock() + + async def register_connection( + self, + sid: str, + session_id: str, + *, + seller_id: str | None = None, + ) -> set[str]: + async with self._lock: + restored_rooms: set[str] = set() + previous = self._sessions.pop(session_id, None) + if previous: + restored_rooms = set(previous.rooms) + self._connections[sid] = ConnectionState( + sid=sid, + session_id=session_id, + rooms=restored_rooms, + seller_id=seller_id, + ) + return restored_rooms + + async def mark_seen(self, sid: str) -> None: + async with self._lock: + state = self._connections.get(sid) + if state: + state.last_seen = _utcnow() + + async def add_rooms(self, sid: str, rooms: set[str]) -> set[str]: + async with self._lock: + state = self._connections.get(sid) + if not state: + return set() + state.rooms.update(rooms) + return set(state.rooms) + + async def remove_rooms(self, sid: str, rooms: set[str]) -> set[str]: + async with self._lock: + state = self._connections.get(sid) + if not state: + return set() + state.rooms.difference_update(rooms) + return set(state.rooms) + + async def get_rooms(self, sid: str) -> set[str]: + async with self._lock: + state = self._connections.get(sid) + if not state: + return set() + return set(state.rooms) + + async def mark_disconnected(self, sid: str) -> SessionState | None: + async with self._lock: + state = self._connections.pop(sid, None) + if not state: + return None + expires_at = _utcnow() + timedelta(seconds=self._session_ttl_seconds) + session = SessionState( + session_id=state.session_id, + rooms=set(state.rooms), + last_seen=state.last_seen, + expires_at=expires_at, + seller_id=state.seller_id, + ) + self._sessions[state.session_id] = session + return session + + async def stale_connection_ids(self) -> list[str]: + now = _utcnow() + timeout = timedelta(seconds=self._heartbeat_timeout_seconds) + async with self._lock: + return [ + sid + for sid, state in self._connections.items() + if now - state.last_seen > timeout + ] + + async def cleanup_sessions(self) -> int: + now = _utcnow() + async with self._lock: + stale = [sid for sid, session in self._sessions.items() if session.expires_at < now] + for session_id in stale: + self._sessions.pop(session_id, None) + return len(stale) + + async def snapshot(self) -> dict[str, int]: + async with self._lock: + return { + "connections": len(self._connections), + "sessions": len(self._sessions), + } diff --git a/backend/app/websocket/socket_emitter.py b/backend/app/websocket/socket_emitter.py new file mode 100644 index 0000000..c919a46 --- /dev/null +++ b/backend/app/websocket/socket_emitter.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +import logging +import time +from typing import Iterable + +import socketio + +from app.websocket.socket_events import RealtimeEvent + + +class SocketEmitter: + """Emit structured realtime events through the Socket.IO server.""" + + def __init__(self, server: socketio.AsyncServer, logger: logging.Logger | None = None) -> None: + self._server = server + self._logger = logger or logging.getLogger(__name__) + + async def emit_event( + self, + event: RealtimeEvent, + *, + rooms: Iterable[str] | None = None, + to: str | None = None, + skip_sid: str | None = None, + ) -> None: + payload = event.model_dump(mode="json", by_alias=True) + serialized = json.dumps(payload, default=str) + target_rooms = list(rooms or []) + started = time.perf_counter() + await self._server.emit( + payload["event"], + payload, + to=to or target_rooms or None, + skip_sid=skip_sid, + ) + elapsed_ms = int((time.perf_counter() - started) * 1000) + self._logger.info( + "socket_emit event=%s rooms=%s to=%s size_bytes=%s latency_ms=%s", + payload["event"], + target_rooms, + to, + len(serialized), + elapsed_ms, + ) diff --git a/backend/app/websocket/socket_events.py b/backend/app/websocket/socket_events.py new file mode 100644 index 0000000..5f532c7 --- /dev/null +++ b/backend/app/websocket/socket_events.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import Literal +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, Field + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _to_camel(value: str) -> str: + parts = value.split("_") + return parts[0] + "".join(word.capitalize() for word in parts[1:]) + + +class CamelModel(BaseModel): + model_config = ConfigDict( + alias_generator=_to_camel, + populate_by_name=True, + extra="forbid", + ) + + +class RealtimeEventType(str, Enum): + RECOMMENDATION_CREATED = "recommendation_created" + ALERT_SENT = "alert_sent" + PRODUCT_SNAPSHOT_SENT = "product_snapshot_sent" + COMPETITOR_UPDATED = "competitor_updated" + STOCK_RISK_DETECTED = "stock_risk_detected" + URGENCY_SCORE_UPDATED = "urgency_score_updated" + DASHBOARD_SUMMARY_UPDATED = "dashboard_summary_updated" + MARKET_OPPORTUNITY_DETECTED = "market_opportunity_detected" + CONNECTION_ESTABLISHED = "connection_established" + ROOMS_UPDATED = "rooms_updated" + SERVER_PONG = "server_pong" + REALTIME_ERROR = "realtime_error" + TELEGRAM_CONNECTED = "telegram_connected" + TELEGRAM_DISCONNECTED = "telegram_disconnected" + ALERT_FAILED = "alert_failed" + + +class RealtimeEventBase(CamelModel): + event_id: UUID = Field(default_factory=uuid4) + event: RealtimeEventType + version: int = 1 + timestamp: datetime = Field(default_factory=_utcnow) + seller_id: UUID | None = None + product_id: UUID | None = None + + model_config = ConfigDict( + alias_generator=_to_camel, + populate_by_name=True, + use_enum_values=True, + extra="forbid", + ) + + +class RecommendationSnapshot(CamelModel): + id: UUID + title: str + summary: str + action: str + status: str + priority: str + confidence: float + expected_lift: float + product: str + competitor: str | None = None + created_at: datetime + + +class AlertSnapshot(CamelModel): + id: UUID + title: str + message: str + severity: str + status: str + category: str + channel: str + product: str | None = None + competitor: str | None = None + urgency_score: int + created_at: datetime + + +class CompetitorSnapshot(CamelModel): + id: UUID + product: str + competitor: str + competitor_price: float + seller_price: float + price_gap: float + stock_status: str + urgency_score: int + recommendation_action: str + trend: str + last_updated: datetime + status: str + + +class RecommendationCreatedPayload(CamelModel): + recommendation: RecommendationSnapshot + urgency_score: int + confidence: Literal["LOW", "MEDIUM", "HIGH"] + opportunity_score: float + + +class AlertSentPayload(CamelModel): + alert: AlertSnapshot + + +class ProductSnapshotSentPayload(CamelModel): + seller_id: UUID + product_id: UUID + timestamp: datetime + product_title: str | None = None + + +class CompetitorUpdatedPayload(CamelModel): + competitor: CompetitorSnapshot + + +class StockRiskDetectedPayload(CamelModel): + alert: AlertSnapshot + risk_score: int + + +class UrgencyScoreUpdatedPayload(CamelModel): + urgency_score: int + previous_score: int | None = None + delta: int | None = None + updated_at: datetime + reason: str | None = None + + +class KpiMetricPayload(CamelModel): + label: str + value: str + delta: str + trend: str + + +class InsightPayload(CamelModel): + id: str + title: str + summary: str + severity: str + + +class DashboardSummarySnapshot(CamelModel): + kpis: list[KpiMetricPayload] = Field(default_factory=list) + insights: list[InsightPayload] = Field(default_factory=list) + active_alerts: int + live_competitors: int + ai_recommendations: int + revenue_opportunities: int + urgency_score: int + system_status: str + last_synced_at: datetime + + +class DashboardSummaryPayload(CamelModel): + summary: DashboardSummarySnapshot + + +class MarketOpportunitySnapshot(CamelModel): + id: UUID + title: str + description: str + potential_revenue: float + probability: float + urgency_score: int + time_window: str + category: str + + +class MarketOpportunityPayload(CamelModel): + opportunity: MarketOpportunitySnapshot + + +class ConnectionEstablishedPayload(CamelModel): + session_id: str + sid: str + rooms: list[str] + heartbeat_interval: int + heartbeat_timeout: int + + +class RoomsUpdatedPayload(CamelModel): + rooms: list[str] + action: Literal["joined", "left", "restored"] + + +class ServerPongPayload(CamelModel): + client_timestamp: int + server_timestamp: int + latency_ms: int | None = None + + +class RealtimeErrorPayload(CamelModel): + code: str + message: str + details: dict[str, str] | None = None + + +class TelegramConnectionSnapshot(CamelModel): + id: UUID + is_connected: bool + telegram_username: str | None = None + telegram_first_name: str | None = None + telegram_last_name: str | None = None + connected_at: datetime | None = None + last_alert_sent_at: datetime | None = None + + +class TelegramConnectionPayload(CamelModel): + connection: TelegramConnectionSnapshot + + +class AlertFailedPayload(CamelModel): + alert_id: UUID + status: str + reason: str | None = None + telegram_chat_id: str | None = None + + +class RecommendationCreatedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.RECOMMENDATION_CREATED + payload: RecommendationCreatedPayload + + +class AlertSentEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.ALERT_SENT + payload: AlertSentPayload + + +class ProductSnapshotSentEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.PRODUCT_SNAPSHOT_SENT + payload: ProductSnapshotSentPayload + + +class CompetitorUpdatedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.COMPETITOR_UPDATED + payload: CompetitorUpdatedPayload + + +class StockRiskDetectedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.STOCK_RISK_DETECTED + payload: StockRiskDetectedPayload + + +class UrgencyScoreUpdatedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.URGENCY_SCORE_UPDATED + payload: UrgencyScoreUpdatedPayload + + +class DashboardSummaryUpdatedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.DASHBOARD_SUMMARY_UPDATED + payload: DashboardSummaryPayload + + +class MarketOpportunityDetectedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.MARKET_OPPORTUNITY_DETECTED + payload: MarketOpportunityPayload + + +class ConnectionEstablishedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.CONNECTION_ESTABLISHED + payload: ConnectionEstablishedPayload + + +class RoomsUpdatedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.ROOMS_UPDATED + payload: RoomsUpdatedPayload + + +class ServerPongEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.SERVER_PONG + payload: ServerPongPayload + + +class RealtimeErrorEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.REALTIME_ERROR + payload: RealtimeErrorPayload + + +class TelegramConnectedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.TELEGRAM_CONNECTED + payload: TelegramConnectionPayload + + +class TelegramDisconnectedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.TELEGRAM_DISCONNECTED + payload: TelegramConnectionPayload + + +class AlertFailedEvent(RealtimeEventBase): + event: RealtimeEventType = RealtimeEventType.ALERT_FAILED + payload: AlertFailedPayload + + +RealtimeEvent = ( + RecommendationCreatedEvent + | AlertSentEvent + | ProductSnapshotSentEvent + | CompetitorUpdatedEvent + | StockRiskDetectedEvent + | UrgencyScoreUpdatedEvent + | DashboardSummaryUpdatedEvent + | MarketOpportunityDetectedEvent + | ConnectionEstablishedEvent + | RoomsUpdatedEvent + | ServerPongEvent + | RealtimeErrorEvent + | TelegramConnectedEvent + | TelegramDisconnectedEvent + | AlertFailedEvent +) diff --git a/backend/app/websocket/socket_manager.py b/backend/app/websocket/socket_manager.py new file mode 100644 index 0000000..f977733 --- /dev/null +++ b/backend/app/websocket/socket_manager.py @@ -0,0 +1,277 @@ +"""Socket.IO lifecycle and event wiring for realtime dashboard events.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any, Literal +from uuid import uuid4 + +import socketio +from fastapi import FastAPI +from pydantic import ValidationError + +from app.core.config import settings +from app.core.socketio_config import build_socketio_resources +from app.websocket.connection_manager import ConnectionManager +from app.websocket.socket_emitter import SocketEmitter +from app.websocket.socket_events import ( + ConnectionEstablishedEvent, + ConnectionEstablishedPayload, + RealtimeErrorEvent, + RealtimeErrorPayload, + RoomsUpdatedEvent, + RoomsUpdatedPayload, + ServerPongEvent, + ServerPongPayload, +) +from app.websocket.socket_rooms import ( + ACTIVITY_ROOM, + ALERTS_ROOM, + DASHBOARD_ROOM, + RoomSubscriptionRequest, + resolve_rooms, + seller_room, +) + + +class RealtimeSocketManager: + def __init__( + self, + server: socketio.AsyncServer, + connection_manager: ConnectionManager, + emitter: SocketEmitter, + logger: logging.Logger | None = None, + ) -> None: + self._server = server + self._connections = connection_manager + self._emitter = emitter + self._logger = logger or logging.getLogger(__name__) + self._heartbeat_task: asyncio.Task | None = None + + def register_handlers(self) -> None: + @self._server.event + async def connect(sid: str, _environ: dict, auth: dict | None) -> bool: + auth = auth or {} + session_id = auth.get("session_id") or str(uuid4()) + seller_id = auth.get("seller_id") + restored_rooms = await self._connections.register_connection( + sid, session_id, seller_id=seller_id + ) + for room in restored_rooms: + await self._server.enter_room(sid, room) + await self._connections.mark_seen(sid) + await self._emit_connection_established(sid, session_id, restored_rooms) + if restored_rooms: + await self._emit_rooms_updated(sid, restored_rooms, action="restored") + self._logger.info( + "socket_connected sid=%s session_id=%s restored_rooms=%s", + sid, + session_id, + list(restored_rooms), + ) + return True + + @self._server.event + async def disconnect(sid: str) -> None: + session = await self._connections.mark_disconnected(sid) + self._logger.info( + "socket_disconnected sid=%s session_id=%s rooms=%s", + sid, + session.session_id if session else None, + list(session.rooms) if session else [], + ) + + @self._server.on("subscribe_rooms") + async def subscribe_rooms(sid: str, payload: dict | None) -> None: + await self._connections.mark_seen(sid) + try: + request = RoomSubscriptionRequest.model_validate(payload or {}) + except ValidationError as exc: + await self._emit_error( + sid, + code="invalid_payload", + message="Invalid room subscription payload.", + details={"error": str(exc)}, + ) + return + rooms = resolve_rooms(request) + for room in rooms: + await self._server.enter_room(sid, room) + active_rooms = await self._connections.add_rooms(sid, rooms) + await self._emit_rooms_updated(sid, active_rooms, action="joined") + self._logger.info("socket_rooms_joined sid=%s rooms=%s", sid, list(rooms)) + + @self._server.on("unsubscribe_rooms") + async def unsubscribe_rooms(sid: str, payload: dict | None) -> None: + await self._connections.mark_seen(sid) + try: + request = RoomSubscriptionRequest.model_validate(payload or {}) + except ValidationError as exc: + await self._emit_error( + sid, + code="invalid_payload", + message="Invalid room unsubscribe payload.", + details={"error": str(exc)}, + ) + return + rooms = resolve_rooms(request) + for room in rooms: + await self._server.leave_room(sid, room) + active_rooms = await self._connections.remove_rooms(sid, rooms) + await self._emit_rooms_updated(sid, active_rooms, action="left") + self._logger.info("socket_rooms_left sid=%s rooms=%s", sid, list(rooms)) + + @self._server.on("subscribe_war_room") + async def subscribe_war_room(sid: str, payload: dict | None) -> None: + await self._connections.mark_seen(sid) + seller_id = None + if payload: + seller_id = payload.get("sellerId") or payload.get("seller_id") + rooms = { + DASHBOARD_ROOM, + ALERTS_ROOM, + ACTIVITY_ROOM, + } + if seller_id: + rooms.add(seller_room(seller_id)) + for room in rooms: + await self._server.enter_room(sid, room) + active_rooms = await self._connections.add_rooms(sid, rooms) + await self._emit_rooms_updated(sid, active_rooms, action="joined") + self._logger.info( + "socket_war_room_joined sid=%s seller_id=%s rooms=%s", + sid, + seller_id, + list(rooms), + ) + + @self._server.on("client_ping") + async def client_ping(sid: str, payload: dict | None) -> None: + await self._connections.mark_seen(sid) + client_ts = 0 + if payload and isinstance(payload.get("timestamp"), int): + client_ts = payload["timestamp"] + server_ts = int(time.time() * 1000) + latency_ms = server_ts - client_ts if client_ts else None + event = ServerPongEvent( + payload=ServerPongPayload( + client_timestamp=client_ts, + server_timestamp=server_ts, + latency_ms=latency_ms, + ) + ) + await self._emitter.emit_event(event, to=sid) + + async def start(self) -> None: + if self._heartbeat_task: + return + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + self._logger.info("socket_heartbeat_started interval=%s", settings.socketio_heartbeat_interval) + + async def shutdown(self) -> None: + if not self._heartbeat_task: + return + self._heartbeat_task.cancel() + try: + await self._heartbeat_task + except asyncio.CancelledError: + pass + self._heartbeat_task = None + self._logger.info("socket_heartbeat_stopped") + + async def _heartbeat_loop(self) -> None: + while True: + await asyncio.sleep(settings.socketio_heartbeat_interval) + stale = await self._connections.stale_connection_ids() + for sid in stale: + await self._server.disconnect(sid) + self._logger.warning("socket_stale_disconnect sid=%s", sid) + cleaned = await self._connections.cleanup_sessions() + if cleaned: + self._logger.info("socket_session_cleanup cleaned=%s", cleaned) + + async def _emit_connection_established( + self, sid: str, session_id: str, rooms: set[str] + ) -> None: + event = ConnectionEstablishedEvent( + payload=ConnectionEstablishedPayload( + session_id=session_id, + sid=sid, + rooms=sorted(rooms), + heartbeat_interval=settings.socketio_heartbeat_interval, + heartbeat_timeout=settings.socketio_heartbeat_timeout, + ) + ) + await self._emitter.emit_event(event, to=sid) + + async def _emit_rooms_updated( + self, + sid: str, + rooms: set[str], + *, + action: Literal["joined", "left", "restored"], + ) -> None: + event = RoomsUpdatedEvent( + payload=RoomsUpdatedPayload(rooms=sorted(rooms), action=action) + ) + await self._emitter.emit_event(event, to=sid) + + async def _emit_error( + self, sid: str, *, code: str, message: str, details: dict[str, str] | None = None + ) -> None: + event = RealtimeErrorEvent( + payload=RealtimeErrorPayload(code=code, message=message, details=details) + ) + await self._emitter.emit_event(event, to=sid) + + +_socket_manager: RealtimeSocketManager | None = None +_socket_emitter: SocketEmitter | None = None + + +def init_socketio(app: FastAPI) -> RealtimeSocketManager: + global _socket_manager, _socket_emitter + if _socket_manager: + return _socket_manager + resources = build_socketio_resources(logging.getLogger("app.websocket")) + connection_manager = ConnectionManager( + session_ttl_seconds=settings.socketio_session_ttl_seconds, + heartbeat_timeout_seconds=settings.socketio_heartbeat_timeout, + logger=logging.getLogger("app.websocket.connection"), + ) + emitter = SocketEmitter(resources.server, logging.getLogger("app.websocket.emit")) + manager = RealtimeSocketManager( + resources.server, + connection_manager, + emitter, + logger=logging.getLogger("app.websocket"), + ) + manager.register_handlers() + app.mount(settings.socketio_mount_path, resources.app) + _socket_manager = manager + _socket_emitter = emitter + return manager + + +def get_socket_emitter() -> SocketEmitter: + if not _socket_emitter: + raise RuntimeError("Socket emitter not initialized. Call init_socketio() first.") + return _socket_emitter + + +class LegacySocketEmitter: + """Backward-compatible emitter for legacy DashboardEvent pipelines.""" + + def __init__(self) -> None: + self._logger = logging.getLogger("app.websocket.legacy") + + async def emit(self, event: str, payload: dict[str, Any]) -> None: + if not _socket_manager: + self._logger.debug("socket_emit_skipped event=%s reason=not_initialized", event) + return + await _socket_manager._server.emit(event, payload) + + +socket_manager = LegacySocketEmitter() diff --git a/backend/app/websocket/socket_rooms.py b/backend/app/websocket/socket_rooms.py new file mode 100644 index 0000000..68cf5c2 --- /dev/null +++ b/backend/app/websocket/socket_rooms.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class RoomScope(str, Enum): + SELLER = "seller" + PRODUCT = "product" + ALERTS = "alerts" + DASHBOARD = "dashboard" + ACTIVITY = "activity" + CUSTOM = "custom" + + +class RoomTarget(BaseModel): + scope: RoomScope + id: str | None = None + + +class RoomSubscriptionRequest(BaseModel): + rooms: list[RoomTarget] = Field(default_factory=list) + + +ALERTS_ROOM = "alerts" +DASHBOARD_ROOM = "dashboard" +ACTIVITY_ROOM = "activity" + + +def seller_room(seller_id: str) -> str: + return f"seller:{seller_id}" + + +def product_room(product_id: str) -> str: + return f"product:{product_id}" + + +def resolve_room(target: RoomTarget) -> str | None: + if target.scope is RoomScope.SELLER and target.id: + return seller_room(target.id) + if target.scope is RoomScope.PRODUCT and target.id: + return product_room(target.id) + if target.scope is RoomScope.ALERTS: + return ALERTS_ROOM + if target.scope is RoomScope.DASHBOARD: + return DASHBOARD_ROOM + if target.scope is RoomScope.ACTIVITY: + return ACTIVITY_ROOM + if target.scope is RoomScope.CUSTOM and target.id: + return target.id + return None + + +def resolve_rooms(request: RoomSubscriptionRequest) -> set[str]: + rooms: set[str] = set() + for target in request.rooms: + resolved = resolve_room(target) + if resolved: + rooms.add(resolved) + return rooms diff --git a/backend/requirements.txt b/backend/requirements.txt index 84d98ed..fcb635a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,16 @@ +aiohappyeyeballs==2.6.2 +aiohttp==3.13.5 +aiosignal==1.4.0 alembic==1.18.4 amqp==5.3.1 annotated-doc==0.0.4 annotated-types==0.7.0 +asyncpg==0.30.0 anyio==4.13.0 -bcrypt==5.0.0 +attrs==26.1.0 +bcrypt==3.2.2 beautifulsoup4==4.14.3 +bidict==0.23.1 billiard==4.2.4 cachetools==6.2.6 celery==5.6.3 @@ -23,7 +29,9 @@ ecdsa==0.19.2 email-validator==2.3.0 fake-useragent==2.2.0 fastapi==0.136.3 +fastapi-socketio==0.0.10 filelock==3.29.0 +frozenlist==1.8.0 fsspec==2026.4.0 greenlet==3.5.1 groq==1.2.0 @@ -60,6 +68,7 @@ psycopg2-binary==2.9.12 pyasn1==0.6.3 pycparser==3.0 pydantic==2.13.4 +pydantic-settings==2.14.1 pydantic_core==2.46.4 pyee==13.0.1 Pygments==2.20.0 @@ -69,8 +78,11 @@ pyparsing==3.3.2 pyroaring==1.1.0 python-dateutil==2.9.0.post0 python-dotenv==1.2.2 +python-engineio==4.13.2 python-jose==3.5.0 python-multipart==0.0.29 +python-socketio==5.16.2 +python-telegram-bot==22.7 PyYAML==6.0.3 realtime==2.30.0 redis==7.4.0 @@ -84,6 +96,7 @@ scipy==1.17.1 sentence-transformers==5.5.1 setuptools==81.0.0 shellingham==1.5.4 +simple-websocket==1.1.0 six==1.17.0 sniffio==1.3.1 soupsieve==2.8.4 @@ -112,5 +125,6 @@ uvicorn==0.48.0 vine==5.1.0 wcwidth==0.7.0 websockets==15.0.1 +wsproto==1.3.2 yarl==1.24.2 zstandard==0.25.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8a02e8f..a4fedcb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "@fontsource-variable/geist": "^5.2.9", - "@tanstack/react-query": "^5.100.11", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-table": "^8.21.3", "axios": "^1.16.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -19,7 +20,8 @@ "react-dom": "^19.2.6", "react-router-dom": "^7.15.1", "recharts": "^3.8.1", - "shadcn": "^4.7.0", + "shadcn": "^4.8.0", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, @@ -28,17 +30,17 @@ "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.5.0", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", - "postcss": "^8.5.14", + "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.13" + "vite": "^8.0.12" } }, "node_modules/@alloc/quick-lru": { @@ -55,12 +57,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -108,13 +110,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -124,12 +126,12 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -152,17 +154,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", - "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.29.0", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -173,49 +175,49 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -225,35 +227,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -263,40 +265,40 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -316,12 +318,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -331,12 +333,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -346,12 +348,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -361,13 +363,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -377,16 +379,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -396,16 +398,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -415,31 +417,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -447,27 +449,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@dotenvx/dotenvx": { - "version": "1.66.0", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.66.0.tgz", - "integrity": "sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw==", + "version": "1.69.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.69.1.tgz", + "integrity": "sha512-kwQB5KcAegxw/+NGUgXAo5ovyOSjlMhoXSSnSEpDhoHJwzMcMO0HE1U0VCYZ7jbAeCMGamed9XdWzOA5ixtTNg==", "license": "BSD-3-Clause", "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", + "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", @@ -916,22 +919,22 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", - "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.6.tgz", + "integrity": "sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/confirm": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz", - "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.0.tgz", + "integrity": "sha512-USpeB76eqK7yGricDlGAupxWlp4a59qpeZOoNWaxO/nJln7agpJveyNkQ1d5u8YXG6TOqxZtQpKPORQQDrdVsA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -946,17 +949,17 @@ } }, "node_modules/@inquirer/core": { - "version": "11.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", - "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.0.tgz", + "integrity": "sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5", + "@inquirer/ansi": "^2.0.6", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", + "mute-stream": "^4.0.0", "signal-exit": "^4.1.0" }, "engines": { @@ -972,18 +975,18 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", - "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.6.tgz", + "integrity": "sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/type": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", - "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.6.tgz", + "integrity": "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -3086,6 +3089,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3099,9 +3108,9 @@ "license": "MIT" }, "node_modules/@tanstack/query-core": { - "version": "5.100.11", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz", - "integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==", + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", "license": "MIT", "funding": { "type": "github", @@ -3109,12 +3118,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.11", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz", - "integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==", + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.11" + "@tanstack/query-core": "5.100.14" }, "funding": { "type": "github", @@ -3124,6 +3133,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -3684,16 +3726,22 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { @@ -4156,15 +4204,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4185,18 +4224,6 @@ "node": ">=8" } }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4728,6 +4755,41 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz", + "integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4765,9 +4827,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4792,9 +4854,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", "license": "MIT", "workspaces": [ "docs", @@ -5261,9 +5323,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-wrap-ansi": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", - "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "license": "MIT", "dependencies": { "fast-string-width": "^3.0.2" @@ -5771,9 +5833,9 @@ } }, "node_modules/hono": { - "version": "4.12.19", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", - "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6819,12 +6881,12 @@ } }, "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-4.0.0.tgz", + "integrity": "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/mz": { @@ -7092,6 +7154,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -7267,9 +7356,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7286,7 +7375,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8196,9 +8285,9 @@ "license": "ISC" }, "node_modules/shadcn": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.7.0.tgz", - "integrity": "sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.8.0.tgz", + "integrity": "sha512-LAm3I/1FdoU/zu5GVG8Hbna4X9zlzEG5TeeCPXqsopkjvGk8QUF9OFhqeRN8oM6Oh/ynUI/yQHZxQAO3Ymcqsg==", "license": "MIT", "dependencies": { "@babel/core": "^7.28.0", @@ -8404,6 +8493,34 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8466,6 +8583,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/stringify-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", @@ -8484,18 +8628,15 @@ } }, "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/strip-bom": { @@ -8662,21 +8803,21 @@ } }, "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.30" + "tldts-core": "^7.4.0" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", + "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", "license": "MIT" }, "node_modules/to-regex-range": { @@ -9205,15 +9346,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -9234,24 +9366,33 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -9268,6 +9409,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -9310,15 +9459,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -9339,18 +9479,6 @@ "node": ">=8" } }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 87af632..2dd57ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@fontsource-variable/geist": "^5.2.9", - "@tanstack/react-query": "^5.100.11", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-table": "^8.21.3", "axios": "^1.16.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -21,7 +22,8 @@ "react-dom": "^19.2.6", "react-router-dom": "^7.15.1", "recharts": "^3.8.1", - "shadcn": "^4.7.0", + "shadcn": "^4.8.0", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, @@ -30,16 +32,16 @@ "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.5.0", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", - "postcss": "^8.5.14", + "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.13" + "vite": "^8.0.12" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a117e2..0047069 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,18 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { Suspense } from "react" +import { QueryClientProvider } from "@tanstack/react-query" import { RouterProvider } from "react-router-dom" -import { LoadingState } from "@/components/common/loading-state" import { AuthProvider } from "@/context/auth-provider" +import { queryClient } from "@/lib/query-client" import router from "@/routes" -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - retry: 1, - }, - }, -}) - -export default function App() { +function App() { return ( - }> - - + ) } + +export default App diff --git a/frontend/src/components/app/app-header.tsx b/frontend/src/components/app/app-header.tsx index 34f1b22..3a2c97a 100644 --- a/frontend/src/components/app/app-header.tsx +++ b/frontend/src/components/app/app-header.tsx @@ -1,13 +1,15 @@ import { useMemo } from "react" import { NavLink, useLocation } from "react-router-dom" -import { Button } from "@/components/ui/button" import UserMenu from "@/components/app/user-menu" +import { ComingSoonModal } from "@/components/common/coming-soon-modal" +import { useComingSoon } from "@/hooks/useComingSoon" import { appNavItems } from "@/routes/nav-config" import { cn } from "@/lib/utils" export default function AppHeader() { const location = useLocation() + const comingSoon = useComingSoon() const activeItem = useMemo( () => @@ -28,10 +30,6 @@ export default function AppHeader() {
- -
@@ -40,6 +38,7 @@ export default function AppHeader() { comingSoon.handleNavClick(event, item)} className={({ isActive }) => cn( "whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition-colors", @@ -53,6 +52,7 @@ export default function AppHeader() { ))} + ) } diff --git a/frontend/src/components/app/app-sidebar.tsx b/frontend/src/components/app/app-sidebar.tsx index e0d330f..93744bb 100644 --- a/frontend/src/components/app/app-sidebar.tsx +++ b/frontend/src/components/app/app-sidebar.tsx @@ -1,36 +1,44 @@ import { NavLink } from "react-router-dom" +import { ComingSoonModal } from "@/components/common/coming-soon-modal" +import { useComingSoon } from "@/hooks/useComingSoon" import { appNavItems } from "@/routes/nav-config" import { cn } from "@/lib/utils" export default function AppSidebar() { + const comingSoon = useComingSoon() + return ( - + <> + + + ) } diff --git a/frontend/src/components/common/coming-soon-modal.tsx b/frontend/src/components/common/coming-soon-modal.tsx new file mode 100644 index 0000000..dfb0cce --- /dev/null +++ b/frontend/src/components/common/coming-soon-modal.tsx @@ -0,0 +1,74 @@ +import { useEffect } from "react" +import { Sparkles, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +type ComingSoonModalProps = { + open: boolean + onClose: () => void + className?: string +} + +export function ComingSoonModal({ open, onClose, className }: ComingSoonModalProps) { + useEffect(() => { + if (!open) { + return + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose() + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [onClose, open]) + + if (!open) { + return null + } + + return ( +
+ + ) +} diff --git a/frontend/src/components/dashboard/alert-history-panel.tsx b/frontend/src/components/dashboard/alert-history-panel.tsx new file mode 100644 index 0000000..0144624 --- /dev/null +++ b/frontend/src/components/dashboard/alert-history-panel.tsx @@ -0,0 +1,123 @@ +import { CheckCircle2, Clock } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { ErrorState } from "@/components/common/error-state" +import { cn } from "@/lib/utils" +import { AlertStatus, type Alert } from "@/types/alert" + +type AlertHistoryPanelProps = { + alerts?: Alert[] + isLoading: boolean + isError: boolean + onRetry: () => void + onResolve: (alertId: string) => void + resolvingId?: string | null +} + +const statusStyles: Record = { + [AlertStatus.New]: "bg-rose-100 text-rose-700", + [AlertStatus.Acknowledged]: "bg-amber-100 text-amber-700", + [AlertStatus.Resolved]: "bg-emerald-100 text-emerald-700", +} + +export function AlertHistoryPanel({ + alerts, + isLoading, + isError, + onRetry, + onResolve, + resolvingId, +}: AlertHistoryPanelProps) { + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + if (isError) { + return ( + + Retry + + } + /> + ) + } + + const history = alerts ?? [] + + return ( +
+
+

Alert history

+ + {history.length} total + +
+ {history.length === 0 ? ( +
+ Alerts will appear here once market risks are detected. +
+ ) : ( +
+ {history.map((alert) => ( +
+
+
+

+ {alert.title} +

+

+ {alert.message} +

+
+ + {alert.status.replace("_", " ")} + +
+
+ + + {new Date(alert.createdAt).toLocaleString()} + + {alert.status !== AlertStatus.Resolved ? ( + + ) : null} +
+
+ ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/competitor-price-chart.tsx b/frontend/src/components/dashboard/competitor-price-chart.tsx new file mode 100644 index 0000000..718bd58 --- /dev/null +++ b/frontend/src/components/dashboard/competitor-price-chart.tsx @@ -0,0 +1,118 @@ +import { + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + Legend, +} from "recharts" + +import { Button } from "@/components/ui/button" +import { ErrorState } from "@/components/common/error-state" +import type { CompetitorPricePoint } from "@/types/competitor" +import { formatCurrency } from "@/utils/formatters" + +type CompetitorPriceChartProps = { + data?: CompetitorPricePoint[] + isLoading: boolean + isError: boolean + onRetry: () => void +} + +export function CompetitorPriceChart({ + data, + isLoading, + isError, + onRetry, +}: CompetitorPriceChartProps) { + if (isLoading) { + return ( +
+
+
+
+ ) + } + + if (isError) { + return ( + + Retry + + } + /> + ) + } + + if (!data || data.length === 0) { + return ( +
+

+ Competitor price trend +

+
+ No price history yet. Once competitor data streams in, trends will + appear here. +
+
+ ) + } + + return ( +
+

+ Competitor price trend +

+

+ Live pricing movements across top competitors. +

+
+ + + + formatCurrency(Number(value))} + width={72} + /> + formatCurrency(Number(value))} + labelFormatter={(label) => `Time: ${label}`} + /> + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/competitor-table.tsx b/frontend/src/components/dashboard/competitor-table.tsx new file mode 100644 index 0000000..5752fce --- /dev/null +++ b/frontend/src/components/dashboard/competitor-table.tsx @@ -0,0 +1,340 @@ +import { useMemo, useState } from "react" +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import type { ColumnDef, SortingState } from "@tanstack/react-table" +import { ArrowDown, ArrowUp, Search } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { ErrorState } from "@/components/common/error-state" +import { cn } from "@/lib/utils" +import { + CompetitorTrend, + StockStatus, + type Competitor, +} from "@/types/competitor" +import { formatCurrency, formatNumber } from "@/utils/formatters" + +type CompetitorTableProps = { + competitors?: Competitor[] + isLoading: boolean + isError: boolean + onRetry: () => void +} + +const stockTone: Record = { + [StockStatus.InStock]: "bg-emerald-100 text-emerald-700", + [StockStatus.LowStock]: "bg-amber-100 text-amber-700", + [StockStatus.OutOfStock]: "bg-rose-100 text-rose-700", +} + +const trendTone: Record = { + [CompetitorTrend.Up]: "text-rose-600", + [CompetitorTrend.Down]: "text-emerald-600", + [CompetitorTrend.Flat]: "text-muted-foreground", +} + +export function CompetitorTable({ + competitors, + isLoading, + isError, + onRetry, +}: CompetitorTableProps) { + const [globalFilter, setGlobalFilter] = useState("") + const [sorting, setSorting] = useState([ + { id: "urgencyScore", desc: true }, + ]) + + const data = competitors ?? [] + + const columns = useMemo[]>( + () => [ + { + accessorKey: "product", + header: "Product", + cell: ({ row }) => ( +
+
+ {row.original.product} +
+
+ {row.original.status.replace("_", " ")} +
+
+ ), + }, + { + accessorKey: "competitor", + header: "Competitor", + cell: ({ row }) => ( +
+ {row.original.competitor} +
+ ), + }, + { + accessorKey: "competitorPrice", + header: "Competitor price", + cell: ({ row }) => ( +
+ {formatCurrency(row.original.competitorPrice)} +
+ ), + }, + { + accessorKey: "sellerPrice", + header: "Seller price", + cell: ({ row }) => ( +
+ {formatCurrency(row.original.sellerPrice)} +
+ ), + }, + { + accessorKey: "priceGap", + header: "Price gap", + cell: ({ row }) => { + const gap = row.original.priceGap + const tone = gap >= 0 ? "text-rose-600" : "text-emerald-600" + return ( +
+ {gap >= 0 ? "+" : ""} + {formatCurrency(gap)} +
+ ) + }, + }, + { + accessorKey: "stockStatus", + header: "Stock", + cell: ({ row }) => ( + + {row.original.stockStatus.replace("_", " ")} + + ), + }, + { + accessorKey: "urgencyScore", + header: "Urgency", + cell: ({ row }) => ( +
+
+
+
+ + {formatNumber(row.original.urgencyScore)} + +
+ ), + }, + { + accessorKey: "recommendationAction", + header: "Recommendation", + cell: ({ row }) => ( +
+ {row.original.recommendationAction} +
+ ), + }, + { + accessorKey: "trend", + header: "Trend", + cell: ({ row }) => ( +
+ {row.original.trend.toUpperCase()} +
+ ), + }, + ], + [] + ) + + const table = useReactTable({ + data, + columns, + state: { + globalFilter, + sorting, + }, + onGlobalFilterChange: setGlobalFilter, + onSortingChange: setSorting, + globalFilterFn: (row, _columnId, filterValue) => { + const search = String(filterValue).toLowerCase() + return ( + row.original.product.toLowerCase().includes(search) || + row.original.competitor.toLowerCase().includes(search) + ) + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + if (isError) { + return ( + + Retry + + } + /> + ) + } + + return ( +
+
+
+

+ Competitor monitoring +

+

+ {formatNumber(data.length)} competitor listings tracked +

+
+
+ + setGlobalFilter(event.target.value)} + placeholder="Search product or competitor" + className="pl-9" + aria-label="Search competitors" + /> +
+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort() + const sort = header.column.getIsSorted() + return ( + + ) + })} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => { + const isCritical = + row.original.urgencyScore >= 80 || + row.original.stockStatus === StockStatus.OutOfStock + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ) + }) + )} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {sort === "asc" ? ( + + ) : sort === "desc" ? ( + + ) : null} +
+
+ No competitors found for this search. +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/dashboard-summary-header.tsx b/frontend/src/components/dashboard/dashboard-summary-header.tsx new file mode 100644 index 0000000..0b354d5 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-summary-header.tsx @@ -0,0 +1,150 @@ +import { RefreshCcw } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { LiveStatusIndicator } from "@/components/dashboard/live-status-indicator" +import { cn } from "@/lib/utils" +import { + KpiTrend, + type DashboardSummary, + type RealtimeStatus, +} from "@/types/dashboard" +import { formatNumber } from "@/utils/formatters" + +type DashboardSummaryHeaderProps = { + summary?: DashboardSummary + isLoading: boolean + isRefreshing: boolean + realtimeStatus: RealtimeStatus + onRefresh: () => void +} + +const trendStyles: Record = { + [KpiTrend.Up]: "text-emerald-600", + [KpiTrend.Down]: "text-rose-600", + [KpiTrend.Flat]: "text-muted-foreground", +} + +export function DashboardSummaryHeader({ + summary, + isLoading, + isRefreshing, + realtimeStatus, + onRefresh, +}: DashboardSummaryHeaderProps) { + if (isLoading && !summary) { + return ( +
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + const metrics = + summary?.kpis.length + ? summary.kpis + : summary + ? [ + { + label: "Active alerts", + value: formatNumber(summary.activeAlerts), + delta: "Realtime", + trend: KpiTrend.Up, + }, + { + label: "Live competitors", + value: formatNumber(summary.liveCompetitors), + delta: "Tracking", + trend: KpiTrend.Flat, + }, + { + label: "AI recommendations", + value: formatNumber(summary.aiRecommendations), + delta: "Today", + trend: KpiTrend.Up, + }, + { + label: "Revenue opportunities", + value: formatNumber(summary.revenueOpportunities), + delta: "This week", + trend: KpiTrend.Up, + }, + ] + : [] + + return ( +
+
+
+

+ War Room +

+

+ Competitor intelligence command center +

+

+ Live pricing, urgency, and recommendation signals for BazarSync-AI + sellers. +

+
+
+ + +
+
+ +
+ {metrics.map((metric) => ( +
+

{metric.label}

+
+ {metric.value} +
+

+ {metric.delta} +

+
+ ))} +
+ + {summary?.insights?.length ? ( +
+ {summary.insights.slice(0, 2).map((insight) => ( +
+
+ {insight.title} +
+

+ {insight.summary} +

+
+ ))} +
+ ) : null} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/live-status-indicator.tsx b/frontend/src/components/dashboard/live-status-indicator.tsx new file mode 100644 index 0000000..6e57b62 --- /dev/null +++ b/frontend/src/components/dashboard/live-status-indicator.tsx @@ -0,0 +1,73 @@ +import { Activity, RefreshCcw, WifiOff } from "lucide-react" + +import { cn } from "@/lib/utils" +import type { RealtimeStatus } from "@/types/dashboard" + +type LiveStatusIndicatorProps = { + status: RealtimeStatus + className?: string +} + +const formatRelativeTime = (value: string | null) => { + if (!value) { + return "Waiting for events" + } + const now = new Date() + const date = new Date(value) + const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000) + const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }) + + if (Math.abs(diffSeconds) < 60) { + return rtf.format(diffSeconds, "second") + } + const diffMinutes = Math.round(diffSeconds / 60) + if (Math.abs(diffMinutes) < 60) { + return rtf.format(diffMinutes, "minute") + } + const diffHours = Math.round(diffMinutes / 60) + return rtf.format(diffHours, "hour") +} + +export function LiveStatusIndicator({ status, className }: LiveStatusIndicatorProps) { + const isLive = status.connected + const isReconnecting = status.reconnecting + const label = isLive + ? "Live sync" + : isReconnecting + ? "Reconnecting" + : "Offline" + const Icon = isLive ? Activity : isReconnecting ? RefreshCcw : WifiOff + + return ( +
+
+ ) +} diff --git a/frontend/src/components/dashboard/market-opportunity-card.tsx b/frontend/src/components/dashboard/market-opportunity-card.tsx new file mode 100644 index 0000000..4effa6d --- /dev/null +++ b/frontend/src/components/dashboard/market-opportunity-card.tsx @@ -0,0 +1,71 @@ +import { Sparkles } from "lucide-react" + +import { cn } from "@/lib/utils" +import type { MarketOpportunity } from "@/types/dashboard" +import { formatCurrency, formatNumber, formatPercent } from "@/utils/formatters" + +type MarketOpportunityCardProps = { + opportunity: MarketOpportunity +} + +const urgencyTone = (score: number) => { + if (score >= 80) return "text-rose-600 bg-rose-50" + if (score >= 60) return "text-amber-600 bg-amber-50" + return "text-emerald-600 bg-emerald-50" +} + +export function MarketOpportunityCard({ opportunity }: MarketOpportunityCardProps) { + const probability = + opportunity.probability <= 1 + ? opportunity.probability * 100 + : opportunity.probability + + return ( +
+
+
+

+ {opportunity.category.toUpperCase()} +

+

+ {opportunity.title} +

+

+ {opportunity.description} +

+
+
+ +
+
+
+
+ Potential revenue + + {formatCurrency(opportunity.potentialRevenue)} + +
+
+ Win probability + + {formatPercent(probability)} + +
+
+ Time window + + {opportunity.timeWindow} + +
+
+
+ Urgency {formatNumber(opportunity.urgencyScore)} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/market-trend-widget.tsx b/frontend/src/components/dashboard/market-trend-widget.tsx new file mode 100644 index 0000000..8afec93 --- /dev/null +++ b/frontend/src/components/dashboard/market-trend-widget.tsx @@ -0,0 +1,104 @@ +import { + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + Legend, +} from "recharts" + +import { Button } from "@/components/ui/button" +import { ErrorState } from "@/components/common/error-state" +import type { MarketTrendPoint } from "@/types/dashboard" + +type MarketTrendWidgetProps = { + data?: MarketTrendPoint[] + isLoading: boolean + isError: boolean + onRetry: () => void +} + +export function MarketTrendWidget({ + data, + isLoading, + isError, + onRetry, +}: MarketTrendWidgetProps) { + if (isLoading) { + return ( +
+
+
+
+ ) + } + + if (isError) { + return ( + + Retry + + } + /> + ) + } + + if (!data || data.length === 0) { + return ( +
+

Market trends

+
+ Trend signals will appear once market intelligence starts streaming. +
+
+ ) + } + + return ( +
+

Market trends

+

+ Demand, urgency, and opportunity indices. +

+
+ + + + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/components/dashboard/opportunity-metrics-panel.tsx b/frontend/src/components/dashboard/opportunity-metrics-panel.tsx new file mode 100644 index 0000000..aabb92c --- /dev/null +++ b/frontend/src/components/dashboard/opportunity-metrics-panel.tsx @@ -0,0 +1,68 @@ +import { cn } from "@/lib/utils" +import { KpiTrend, type OpportunityMetric } from "@/types/dashboard" + +type OpportunityMetricsPanelProps = { + metrics?: OpportunityMetric[] + isLoading: boolean +} + +const trendStyles: Record = { + [KpiTrend.Up]: "text-emerald-600", + [KpiTrend.Down]: "text-rose-600", + [KpiTrend.Flat]: "text-muted-foreground", +} + +export function OpportunityMetricsPanel({ + metrics, + isLoading, +}: OpportunityMetricsPanelProps) { + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + const items = metrics ?? [] + + return ( +
+

+ Opportunity metrics +

+ {items.length === 0 ? ( +
+ Opportunity metrics will appear when AI signals are available. +
+ ) : ( +
+ {items.map((metric) => ( +
+

{metric.label}

+

+ {metric.value} +

+

+ {metric.delta} +

+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard/realtime-feed.tsx b/frontend/src/components/dashboard/realtime-feed.tsx new file mode 100644 index 0000000..68ea31f --- /dev/null +++ b/frontend/src/components/dashboard/realtime-feed.tsx @@ -0,0 +1,115 @@ +import { Activity, Bell, Bot, Server } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { ErrorState } from "@/components/common/error-state" +import { ActivitySeverity, type ActivityItem } from "@/types/dashboard" +import { cn } from "@/lib/utils" + +type RealtimeActivityFeedProps = { + items?: ActivityItem[] + isLoading: boolean + isError: boolean + onRetry: () => void +} + +const categoryIcons: Record = { + recommendation: Bot, + alert: Bell, + competitor: Activity, + system: Server, +} + +const severityTone: Record = { + [ActivitySeverity.Info]: "text-emerald-500", + [ActivitySeverity.Warning]: "text-amber-500", + [ActivitySeverity.Critical]: "text-rose-500", +} + +export function RealtimeActivityFeed({ + items, + isLoading, + isError, + onRetry, +}: RealtimeActivityFeedProps) { + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + if (isError) { + return ( + + Retry + + } + /> + ) + } + + const feed = items ?? [] + + return ( +
+
+

+ Realtime activity +

+ + {feed.length} signals + +
+ {feed.length === 0 ? ( +
+ Waiting for live events from the BazarSync-AI engine. +
+ ) : ( +
+ {feed.map((item) => { + const Icon = categoryIcons[item.category] + return ( +
+ +
+
+

+ {item.title} +

+ + {new Date(item.timestamp).toLocaleTimeString()} + +
+

+ {item.description} +

+
+
+ ) + })} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/recommendation-card.tsx b/frontend/src/components/dashboard/recommendation-card.tsx new file mode 100644 index 0000000..00099d3 --- /dev/null +++ b/frontend/src/components/dashboard/recommendation-card.tsx @@ -0,0 +1,111 @@ +import { CheckCircle2, Sparkles } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + RecommendationAction, + RecommendationPriority, + RecommendationStatus, + type Recommendation, +} from "@/types/recommendation" +import { formatPercent } from "@/utils/formatters" + +type RecommendationCardProps = { + recommendation: Recommendation +} + +const priorityStyles: Record = { + [RecommendationPriority.Low]: "bg-muted text-muted-foreground", + [RecommendationPriority.Medium]: "bg-amber-100 text-amber-700", + [RecommendationPriority.High]: "bg-rose-100 text-rose-700", + [RecommendationPriority.Critical]: "bg-rose-200 text-rose-800", +} + +const statusStyles: Record = { + [RecommendationStatus.New]: "bg-emerald-100 text-emerald-700", + [RecommendationStatus.Reviewed]: "bg-blue-100 text-blue-700", + [RecommendationStatus.Implemented]: "bg-indigo-100 text-indigo-700", + [RecommendationStatus.Dismissed]: "bg-muted text-muted-foreground", +} + +const actionLabels: Record = { + [RecommendationAction.RaisePrice]: "Raise price", + [RecommendationAction.MatchPrice]: "Match competitor", + [RecommendationAction.Undercut]: "Undercut", + [RecommendationAction.Bundle]: "Bundle offer", + [RecommendationAction.Restock]: "Restock", + [RecommendationAction.Hold]: "Hold price", +} + +export function RecommendationCard({ recommendation }: RecommendationCardProps) { + const confidence = + recommendation.confidence <= 1 + ? recommendation.confidence * 100 + : recommendation.confidence + const expectedLift = + recommendation.expectedLift <= 1 + ? recommendation.expectedLift * 100 + : recommendation.expectedLift + + return ( +
+
+
+

+ {recommendation.product} +

+

+ {recommendation.title} +

+

+ {recommendation.summary} +

+
+
+ +
+
+
+ + {recommendation.priority.toUpperCase()} + + + {recommendation.status.replace("_", " ")} + +
+
+
+ Recommended action + + {actionLabels[recommendation.action]} + +
+
+ Confidence + + {formatPercent(confidence)} + +
+
+ Expected lift + + {formatPercent(expectedLift)} + +
+
+
+ + Updated {new Date(recommendation.createdAt).toLocaleString()} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/stock-alert-card.tsx b/frontend/src/components/dashboard/stock-alert-card.tsx new file mode 100644 index 0000000..f45c1b4 --- /dev/null +++ b/frontend/src/components/dashboard/stock-alert-card.tsx @@ -0,0 +1,178 @@ +import { + AlertTriangle, + CheckCircle2, + Flame, + ShieldAlert, +} from "lucide-react" +import { + Bar, + BarChart, + ResponsiveContainer, + Tooltip, + XAxis, +} from "recharts" + +import { Button } from "@/components/ui/button" +import { ErrorState } from "@/components/common/error-state" +import { cn } from "@/lib/utils" +import { AlertCategory, AlertSeverity, type Alert } from "@/types/alert" +import { formatNumber } from "@/utils/formatters" + +type StockAlertCardProps = { + alerts?: Alert[] + isLoading: boolean + isError: boolean + onRetry: () => void +} + +const severityIcons = { + [AlertSeverity.Low]: CheckCircle2, + [AlertSeverity.Medium]: AlertTriangle, + [AlertSeverity.High]: ShieldAlert, + [AlertSeverity.Critical]: Flame, +} + +const severityTone = { + [AlertSeverity.Low]: "text-emerald-600", + [AlertSeverity.Medium]: "text-amber-600", + [AlertSeverity.High]: "text-rose-600", + [AlertSeverity.Critical]: "text-rose-700", +} + +const severityLabel: Record = { + [AlertSeverity.Low]: "Low", + [AlertSeverity.Medium]: "Medium", + [AlertSeverity.High]: "High", + [AlertSeverity.Critical]: "Critical", +} + +export function StockAlertCard({ + alerts, + isLoading, + isError, + onRetry, +}: StockAlertCardProps) { + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + if (isError) { + return ( + + Retry + + } + /> + ) + } + + const stockAlerts = + alerts?.filter((alert) => alert.category === AlertCategory.StockRisk) ?? [] + const topAlerts = stockAlerts.slice(0, 3) + + const chartData = [ + { + name: "Low", + count: stockAlerts.filter((alert) => alert.severity === AlertSeverity.Low) + .length, + }, + { + name: "Med", + count: stockAlerts.filter( + (alert) => alert.severity === AlertSeverity.Medium + ).length, + }, + { + name: "High", + count: stockAlerts.filter((alert) => alert.severity === AlertSeverity.High) + .length, + }, + { + name: "Crit", + count: stockAlerts.filter( + (alert) => alert.severity === AlertSeverity.Critical + ).length, + }, + ] + + return ( +
+
+
+

+ Stock risk alerts +

+

+ {formatNumber(stockAlerts.length)} active stock risk signals +

+
+
+ + {stockAlerts.length === 0 ? ( +
+ No stock risks detected. Inventory is stable. +
+ ) : ( + <> +
+ + + + + + + +
+
+ {topAlerts.map((alert) => { + const Icon = severityIcons[alert.severity] + return ( +
+
+ +
+

+ {alert.title} +

+

+ {alert.message} +

+ + {severityLabel[alert.severity]} ·{" "} + {new Date(alert.createdAt).toLocaleTimeString()} + +
+
+
+ ) + })} +
+ + )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/urgency-score-widget.tsx b/frontend/src/components/dashboard/urgency-score-widget.tsx new file mode 100644 index 0000000..2253002 --- /dev/null +++ b/frontend/src/components/dashboard/urgency-score-widget.tsx @@ -0,0 +1,58 @@ +import { Flame } from "lucide-react" + +import { cn } from "@/lib/utils" +import { formatNumber } from "@/utils/formatters" + +type UrgencyScoreWidgetProps = { + score?: number + isLoading: boolean +} + +const scoreTone = (score: number) => { + if (score >= 80) return "text-rose-600" + if (score >= 60) return "text-amber-600" + return "text-emerald-600" +} + +export function UrgencyScoreWidget({ + score = 0, + isLoading, +}: UrgencyScoreWidgetProps) { + if (isLoading) { + return ( +
+
+
+
+ ) + } + + return ( +
+
+ +

+ Urgency score +

+
+
+
+ {formatNumber(score)} +
+
+ {score >= 80 + ? "Critical actions required" + : score >= 60 + ? "Elevated competitive risk" + : "Stable competitive posture"} +
+
+
+
+
+
+ ) +} diff --git a/frontend/src/components/telegram/product-selector.tsx b/frontend/src/components/telegram/product-selector.tsx new file mode 100644 index 0000000..69abde3 --- /dev/null +++ b/frontend/src/components/telegram/product-selector.tsx @@ -0,0 +1,154 @@ +import { useMemo, useState } from "react" +import { ChevronDown, PackageSearch } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" +import type { Product } from "@/types/product" +import { formatCurrency, formatNumber } from "@/utils/formatters" + +type ProductSelectorProps = { + products?: Product[] + value?: string | null + onChange: (productId: string | null) => void + isLoading?: boolean + isError?: boolean + disabled?: boolean +} + +export function ProductSelector({ + products, + value, + onChange, + isLoading = false, + isError = false, + disabled = false, +}: ProductSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + const [query, setQuery] = useState("") + const selected = products?.find((item) => item.id === value) ?? null + + const filtered = useMemo(() => { + if (!products) { + return [] + } + const trimmed = query.trim().toLowerCase() + if (!trimmed) { + return products + } + return products.filter((product) => + product.title.toLowerCase().includes(trimmed) + ) + }, [products, query]) + + const handleSelect = (productId: string) => { + onChange(productId) + setIsOpen(false) + } + + const emptyLabel = + products && products.length === 0 + ? "No products found. Add a product first." + : "No matches found." + + return ( +
+
+ Product selector + {selected ? ( + Selected + ) : null} +
+
+ + + {isOpen ? ( +
+
+ setQuery(event.target.value)} + disabled={disabled || isLoading} + /> +
+
+ {isLoading ? ( +
+ {[0, 1, 2].map((item) => ( +
+ ))} +
+ ) : isError ? ( +
+ Unable to load products right now. +
+ ) : filtered.length === 0 ? ( +
+ + {emptyLabel} +
+ ) : ( +
+ {filtered.map((product) => ( + + ))} +
+ )} +
+
+ ) : null} +
+ {!isOpen && isError ? ( +

+ Unable to load products right now. +

+ ) : null} +
+ ) +} diff --git a/frontend/src/components/telegram/product-snapshot-card.tsx b/frontend/src/components/telegram/product-snapshot-card.tsx new file mode 100644 index 0000000..b9e1b20 --- /dev/null +++ b/frontend/src/components/telegram/product-snapshot-card.tsx @@ -0,0 +1,226 @@ +import { useEffect, useMemo, useState } from "react" +import { CheckCircle2, ShieldAlert, Sparkles, XCircle } from "lucide-react" + +import { ProductSelector } from "@/components/telegram/product-selector" +import { SendSnapshotButton } from "@/components/telegram/send-snapshot-button" +import { useProductSnapshot } from "@/hooks/useProductSnapshot" +import { cn } from "@/lib/utils" +import type { ProductSnapshotSendResponse } from "@/types/product-snapshot" +import { getErrorMessage } from "@/utils/errors" +import { formatCurrency, formatNumber } from "@/utils/formatters" + +type ProductSnapshotCardProps = { + isConnected: boolean +} + +type ToastState = { + message: string + tone: "success" | "error" +} + +type SnapshotStatus = { + message: string + tone: "success" | "error" + sentAt?: string | null + deliveryStatus?: string | null +} + +export function ProductSnapshotCard({ isConnected }: ProductSnapshotCardProps) { + const { productsQuery, sendSnapshot } = useProductSnapshot() + const [selectedProductId, setSelectedProductId] = useState(null) + const [toast, setToast] = useState(null) + const [lastSnapshot, setLastSnapshot] = useState(null) + + const products = useMemo( + () => productsQuery.data ?? [], + [productsQuery.data] + ) + const selectedProduct = useMemo( + () => products.find((item) => item.id === selectedProductId) ?? null, + [products, selectedProductId] + ) + + const isDisabled = + !isConnected || productsQuery.isLoading || productsQuery.isError + const canSend = Boolean(selectedProduct) && !isDisabled + + useEffect(() => { + if (!toast) { + return + } + const timer = window.setTimeout(() => setToast(null), 3500) + return () => window.clearTimeout(timer) + }, [toast]) + + + const handleSuccess = (data: ProductSnapshotSendResponse) => { + setToast({ + message: data.message ?? "Snapshot sent successfully.", + tone: "success", + }) + setLastSnapshot({ + message: data.message ?? "Snapshot delivered to Telegram.", + tone: "success", + sentAt: data.sentAt, + deliveryStatus: data.deliveryStatus, + }) + } + + const handleError = (error: unknown) => { + const message = getErrorMessage( + error, + "Unable to send the product snapshot." + ) + setToast({ message, tone: "error" }) + setLastSnapshot({ message, tone: "error" }) + } + + const handleSend = () => { + if (!selectedProduct || sendSnapshot.isPending) { + return + } + sendSnapshot.mutate(selectedProduct.id, { + onSuccess: handleSuccess, + onError: handleError, + }) + } + + return ( +
+
+
+ +
+
+

+ Product Intelligence Snapshot +

+

+ Send an instant intelligence summary to your Telegram inbox. +

+
+
+ + {!isConnected ? ( +
+ + Connect your Telegram account first to send product snapshots. +
+ ) : null} + + {toast ? ( +
+ {toast.tone === "success" ? ( + + ) : ( + + )} + {toast.message} +
+ ) : null} + +
+ + +
+

+ Product quick preview +

+ {productsQuery.isLoading ? ( +
+
+
+
+
+ ) : selectedProduct ? ( +
+

+ {selectedProduct.title} +

+
+ Current price + + {formatCurrency(selectedProduct.sellingPrice)} + +
+
+ Stock + + {formatNumber(selectedProduct.stockQuantity)} units + +
+
+ Lead time + + {selectedProduct.supplierLeadTime} days + +
+
+ ) : ( +

+ Select a product to preview the latest intelligence snapshot. +

+ )} +
+ + + +
+

Last snapshot status

+ {lastSnapshot ? ( +
+

+ {lastSnapshot.message} +

+ {lastSnapshot.sentAt ? ( +

+ Sent at{" "} + + {new Date(lastSnapshot.sentAt).toLocaleString()} + +

+ ) : null} + {lastSnapshot.deliveryStatus ? ( +

+ Delivery status{" "} + + {lastSnapshot.deliveryStatus} + +

+ ) : null} +
+ ) : ( +

No snapshots sent yet.

+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/telegram/send-snapshot-button.tsx b/frontend/src/components/telegram/send-snapshot-button.tsx new file mode 100644 index 0000000..86af238 --- /dev/null +++ b/frontend/src/components/telegram/send-snapshot-button.tsx @@ -0,0 +1,27 @@ +import { Loader2, Send } from "lucide-react" + +import { Button } from "@/components/ui/button" + +type SendSnapshotButtonProps = { + onClick: () => void + disabled?: boolean + isLoading?: boolean +} + +export function SendSnapshotButton({ + onClick, + disabled = false, + isLoading = false, +}: SendSnapshotButtonProps) { + return ( + + ) +} diff --git a/frontend/src/components/telegram/telegram-alert-history.tsx b/frontend/src/components/telegram/telegram-alert-history.tsx new file mode 100644 index 0000000..106e34d --- /dev/null +++ b/frontend/src/components/telegram/telegram-alert-history.tsx @@ -0,0 +1,151 @@ +import { ChevronLeft, ChevronRight } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { ErrorState } from "@/components/common/error-state" +import type { PaginationMeta, TelegramAlertRecord } from "@/types/telegram" +import { cn } from "@/lib/utils" + +type TelegramAlertHistoryProps = { + alerts: TelegramAlertRecord[] + meta?: PaginationMeta + page: number + onPageChange: (page: number) => void + isLoading: boolean + isError: boolean + onRetry: () => void +} + +const statusStyles: Record = { + delivered: "bg-emerald-100 text-emerald-700", + failed: "bg-rose-100 text-rose-700", + pending: "bg-amber-100 text-amber-700", + duplicate: "bg-muted text-muted-foreground", +} + +const toTitleCase = (value: string) => + value.replace(/\b\w/g, (char) => char.toUpperCase()) + +export function TelegramAlertHistory({ + alerts, + meta, + page, + onPageChange, + isLoading, + isError, + onRetry, +}: TelegramAlertHistoryProps) { + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + if (isError) { + return ( + + Retry + + } + /> + ) + } + + return ( +
+
+
+

+ Recent Telegram alerts +

+

+ Latest alerts delivered through BazarSync AI Telegram. +

+
+ + {meta?.total ?? alerts.length} total + +
+ + {alerts.length === 0 ? ( +
+ No Telegram alerts sent yet. Alerts will appear here once delivery starts. +
+ ) : ( +
+
+
+ Alert Type + Product + Action + Sent Time + Status +
+ {alerts.map((alert) => ( +
+ + {toTitleCase(alert.alertType)} + + {alert.product} + {toTitleCase(alert.action)} + {new Date(alert.sentAt).toLocaleString()} + + {toTitleCase(alert.deliveryStatus)} + +
+ ))} +
+
+ )} + + {meta ? ( +
+ + Page {meta.page} of {meta.totalPages} + +
+ + +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/telegram/telegram-connect-card.tsx b/frontend/src/components/telegram/telegram-connect-card.tsx new file mode 100644 index 0000000..02d0733 --- /dev/null +++ b/frontend/src/components/telegram/telegram-connect-card.tsx @@ -0,0 +1,116 @@ +import { Info, MessageCircle } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { TelegramStatusBadge } from "@/components/telegram/telegram-status-badge" +import type { + TelegramConnectionState, + TelegramConnectionStatus, +} from "@/types/telegram" + +const formatDateTime = (value?: string | null) => { + if (!value) { + return "Not yet" + } + return new Date(value).toLocaleString() +} + +const resolveTelegramHandle = (status?: TelegramConnectionStatus | null) => { + if (!status) { + return "Not connected" + } + if (status.telegramUsername) { + return `@${status.telegramUsername.replace(/^@/, "")}` + } + const name = [status.telegramFirstName, status.telegramLastName] + .filter(Boolean) + .join(" ") + return name || "Telegram user" +} + +type TelegramConnectCardProps = { + status?: TelegramConnectionStatus | null + state: TelegramConnectionState + isConnecting: boolean + isDisconnecting?: boolean + errorMessage?: string | null + onConnect: () => void + onDisconnect: () => void + onShowInstructions: () => void +} + +export function TelegramConnectCard({ + status, + state, + isConnecting, + isDisconnecting = false, + errorMessage, + onConnect, + onDisconnect, + onShowInstructions, +}: TelegramConnectCardProps) { + const isConnected = status?.isConnected ?? false + + return ( +
+
+
+

+ Telegram connection +

+

+ Link your Telegram account to receive BazarSync AI alerts instantly. +

+
+
+ + +
+
+ +
+
+ Connected account + + {resolveTelegramHandle(status)} + +
+
+ Last alert sent + + {formatDateTime(status?.lastAlertSentAt)} + +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ +
+ + {isConnected ? ( + + ) : null} +
+
+ ) +} diff --git a/frontend/src/components/telegram/telegram-instructions-modal.tsx b/frontend/src/components/telegram/telegram-instructions-modal.tsx new file mode 100644 index 0000000..caf8c1f --- /dev/null +++ b/frontend/src/components/telegram/telegram-instructions-modal.tsx @@ -0,0 +1,63 @@ +import { useEffect } from "react" +import { X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + TelegramInstructions, + type TelegramInstructionsProps, +} from "@/components/telegram/telegram-instructions" + +type TelegramInstructionsModalProps = TelegramInstructionsProps & { + open: boolean + onClose: () => void +} + +export function TelegramInstructionsModal({ + open, + onClose, + ...instructionsProps +}: TelegramInstructionsModalProps) { + useEffect(() => { + if (!open) { + return + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose() + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [onClose, open]) + + if (!open) { + return null + } + + return ( +
+ + ) +} diff --git a/frontend/src/components/telegram/telegram-instructions.tsx b/frontend/src/components/telegram/telegram-instructions.tsx new file mode 100644 index 0000000..10de303 --- /dev/null +++ b/frontend/src/components/telegram/telegram-instructions.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from "react" +import { CheckCircle2, Copy, RefreshCcw } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { ErrorState } from "@/components/common/error-state" +import { getErrorMessage } from "@/utils/errors" + +export type TelegramInstructionsProps = { + token?: string + expiresAt?: string | null + botUsername?: string | null + isLoading: boolean + error?: unknown + onRefresh: () => void +} + +const formatDateTime = (value?: string | null) => { + if (!value) { + return "soon" + } + return new Date(value).toLocaleString() +} + +export function TelegramInstructions({ + token, + expiresAt, + botUsername, + isLoading, + error, + onRefresh, +}: TelegramInstructionsProps) { + const [copied, setCopied] = useState(false) + const command = token ? `/connect ${token}` : "/connect " + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(command) + setCopied(true) + } catch { + setCopied(false) + } + } + + useEffect(() => { + if (!copied) { + return + } + const timer = window.setTimeout(() => setCopied(false), 1500) + return () => window.clearTimeout(timer) + }, [copied]) + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ) + } + + if (error) { + return ( + + Retry + + } + /> + ) + } + + return ( +
+

+ Bot connection instructions +

+

+ Follow these steps to link your Telegram account to BazarSync AI alerts. +

+ +
+ {[ + "Open Telegram", + `Search: ${botUsername ?? "@bazarsync_bot"}`, + "Press Start on the bot", + "Return to the dashboard", + ].map((step, index) => ( +
+ + {index + 1} + + {step} +
+ ))} +
+ +
+
+ + Send this command in Telegram +
+
+ {command} +
+ + +
+
+ {!token ? ( +

+ Click “Connect Telegram Bot” to generate a secure token. +

+ ) : null} +

+ Token expires {formatDateTime(expiresAt)}. +

+
+
+ ) +} diff --git a/frontend/src/components/telegram/telegram-status-badge.tsx b/frontend/src/components/telegram/telegram-status-badge.tsx new file mode 100644 index 0000000..afc610a --- /dev/null +++ b/frontend/src/components/telegram/telegram-status-badge.tsx @@ -0,0 +1,49 @@ +import { AlertTriangle, CheckCircle2, Clock } from "lucide-react" + +import type { TelegramConnectionState } from "@/types/telegram" +import { cn } from "@/lib/utils" + +const statusConfig: Record< + TelegramConnectionState, + { label: string; tone: string; Icon: typeof CheckCircle2 } +> = { + not_connected: { + label: "Not connected", + tone: "bg-muted text-muted-foreground", + Icon: Clock, + }, + connecting: { + label: "Connecting", + tone: "bg-amber-100 text-amber-700", + Icon: Clock, + }, + connected: { + label: "Connected", + tone: "bg-emerald-100 text-emerald-700", + Icon: CheckCircle2, + }, + error: { + label: "Error", + tone: "bg-rose-100 text-rose-700", + Icon: AlertTriangle, + }, +} + +type TelegramStatusBadgeProps = { + state: TelegramConnectionState +} + +export function TelegramStatusBadge({ state }: TelegramStatusBadgeProps) { + const { label, tone, Icon } = statusConfig[state] + return ( + + + {label} + + ) +} diff --git a/frontend/src/components/telegram/test-alert-button.tsx b/frontend/src/components/telegram/test-alert-button.tsx new file mode 100644 index 0000000..175046a --- /dev/null +++ b/frontend/src/components/telegram/test-alert-button.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react" +import { CheckCircle2, Loader2, Send, XCircle } from "lucide-react" +import { useMutation } from "@tanstack/react-query" + +import { Button } from "@/components/ui/button" +import { sendTelegramTestAlert } from "@/services/telegram-service" +import { cn } from "@/lib/utils" +import { getErrorMessage } from "@/utils/errors" + +type TestAlertButtonProps = { + enabled: boolean +} + +type ToastState = { + message: string + tone: "success" | "error" +} + +export function TestAlertButton({ enabled }: TestAlertButtonProps) { + const [toast, setToast] = useState(null) + const mutation = useMutation({ + mutationFn: () => sendTelegramTestAlert(), + onSuccess: () => + setToast({ + message: "Test alert sent to your Telegram account.", + tone: "success", + }), + onError: (error) => + setToast({ + message: getErrorMessage(error, "Unable to send Telegram test alert."), + tone: "error", + }), + }) + + useEffect(() => { + if (!toast) { + return + } + const timer = window.setTimeout(() => setToast(null), 3500) + return () => window.clearTimeout(timer) + }, [toast]) + + return ( +
+
+
+

Test alert

+

+ Send a live Telegram alert to confirm delivery. +

+
+ +
+ {!enabled ? ( +

+ Connect your Telegram account to enable test alerts. +

+ ) : null} + + {toast ? ( +
+ {toast.tone === "success" ? ( + + ) : ( + + )} + {toast.message} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index bfae05f..3939db4 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "@radix-ui/react-slot" +import { Slot } from "radix-ui" import { cn } from "@/lib/utils" @@ -51,7 +51,7 @@ function Button({ VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot.Root : "button" return ( { + setIsAuthenticating(true) + + try { + await deleteAccountService(input) + logout() + } finally { + setIsAuthenticating(false) + } + }, + [logout] + ) + const value = React.useMemo( () => ({ ...state, @@ -107,9 +123,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { login, register, updateProfile, + deleteAccount, logout, }), - [state, isAuthenticating, login, register, updateProfile, logout] + [state, isAuthenticating, login, register, updateProfile, deleteAccount, logout] ) return {children} diff --git a/frontend/src/hooks/useAlerts.ts b/frontend/src/hooks/useAlerts.ts new file mode 100644 index 0000000..81a4079 --- /dev/null +++ b/frontend/src/hooks/useAlerts.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { + getActiveAlerts, + getAlertHistory, + getAlertSummary, + resolveAlert, +} from "@/services/alert-service" +import { AlertStatus, type Alert } from "@/types/alert" + +export const activeAlertsQueryKey = ["war-room", "alerts", "active"] +export const alertHistoryQueryKey = ["war-room", "alerts", "history"] +export const alertSummaryQueryKey = ["war-room", "alerts", "summary"] + +export function useAlerts() { + const activeAlerts = useQuery({ + queryKey: activeAlertsQueryKey, + queryFn: ({ signal }) => getActiveAlerts(signal), + staleTime: 10_000, + retry: 2, + }) + + const alertHistory = useQuery({ + queryKey: alertHistoryQueryKey, + queryFn: ({ signal }) => getAlertHistory(signal), + staleTime: 30_000, + retry: 2, + }) + + const alertSummary = useQuery({ + queryKey: alertSummaryQueryKey, + queryFn: ({ signal }) => getAlertSummary(signal), + staleTime: 20_000, + retry: 2, + }) + + return { + activeAlerts, + alertHistory, + alertSummary, + } +} + +export function useResolveAlert() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (alertId: string) => resolveAlert(alertId), + onMutate: async (alertId) => { + await queryClient.cancelQueries({ queryKey: alertHistoryQueryKey }) + await queryClient.cancelQueries({ queryKey: activeAlertsQueryKey }) + + const previousHistory = + queryClient.getQueryData(alertHistoryQueryKey) + const previousActive = + queryClient.getQueryData(activeAlertsQueryKey) + + const updateAlertList = (alerts?: Alert[]) => + alerts?.map((alert) => + alert.id === alertId + ? { ...alert, status: AlertStatus.Resolved } + : alert + ) + + if (previousHistory) { + queryClient.setQueryData( + alertHistoryQueryKey, + updateAlertList(previousHistory) + ) + } + if (previousActive) { + queryClient.setQueryData( + activeAlertsQueryKey, + updateAlertList(previousActive) + ) + } + + return { previousHistory, previousActive } + }, + onError: (_error, _alertId, context) => { + if (context?.previousHistory) { + queryClient.setQueryData(alertHistoryQueryKey, context.previousHistory) + } + if (context?.previousActive) { + queryClient.setQueryData(activeAlertsQueryKey, context.previousActive) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: alertHistoryQueryKey }) + queryClient.invalidateQueries({ queryKey: activeAlertsQueryKey }) + queryClient.invalidateQueries({ queryKey: alertSummaryQueryKey }) + }, + }) +} diff --git a/frontend/src/hooks/useComingSoon.ts b/frontend/src/hooks/useComingSoon.ts new file mode 100644 index 0000000..887602e --- /dev/null +++ b/frontend/src/hooks/useComingSoon.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from "react" +import type { MouseEvent } from "react" + +import type { NavItem } from "@/types/navigation" + +type ComingSoonState = { + open: boolean + onClose: () => void + onOpen: () => void + handleNavClick: (event: MouseEvent, item: NavItem) => void +} + +export function useComingSoon(): ComingSoonState { + const [open, setOpen] = useState(false) + + const onOpen = useCallback(() => setOpen(true), []) + const onClose = useCallback(() => setOpen(false), []) + + const handleNavClick = useCallback( + (event: MouseEvent, item: NavItem) => { + if (!item.comingSoon) { + return + } + event.preventDefault() + event.stopPropagation() + onOpen() + }, + [onOpen] + ) + + return { open, onClose, onOpen, handleNavClick } +} diff --git a/frontend/src/hooks/useCompetitors.ts b/frontend/src/hooks/useCompetitors.ts new file mode 100644 index 0000000..fcd6f0d --- /dev/null +++ b/frontend/src/hooks/useCompetitors.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query" + +import { getCompetitors } from "@/services/dashboard-service" + +export const competitorsQueryKey = ["war-room", "competitors"] + +export function useCompetitors() { + return useQuery({ + queryKey: competitorsQueryKey, + queryFn: ({ signal }) => getCompetitors(signal), + staleTime: 20_000, + retry: 2, + }) +} diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts new file mode 100644 index 0000000..2f3a7ae --- /dev/null +++ b/frontend/src/hooks/useDashboard.ts @@ -0,0 +1,80 @@ +import { useQuery } from "@tanstack/react-query" + +import { + getActivityFeed, + getCompetitorPriceTrend, + getDashboardSummary, + getMarketOpportunities, + getMarketTrends, + getOpportunityMetrics, + getRecommendations, +} from "@/services/dashboard-service" + +export const summaryQueryKey = ["war-room", "summary"] +export const marketTrendQueryKey = ["war-room", "market-trends"] +export const opportunityMetricsQueryKey = ["war-room", "opportunity-metrics"] +export const marketOpportunitiesQueryKey = ["war-room", "opportunities"] +export const recommendationsQueryKey = ["war-room", "recommendations"] +export const activityQueryKey = ["war-room", "activity"] +export const competitorPriceTrendQueryKey = ["war-room", "competitor-price-trend"] + +export function useDashboard() { + const summary = useQuery({ + queryKey: summaryQueryKey, + queryFn: ({ signal }) => getDashboardSummary(signal), + staleTime: 20_000, + retry: 2, + }) + + const marketTrends = useQuery({ + queryKey: marketTrendQueryKey, + queryFn: ({ signal }) => getMarketTrends(signal), + staleTime: 30_000, + retry: 2, + }) + + const opportunityMetrics = useQuery({ + queryKey: opportunityMetricsQueryKey, + queryFn: ({ signal }) => getOpportunityMetrics(signal), + staleTime: 30_000, + retry: 2, + }) + + const marketOpportunities = useQuery({ + queryKey: marketOpportunitiesQueryKey, + queryFn: ({ signal }) => getMarketOpportunities(signal), + staleTime: 30_000, + retry: 2, + }) + + const recommendations = useQuery({ + queryKey: recommendationsQueryKey, + queryFn: ({ signal }) => getRecommendations(signal), + staleTime: 20_000, + retry: 2, + }) + + const activity = useQuery({ + queryKey: activityQueryKey, + queryFn: ({ signal }) => getActivityFeed(signal), + staleTime: 10_000, + retry: 2, + }) + + const competitorPriceTrend = useQuery({ + queryKey: competitorPriceTrendQueryKey, + queryFn: ({ signal }) => getCompetitorPriceTrend(signal), + staleTime: 30_000, + retry: 2, + }) + + return { + summary, + marketTrends, + opportunityMetrics, + marketOpportunities, + recommendations, + activity, + competitorPriceTrend, + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useLiveDashboard.ts b/frontend/src/hooks/useLiveDashboard.ts new file mode 100644 index 0000000..ff87182 --- /dev/null +++ b/frontend/src/hooks/useLiveDashboard.ts @@ -0,0 +1,492 @@ +import { useCallback, useMemo, useRef } from "react" +import { useQueryClient } from "@tanstack/react-query" + +import { useRealtime } from "@/hooks/useRealtime" +import { useSocketEvent } from "@/hooks/useSocketEvent" +import { + activityQueryKey, + marketOpportunitiesQueryKey, + recommendationsQueryKey, + summaryQueryKey, +} from "@/hooks/useDashboard" +import { competitorsQueryKey } from "@/hooks/useCompetitors" +import { + activeAlertsQueryKey, + alertHistoryQueryKey, + alertSummaryQueryKey, +} from "@/hooks/useAlerts" +import { + ActivitySeverity, + type ActivityItem, + type DashboardSummary, +} from "@/types/dashboard" +import { + AlertSeverity, + type Alert, + type AlertSummary, +} from "@/types/alert" +import type { Competitor } from "@/types/competitor" +import type { MarketOpportunity } from "@/types/dashboard" +import type { Recommendation } from "@/types/recommendation" +import { + RealtimeEventName, + type AlertFailedEvent, + type AlertSentEvent, + type CompetitorUpdatedEvent, + type DashboardSummaryUpdatedEvent, + type MarketOpportunityDetectedEvent, + type ProductSnapshotSentEvent, + type RecommendationCreatedEvent, + type RoomSubscriptionRequest, + type RealtimeRoomTarget, + type StockRiskDetectedEvent, + type UrgencyScoreUpdatedEvent, +} from "@/types/realtime" + +type UseLiveDashboardOptions = { + sellerId?: string + productIds?: string[] + rooms?: RoomSubscriptionRequest + enabled?: boolean +} + +const upsertById = ( + items: T[] | undefined, + item: T +) => { + const existing = items ?? [] + const index = existing.findIndex((entry) => entry.id === item.id) + if (index === -1) { + return [item, ...existing] + } + const updated = [...existing] + updated[index] = item + return updated +} + +const appendActivity = ( + items: ActivityItem[] | undefined, + item: ActivityItem +) => { + const next = [item, ...(items ?? [])] + return next.slice(0, 12) +} + +const buildActivityItem = ( + data: { + id: string + title: string + description: string + timestamp: string + category: ActivityItem["category"] + severity: ActivitySeverity + } +): ActivityItem => ({ + id: data.id, + title: data.title, + description: data.description, + timestamp: data.timestamp, + category: data.category, + severity: data.severity, +}) + +const mapAlertSeverity = (severity: AlertSeverity): ActivitySeverity => + severity === AlertSeverity.Critical + ? ActivitySeverity.Critical + : ActivitySeverity.Warning + +const useEventDeduper = () => { + const seenRef = useRef(new Map()) + + return useCallback((eventId?: string) => { + if (!eventId) { + return true + } + const now = Date.now() + const seenAt = seenRef.current.get(eventId) + if (seenAt) { + return false + } + seenRef.current.set(eventId, now) + if (seenRef.current.size > 500) { + for (const [key, value] of seenRef.current.entries()) { + if (now - value > 5 * 60_000) { + seenRef.current.delete(key) + } + } + } + return true + }, []) +} + +const buildDefaultRooms = ( + sellerId?: string, + productIds?: string[] +): RoomSubscriptionRequest => { + const rooms: RealtimeRoomTarget[] = [ + { scope: "dashboard" }, + { scope: "alerts" }, + { scope: "activity" }, + ] + if (sellerId) { + rooms.push({ scope: "seller", id: sellerId }) + } + if (productIds?.length) { + rooms.push(...productIds.map((id) => ({ scope: "product" as const, id }))) + } + return { rooms } +} + +export function useLiveDashboard(options: UseLiveDashboardOptions = {}) { + const { sellerId, productIds, rooms, enabled = true } = options + const queryClient = useQueryClient() + const shouldProcess = useEventDeduper() + const subscription = useMemo( + () => rooms ?? buildDefaultRooms(sellerId, productIds), + [rooms, sellerId, productIds] + ) + const realtimeStatus = useRealtime({ + rooms: subscription, + autoConnect: enabled, + }) + + const applyAlert = useCallback( + (alert: Alert) => { + queryClient.setQueryData( + activeAlertsQueryKey, + (current) => upsertById(current, alert) + ) + queryClient.setQueryData( + alertHistoryQueryKey, + (current) => upsertById(current, alert) + ) + queryClient.setQueryData( + alertSummaryQueryKey, + (current) => + current + ? { + ...current, + activeCount: current.activeCount + 1, + criticalCount: + alert.severity === AlertSeverity.Critical + ? current.criticalCount + 1 + : current.criticalCount, + lastTriggeredAt: alert.createdAt, + } + : current + ) + }, + [queryClient] + ) + + const handleRecommendation = useCallback( + (event: RecommendationCreatedEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + const recommendation = event.payload.recommendation + queryClient.setQueryData( + recommendationsQueryKey, + (current) => upsertById(current, recommendation) + ) + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `rec-${recommendation.id}`, + title: "Recommendation generated", + description: recommendation.summary, + timestamp: recommendation.createdAt, + category: "recommendation", + severity: ActivitySeverity.Info, + }) + ) + ) + queryClient.setQueryData( + summaryQueryKey, + (current) => + current + ? { + ...current, + aiRecommendations: current.aiRecommendations + 1, + lastSyncedAt: new Date().toISOString(), + } + : current + ) + }, + [queryClient, shouldProcess] + ) + + const handleAlertSent = useCallback( + (event: AlertSentEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + const alert = event.payload.alert + applyAlert(alert) + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `alert-${alert.id}`, + title: alert.title, + description: alert.message, + timestamp: alert.createdAt, + category: "alert", + severity: mapAlertSeverity(alert.severity), + }) + ) + ) + queryClient.setQueryData( + summaryQueryKey, + (current) => + current + ? { + ...current, + activeAlerts: current.activeAlerts + 1, + lastSyncedAt: new Date().toISOString(), + } + : current + ) + }, + [applyAlert, queryClient, shouldProcess] + ) + + const handleAlertFailed = useCallback( + (event: AlertFailedEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + const message = + event.payload.reason ?? "Telegram delivery failed for a new alert." + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `alert-failed-${event.payload.alertId ?? event.eventId}`, + title: "Alert delivery failed", + description: message, + timestamp: event.timestamp, + category: "system", + severity: ActivitySeverity.Warning, + }) + ) + ) + }, + [queryClient, shouldProcess] + ) + + const handleCompetitorUpdated = useCallback( + (event: CompetitorUpdatedEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + const competitor = event.payload.competitor + queryClient.setQueryData( + competitorsQueryKey, + (current) => upsertById(current, competitor) + ) + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `comp-${competitor.id}`, + title: "Competitor update", + description: `${competitor.competitor} adjusted ${competitor.product}.`, + timestamp: competitor.lastUpdated, + category: "competitor", + severity: ActivitySeverity.Info, + }) + ) + ) + }, + [queryClient, shouldProcess] + ) + + const handleStockRisk = useCallback( + (event: StockRiskDetectedEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + const alert = event.payload.alert + applyAlert(alert) + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `risk-${alert.id}`, + title: "Stock risk detected", + description: alert.message, + timestamp: alert.createdAt, + category: "alert", + severity: mapAlertSeverity(alert.severity), + }) + ) + ) + }, + [applyAlert, queryClient, shouldProcess] + ) + + const handleUrgencyScoreUpdated = useCallback( + (event: UrgencyScoreUpdatedEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + queryClient.setQueryData( + summaryQueryKey, + (current) => + current + ? { + ...current, + urgencyScore: event.payload.urgencyScore, + lastSyncedAt: event.payload.updatedAt, + } + : current + ) + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `urgency-${event.eventId}`, + title: "Urgency score updated", + description: event.payload.reason ?? "Urgency recalculated", + timestamp: event.payload.updatedAt, + category: "system", + severity: ActivitySeverity.Warning, + }) + ) + ) + }, + [queryClient, shouldProcess] + ) + + const handleSummaryUpdated = useCallback( + (event: DashboardSummaryUpdatedEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + queryClient.setQueryData(summaryQueryKey, event.payload.summary) + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `summary-${event.eventId}`, + title: "Dashboard summary updated", + description: "Live metrics refreshed", + timestamp: event.payload.summary.lastSyncedAt, + category: "system", + severity: ActivitySeverity.Info, + }) + ) + ) + }, + [queryClient, shouldProcess] + ) + + const handleMarketOpportunity = useCallback( + (event: MarketOpportunityDetectedEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + const opportunity = event.payload.opportunity + queryClient.setQueryData( + marketOpportunitiesQueryKey, + (current) => upsertById(current, opportunity) + ) + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `opp-${opportunity.id}`, + title: "Market opportunity detected", + description: opportunity.title, + timestamp: new Date().toISOString(), + category: "recommendation", + severity: ActivitySeverity.Info, + }) + ) + ) + queryClient.setQueryData( + summaryQueryKey, + (current) => + current + ? { + ...current, + revenueOpportunities: current.revenueOpportunities + 1, + lastSyncedAt: new Date().toISOString(), + } + : current + ) + }, + [queryClient, shouldProcess] + ) + + const handleProductSnapshotSent = useCallback( + (event: ProductSnapshotSentEvent) => { + if (!shouldProcess(event.eventId)) { + return + } + const title = event.payload.productTitle + ? `Snapshot sent for ${event.payload.productTitle}` + : "Product snapshot sent" + queryClient.setQueryData( + activityQueryKey, + (current) => + appendActivity( + current, + buildActivityItem({ + id: `snapshot-${event.eventId}`, + title, + description: "Telegram product intelligence snapshot delivered.", + timestamp: event.payload.timestamp ?? event.timestamp, + category: "system", + severity: ActivitySeverity.Info, + }) + ) + ) + }, + [queryClient, shouldProcess] + ) + + useSocketEvent(RealtimeEventName.RecommendationCreated, handleRecommendation, { + enabled, + }) + useSocketEvent(RealtimeEventName.AlertSent, handleAlertSent, { enabled }) + useSocketEvent(RealtimeEventName.AlertFailed, handleAlertFailed, { enabled }) + useSocketEvent(RealtimeEventName.CompetitorUpdated, handleCompetitorUpdated, { + enabled, + }) + useSocketEvent(RealtimeEventName.StockRiskDetected, handleStockRisk, { enabled }) + useSocketEvent(RealtimeEventName.UrgencyScoreUpdated, handleUrgencyScoreUpdated, { + enabled, + }) + useSocketEvent( + RealtimeEventName.DashboardSummaryUpdated, + handleSummaryUpdated, + { enabled } + ) + useSocketEvent(RealtimeEventName.MarketOpportunityDetected, handleMarketOpportunity, { + enabled, + }) + useSocketEvent(RealtimeEventName.ProductSnapshotSent, handleProductSnapshotSent, { + enabled, + }) + + return realtimeStatus +} diff --git a/frontend/src/hooks/useProductSnapshot.ts b/frontend/src/hooks/useProductSnapshot.ts new file mode 100644 index 0000000..f43bce7 --- /dev/null +++ b/frontend/src/hooks/useProductSnapshot.ts @@ -0,0 +1,16 @@ +import { useMutation } from "@tanstack/react-query" + +import { useProducts } from "@/hooks/use-products" +import { sendProductSnapshot } from "@/services/telegram-snapshot-service" + +export function useProductSnapshot() { + const productsQuery = useProducts() + const sendSnapshot = useMutation({ + mutationFn: (productId: string) => sendProductSnapshot(productId), + }) + + return { + productsQuery, + sendSnapshot, + } +} diff --git a/frontend/src/hooks/useRealtime.ts b/frontend/src/hooks/useRealtime.ts new file mode 100644 index 0000000..e904015 --- /dev/null +++ b/frontend/src/hooks/useRealtime.ts @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from "react" + +import { getSocket, refreshSocketAuth, setSocketSessionId } from "@/lib/socket" +import { connectRealtime, sendHeartbeat, subscribeRooms } from "@/services/socket-service" +import type { RealtimeStatus } from "@/types/dashboard" +import type { + ConnectionEstablishedEvent, + RoomSubscriptionRequest, + RealtimeErrorEvent, + RoomsUpdatedEvent, + ServerPongEvent, +} from "@/types/realtime" +import { RealtimeEventName } from "@/types/realtime" + +const initialStatus: RealtimeStatus = { + connected: false, + reconnecting: false, + lastEventAt: null, +} + +type UseRealtimeOptions = { + rooms?: RoomSubscriptionRequest + autoConnect?: boolean + heartbeatIntervalMs?: number +} + +export function useRealtime(options: UseRealtimeOptions = {}) { + const { rooms, autoConnect = true, heartbeatIntervalMs = 15_000 } = options + const [status, setStatus] = useState(initialStatus) + const roomsRef = useRef(rooms) + + useEffect(() => { + roomsRef.current = rooms + if (!rooms) { + return + } + const socket = getSocket() + if (socket.connected) { + subscribeRooms(rooms) + } + }, [rooms]) + + useEffect(() => { + if (!autoConnect) { + return + } + const socket = connectRealtime() + + const handleConnect = () => { + setStatus((previous) => ({ + ...previous, + connected: true, + reconnecting: false, + transport: socket.io.engine?.transport?.name, + })) + const payload = roomsRef.current + if (payload) { + subscribeRooms(payload) + } + } + + const handleDisconnect = () => { + setStatus((previous) => ({ + ...previous, + connected: false, + reconnecting: false, + })) + } + + const handleReconnectAttempt = () => { + setStatus((previous) => ({ + ...previous, + reconnecting: true, + })) + } + + const handleReconnect = () => { + setStatus((previous) => ({ + ...previous, + reconnecting: false, + })) + } + + const handleConnectError = (error: Error) => { + setStatus((previous) => ({ + ...previous, + reconnecting: false, + lastError: error.message, + })) + } + + const handleAny = () => { + setStatus((previous) => ({ + ...previous, + lastEventAt: new Date().toISOString(), + })) + } + + socket.on("connect", handleConnect) + socket.on("disconnect", handleDisconnect) + socket.io.on("reconnect_attempt", handleReconnectAttempt) + socket.io.on("reconnect", handleReconnect) + socket.on("connect_error", handleConnectError) + socket.onAny(handleAny) + + return () => { + socket.off("connect", handleConnect) + socket.off("disconnect", handleDisconnect) + socket.io.off("reconnect_attempt", handleReconnectAttempt) + socket.io.off("reconnect", handleReconnect) + socket.off("connect_error", handleConnectError) + socket.offAny(handleAny) + socket.disconnect() + } + }, [autoConnect]) + + useEffect(() => { + if (!status.connected) { + return + } + sendHeartbeat() + const interval = window.setInterval(() => { + sendHeartbeat() + }, heartbeatIntervalMs) + return () => window.clearInterval(interval) + }, [status.connected, heartbeatIntervalMs]) + + useEffect(() => { + const socket = getSocket() + + const handleConnectionEstablished = (event: ConnectionEstablishedEvent) => { + if (event.payload.sessionId) { + setSocketSessionId(event.payload.sessionId) + refreshSocketAuth() + } + setStatus((previous) => ({ + ...previous, + rooms: event.payload.rooms, + sessionId: event.payload.sessionId, + })) + } + + const handleRoomsUpdated = (event: RoomsUpdatedEvent) => { + setStatus((previous) => ({ + ...previous, + rooms: event.payload.rooms, + })) + } + + const handleServerPong = (event: ServerPongEvent) => { + setStatus((previous) => ({ + ...previous, + latencyMs: event.payload.latencyMs ?? null, + })) + } + + const handleRealtimeError = (event: RealtimeErrorEvent) => { + setStatus((previous) => ({ + ...previous, + lastError: event.payload.message, + })) + } + + socket.on(RealtimeEventName.ConnectionEstablished, handleConnectionEstablished) + socket.on(RealtimeEventName.RoomsUpdated, handleRoomsUpdated) + socket.on(RealtimeEventName.ServerPong, handleServerPong) + socket.on(RealtimeEventName.RealtimeError, handleRealtimeError) + + return () => { + socket.off(RealtimeEventName.ConnectionEstablished, handleConnectionEstablished) + socket.off(RealtimeEventName.RoomsUpdated, handleRoomsUpdated) + socket.off(RealtimeEventName.ServerPong, handleServerPong) + socket.off(RealtimeEventName.RealtimeError, handleRealtimeError) + } + }, []) + + return status +} \ No newline at end of file diff --git a/frontend/src/hooks/useSocketEvent.ts b/frontend/src/hooks/useSocketEvent.ts new file mode 100644 index 0000000..e933cd1 --- /dev/null +++ b/frontend/src/hooks/useSocketEvent.ts @@ -0,0 +1,28 @@ +import { useEffect } from "react" + +import { getSocket } from "@/lib/socket" +import type { RealtimeServerEvents } from "@/types/realtime" + +type SocketEventKey = keyof RealtimeServerEvents + +type UseSocketEventOptions = { + enabled?: boolean +} + +export function useSocketEvent( + event: E, + handler: RealtimeServerEvents[E], + options: UseSocketEventOptions = {} +) { + useEffect(() => { + if (options.enabled === false) { + return + } + const socket = getSocket() + const listener = handler as RealtimeServerEvents[keyof RealtimeServerEvents] + socket.on(event as keyof RealtimeServerEvents, listener) + return () => { + socket.off(event as keyof RealtimeServerEvents, listener) + } + }, [event, handler, options.enabled]) +} diff --git a/frontend/src/hooks/useTelegramAlerts.ts b/frontend/src/hooks/useTelegramAlerts.ts new file mode 100644 index 0000000..18fc2bd --- /dev/null +++ b/frontend/src/hooks/useTelegramAlerts.ts @@ -0,0 +1,37 @@ +import { useMemo, useState } from "react" +import { useQuery } from "@tanstack/react-query" + +import { + getTelegramAlertHistory, +} from "@/services/telegram-service" + +const DEFAULT_PAGE_SIZE = 8 + +export function useTelegramAlerts() { + const [page, setPage] = useState(1) + const pageSize = DEFAULT_PAGE_SIZE + + const query = useQuery({ + queryKey: ["telegram", "alerts", page, pageSize], + queryFn: ({ signal }) => getTelegramAlertHistory({ page, pageSize }, signal), + staleTime: 15_000, + retry: 1, + }) + + const alerts = useMemo(() => { + const items = query.data?.items ?? [] + return items.filter((item) => item.isTelegram) + }, [query.data?.items]) + + return { + alerts, + meta: query.data?.meta, + page, + pageSize, + setPage, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + refetch: query.refetch, + } +} diff --git a/frontend/src/hooks/useTelegramConnection.ts b/frontend/src/hooks/useTelegramConnection.ts new file mode 100644 index 0000000..e9e2cb6 --- /dev/null +++ b/frontend/src/hooks/useTelegramConnection.ts @@ -0,0 +1,135 @@ +import { useCallback } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { useAuth } from "@/hooks/use-auth" +import { + createTelegramConnectToken, + disconnectTelegram, + getTelegramHealth, + getTelegramStatus, +} from "@/services/telegram-service" +import type { TelegramConnectionStatus } from "@/types/telegram" +import { + RealtimeEventName, + type AlertSentEvent, + type TelegramConnectedEvent, + type TelegramDisconnectedEvent, +} from "@/types/realtime" +import { useSocketEvent } from "@/hooks/useSocketEvent" + +export const telegramStatusQueryKey = ["telegram", "status"] +export const telegramHealthQueryKey = ["telegram", "health"] + +const mapSnapshotToStatus = ( + snapshot: TelegramConnectedEvent["payload"]["connection"], + current?: TelegramConnectionStatus +): TelegramConnectionStatus => ({ + isConnected: snapshot.isConnected, + connectedAt: snapshot.connectedAt ?? null, + telegramUsername: snapshot.telegramUsername ?? null, + telegramFirstName: snapshot.telegramFirstName ?? null, + telegramLastName: snapshot.telegramLastName ?? null, + lastAlertSentAt: snapshot.lastAlertSentAt ?? current?.lastAlertSentAt ?? null, + botUsername: current?.botUsername ?? null, +}) + +export function useTelegramConnection() { + const queryClient = useQueryClient() + const { user } = useAuth() + + const status = useQuery({ + queryKey: telegramStatusQueryKey, + queryFn: ({ signal }) => getTelegramStatus(signal), + staleTime: 20_000, + retry: 2, + }) + + const health = useQuery({ + queryKey: telegramHealthQueryKey, + queryFn: ({ signal }) => getTelegramHealth(signal), + staleTime: 30_000, + retry: 1, + }) + + const connectToken = useMutation({ + mutationFn: createTelegramConnectToken, + }) + + const disconnect = useMutation({ + mutationFn: disconnectTelegram, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: telegramStatusQueryKey }) + const previous = + queryClient.getQueryData(telegramStatusQueryKey) + if (previous) { + queryClient.setQueryData(telegramStatusQueryKey, { + ...previous, + isConnected: false, + connectedAt: null, + telegramUsername: null, + telegramFirstName: null, + telegramLastName: null, + }) + } + return { previous } + }, + onError: (_error, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(telegramStatusQueryKey, context.previous) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: telegramStatusQueryKey }) + }, + }) + + const handleConnected = useCallback( + (event: TelegramConnectedEvent) => { + queryClient.setQueryData( + telegramStatusQueryKey, + (current) => mapSnapshotToStatus(event.payload.connection, current) + ) + }, + [queryClient] + ) + + const handleDisconnected = useCallback( + (event: TelegramDisconnectedEvent) => { + queryClient.setQueryData( + telegramStatusQueryKey, + (current) => mapSnapshotToStatus(event.payload.connection, current) + ) + }, + [queryClient] + ) + + const handleAlertSent = useCallback( + (event: AlertSentEvent) => { + if (user?.id && event.sellerId && event.sellerId !== user.id) { + return + } + queryClient.setQueryData( + telegramStatusQueryKey, + (current) => + current + ? { + ...current, + lastAlertSentAt: event.timestamp, + } + : current + ) + }, + [queryClient, user] + ) + + useSocketEvent(RealtimeEventName.TelegramConnected, handleConnected) + useSocketEvent(RealtimeEventName.TelegramDisconnected, handleDisconnected) + useSocketEvent(RealtimeEventName.AlertSent, handleAlertSent) + + return { + status, + health, + connectToken, + disconnect, + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 44f3f1a..587e78f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,8 +8,7 @@ @layer base { .theme { --font-heading: var(--font-sans); - --font-sans: 'Geist Variable', sans-serif - } + --font-sans: 'Geist Variable', sans-serif} :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); @@ -42,8 +41,7 @@ --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0) - } + --sidebar-ring: oklch(0.708 0 0)} .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); @@ -75,10 +73,9 @@ --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0) - } + --sidebar-ring: oklch(0.556 0 0)} * { - @apply border-border; + @apply border-border outline-ring; } body { @apply bg-background text-foreground; @@ -86,4 +83,4 @@ html { @apply font-sans; } -} +} \ No newline at end of file diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts new file mode 100644 index 0000000..b10854f --- /dev/null +++ b/frontend/src/lib/query-client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query" + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + refetchOnWindowFocus: false, + staleTime: 20_000, + }, + }, +}) diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts new file mode 100644 index 0000000..eeb629b --- /dev/null +++ b/frontend/src/lib/socket.ts @@ -0,0 +1,100 @@ +import { io, type Socket } from "socket.io-client" + +import { getAuthToken } from "@/services/auth-storage" +import { env } from "@/utils/env" +import type { + RealtimeClientEvents, + RealtimeServerEvents, +} from "@/types/realtime" + +let socketInstance: + | Socket + | null = null +const SOCKET_SESSION_KEY = "bazarsync.socket.session" + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "") + +const normalizeSocketBaseUrl = (value: string, socketPath: string) => { + try { + const url = new URL(value) + const normalizedPath = trimTrailingSlash(url.pathname) + const normalizedSocketPath = trimTrailingSlash(socketPath) + const mountPath = normalizedSocketPath.replace(/\/socket\.io$/, "") + if (normalizedSocketPath && normalizedPath.endsWith(normalizedSocketPath)) { + url.pathname = + normalizedPath.slice(0, -normalizedSocketPath.length) || "/" + } else if (mountPath && normalizedPath.endsWith(mountPath)) { + url.pathname = normalizedPath.slice(0, -mountPath.length) || "/" + } + return trimTrailingSlash(url.toString()) + } catch { + return value + } +} + +const resolveSocketUrl = () => { + const configured = + import.meta.env.VITE_SOCKET_URL ?? import.meta.env.VITE_SOCKET_BASE_URL + const socketPath = resolveSocketPath() + if (configured) { + return normalizeSocketBaseUrl(configured, socketPath) + } + const normalizedApiBase = trimTrailingSlash(env.apiBaseUrl) + return normalizedApiBase.replace(/\/api\/v\d+$/, "") +} + +const resolveSocketPath = () => { + return import.meta.env.VITE_SOCKET_PATH ?? "/ws/socket.io" +} + +const getSocketSessionId = () => { + if (typeof window === "undefined") { + return undefined + } + const existing = window.localStorage.getItem(SOCKET_SESSION_KEY) + if (existing) { + return existing + } + const generated = crypto.randomUUID() + window.localStorage.setItem(SOCKET_SESSION_KEY, generated) + return generated +} + +export const setSocketSessionId = (sessionId: string) => { + if (typeof window === "undefined") { + return + } + window.localStorage.setItem(SOCKET_SESSION_KEY, sessionId) +} + +export const getSocket = () => { + if (!socketInstance) { + socketInstance = io(resolveSocketUrl(), { + autoConnect: false, + transports: ["websocket", "polling"], + path: resolveSocketPath(), + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + randomizationFactor: 0.5, + timeout: 8000, + auth: { + token: getAuthToken(), + session_id: getSocketSessionId(), + }, + }) + } + + return socketInstance +} + +export const refreshSocketAuth = () => { + if (!socketInstance) { + return + } + socketInstance.auth = { + token: getAuthToken(), + session_id: getSocketSessionId(), + } +} diff --git a/frontend/src/pages/alerts/alerts-page.tsx b/frontend/src/pages/alerts/alerts-page.tsx index 0bd0809..ca31a0d 100644 --- a/frontend/src/pages/alerts/alerts-page.tsx +++ b/frontend/src/pages/alerts/alerts-page.tsx @@ -12,7 +12,7 @@ const alertTracks = [ }, { title: "Campaign spikes", - description: "WhatsApp notifications for sudden demand surges.", + description: "Telegram notifications for sudden demand surges.", }, ] @@ -20,7 +20,7 @@ export default function AlertsPage() { return (
Create alert} /> diff --git a/frontend/src/pages/auth/login-page.tsx b/frontend/src/pages/auth/login-page.tsx index 97e4be1..0db2684 100644 --- a/frontend/src/pages/auth/login-page.tsx +++ b/frontend/src/pages/auth/login-page.tsx @@ -106,8 +106,8 @@ export default function LoginPage() { description: "Convert insights into next-best actions instantly.", }, { - title: "WhatsApp alerts", - description: "Keep the team aligned with instant notifications.", + title: "Telegram alerts", + description: "Keep the team aligned with instant Telegram updates.", }, ]} /> diff --git a/frontend/src/pages/auth/register-page.tsx b/frontend/src/pages/auth/register-page.tsx index 6125be0..dafc0d7 100644 --- a/frontend/src/pages/auth/register-page.tsx +++ b/frontend/src/pages/auth/register-page.tsx @@ -108,7 +108,7 @@ export default function RegisterPage() { }, { title: "Team-ready alerts", - description: "Keep ops and sales aligned with WhatsApp notifications.", + description: "Keep ops and sales aligned with Telegram notifications.", }, ]} /> diff --git a/frontend/src/pages/competitors/CompetitorDashboardPage.tsx b/frontend/src/pages/competitors/CompetitorDashboardPage.tsx index 96ca87c..f4286e1 100644 --- a/frontend/src/pages/competitors/CompetitorDashboardPage.tsx +++ b/frontend/src/pages/competitors/CompetitorDashboardPage.tsx @@ -1,5 +1,6 @@ import axios from "axios" import { useEffect, useMemo, useState } from "react" +import { useQueryClient } from "@tanstack/react-query" import { InlineAlert } from "@/components/common/inline-alert" import { PageHeader } from "@/components/common/page-header" @@ -14,8 +15,9 @@ import { EmptyState } from "./components/EmptyState" import { ErrorState } from "./components/ErrorState" import { LoadingState } from "./components/LoadingState" import { RunScraperButton } from "./components/RunScraperButton" -import { useCompetitors } from "./hooks/useCompetitors" +import { competitorsQueryKey, useCompetitors } from "./hooks/useCompetitors" import { useRunScraper } from "./hooks/useRunScraper" +import { useScrapeRunStatus } from "./hooks/useScrapeRunStatus" const getAuthAwareMessage = (error: unknown, fallback: string) => { if (axios.isAxiosError(error) && error.response?.status === 401) { @@ -31,6 +33,8 @@ const isInStock = (status: string) => { export default function CompetitorDashboardPage() { const [selectedProductId, setSelectedProductId] = useState("") + const [activeScrapeRunId, setActiveScrapeRunId] = useState(null) + const queryClient = useQueryClient() const { data: products, @@ -39,7 +43,19 @@ export default function CompetitorDashboardPage() { error: productsError, } = useProducts() - const activeProductId = selectedProductId || products?.[0]?.id || "" + const activeProductId = useMemo(() => { + if (!products || products.length === 0) { + return "" + } + + if (selectedProductId) { + return products.some((product) => product.id === selectedProductId) + ? selectedProductId + : "" + } + + return products.length === 1 ? products[0].id : "" + }, [products, selectedProductId]) const selectedProduct = products?.find( (product) => product.id === activeProductId ) @@ -53,21 +69,45 @@ export default function CompetitorDashboardPage() { } = useCompetitors(activeProductId || null) const runScraper = useRunScraper(activeProductId || null) + const scrapeRunStatus = useScrapeRunStatus( + activeProductId ? activeScrapeRunId : null + ) useEffect(() => { runScraper.reset() }, [activeProductId, runScraper]) + useEffect(() => { + if (!activeProductId) { + return + } + + if (scrapeRunStatus.data?.status === "completed") { + queryClient.invalidateQueries({ + queryKey: competitorsQueryKey(activeProductId), + }) + } + }, [activeProductId, queryClient, scrapeRunStatus.data?.status]) + + const scraperRunFailure = + scrapeRunStatus.data?.status === "failed" + ? scrapeRunStatus.data?.error_message || "Scraper failed. Please try again." + : null + const scraperError = runScraper.isError ? getAuthAwareMessage( runScraper.error, "Scraper failed to start. Please try again." ) - : null + : scraperRunFailure - const scraperSuccess = runScraper.isSuccess - ? runScraper.data?.message ?? "Scraper run queued." - : null + const scraperSuccess = scrapeRunStatus.data?.status === "running" + ? "Scraper running. Fetching fresh competitor data..." + : scrapeRunStatus.data?.status === "completed" + ? "Scraper completed. Refreshing results..." + : runScraper.isSuccess + ? `Scraper queued for ${runScraper.data?.product_title ?? "selected product"}.` + : null const competitorSummary = useMemo(() => { if (!competitors || competitors.length === 0) { @@ -125,6 +165,7 @@ export default function CompetitorDashboardPage() { const hasProducts = (products?.length ?? 0) > 0 const hasCompetitors = (competitors?.length ?? 0) > 0 const canRunScraper = Boolean(activeProductId) + const isScraperBusy = runScraper.isPending || scrapeRunStatus.data?.status === "running" return (
@@ -145,8 +186,14 @@ export default function CompetitorDashboardPage() {

runScraper.mutate()} - isRunning={runScraper.isPending} + onRun={() => + runScraper.mutate(undefined, { + onSuccess: (data) => { + setActiveScrapeRunId(data.scrape_run_id) + }, + }) + } + isRunning={isScraperBusy} disabled={!canRunScraper} statusMessage={scraperSuccess} errorMessage={scraperError} @@ -165,10 +212,18 @@ export default function CompetitorDashboardPage() { setSelectedProductId(event.target.value)} + onChange={(event) => { + setSelectedProductId(event.target.value) + setActiveScrapeRunId(null) + }} placeholder="Enter product id" disabled={isProductsLoading} /> diff --git a/frontend/src/pages/competitors/hooks/useScrapeRunStatus.ts b/frontend/src/pages/competitors/hooks/useScrapeRunStatus.ts new file mode 100644 index 0000000..b278e95 --- /dev/null +++ b/frontend/src/pages/competitors/hooks/useScrapeRunStatus.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query" + +import { getScrapeRunStatus } from "../services/competitorService" + +export const scrapeRunStatusQueryKey = (scrapeRunId: string) => [ + "scrape-runs", + scrapeRunId, +] + +export function useScrapeRunStatus(scrapeRunId?: string | null) { + return useQuery({ + queryKey: scrapeRunId + ? scrapeRunStatusQueryKey(scrapeRunId) + : ["scrape-runs", "empty"], + queryFn: () => getScrapeRunStatus(scrapeRunId ?? ""), + enabled: Boolean(scrapeRunId), + refetchInterval: (query) => { + const status = query.state.data?.status + return status === "pending" || status === "running" ? 5000 : false + }, + staleTime: 0, + }) +} diff --git a/frontend/src/pages/competitors/services/competitorService.ts b/frontend/src/pages/competitors/services/competitorService.ts index c3cd22b..026b762 100644 --- a/frontend/src/pages/competitors/services/competitorService.ts +++ b/frontend/src/pages/competitors/services/competitorService.ts @@ -4,6 +4,7 @@ import type { ApiCompetitor, Competitor, CompetitorApiResponse, + ScrapeRunStatusResponse, ScraperRunResponse, } from "../types/competitor.types" @@ -89,3 +90,16 @@ export async function runScraper(productId: string): Promise ) return data } + +export async function getScrapeRunStatus( + scrapeRunId: string +): Promise { + if (!scrapeRunId) { + throw new Error("Scrape run id is required to fetch status.") + } + + const { data } = await apiClient.get( + `/scraper/runs/${encodeURIComponent(scrapeRunId)}` + ) + return data +} diff --git a/frontend/src/pages/competitors/types/competitor.types.ts b/frontend/src/pages/competitors/types/competitor.types.ts index b0cebb5..1bddda1 100644 --- a/frontend/src/pages/competitors/types/competitor.types.ts +++ b/frontend/src/pages/competitors/types/competitor.types.ts @@ -72,8 +72,23 @@ export type CompetitorApiResponse = } export type ScraperRunResponse = { - message: string - status?: string - run_id?: string - triggered_at?: string + success: boolean + task_id: string + scrape_run_id: string + product_id: string + product_title: string + threshold: number + status: string +} + +export type ScrapeRunStatusResponse = { + scrape_run_id: string + product_id: string + product_title: string + status: string + started_at?: string | null + finished_at?: string | null + total_results?: number | null + successful_matches?: number | null + error_message?: string | null } diff --git a/frontend/src/pages/dashboard/dashboard-page.tsx b/frontend/src/pages/dashboard/dashboard-page.tsx index 9b4e46e..51b06da 100644 --- a/frontend/src/pages/dashboard/dashboard-page.tsx +++ b/frontend/src/pages/dashboard/dashboard-page.tsx @@ -73,7 +73,7 @@ export default function DashboardPage() {
Add product diff --git a/frontend/src/pages/products/product-form-page.tsx b/frontend/src/pages/products/product-form-page.tsx index 4fb4277..31eeb03 100644 --- a/frontend/src/pages/products/product-form-page.tsx +++ b/frontend/src/pages/products/product-form-page.tsx @@ -31,7 +31,7 @@ export default function ProductFormPage() {
View products diff --git a/frontend/src/pages/profile/profile-page.tsx b/frontend/src/pages/profile/profile-page.tsx index c7bc58d..25ffdf6 100644 --- a/frontend/src/pages/profile/profile-page.tsx +++ b/frontend/src/pages/profile/profile-page.tsx @@ -10,7 +10,7 @@ import { getErrorMessage } from "@/utils/errors" import { isValidPhone } from "@/utils/validation" export default function ProfilePage() { - const { user, updateProfile, isAuthenticating } = useAuth() + const { user, updateProfile, isAuthenticating, deleteAccount } = useAuth() const [formValues, setFormValues] = React.useState(() => ({ fullName: user?.fullName ?? "", businessName: user?.businessName ?? "", @@ -26,6 +26,13 @@ export default function ProfilePage() { >({}) const [formError, setFormError] = React.useState(null) const [successMessage, setSuccessMessage] = React.useState(null) + const [deletePassword, setDeletePassword] = React.useState("") + const [deleteFieldError, setDeleteFieldError] = React.useState( + undefined + ) + const [deleteError, setDeleteError] = React.useState(null) + const [isDeleting, setIsDeleting] = React.useState(false) + const isSaving = isAuthenticating && !isDeleting const validateForm = (values: ProfileInput) => { const errors: Partial> = {} @@ -87,6 +94,39 @@ export default function ProfilePage() { } } + const handleDeleteChange = (event: React.ChangeEvent) => { + setDeletePassword(event.target.value) + setDeleteFieldError(undefined) + setDeleteError(null) + } + + const handleDeleteSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setDeleteError(null) + setDeleteFieldError(undefined) + + if (!deletePassword.trim()) { + setDeleteFieldError("Password is required.") + return + } + + const confirmed = window.confirm( + "This will permanently delete your account and all data. This cannot be undone. Continue?" + ) + if (!confirmed) { + return + } + + setIsDeleting(true) + try { + await deleteAccount({ password: deletePassword }) + } catch (error) { + setDeleteError(getErrorMessage(error, "Unable to delete your account.")) + } finally { + setIsDeleting(false) + } + } + return (
+
+ +
+
+
+

Delete account

+

+ Permanently removes your seller profile, products, alerts, and history. +

+
+
+ + +
+
diff --git a/frontend/src/pages/telegram-alerts-page.tsx b/frontend/src/pages/telegram-alerts-page.tsx new file mode 100644 index 0000000..19c8a65 --- /dev/null +++ b/frontend/src/pages/telegram-alerts-page.tsx @@ -0,0 +1,167 @@ +import { useState } from "react" +import { ShieldCheck } from "lucide-react" + +import { PageHeader } from "@/components/common/page-header" +import { TelegramConnectCard } from "@/components/telegram/telegram-connect-card" +import { TelegramInstructionsModal } from "@/components/telegram/telegram-instructions-modal" +import { TelegramStatusBadge } from "@/components/telegram/telegram-status-badge" +import { TelegramAlertHistory } from "@/components/telegram/telegram-alert-history" +import { ProductSnapshotCard } from "@/components/telegram/product-snapshot-card" +import { useTelegramConnection } from "@/hooks/useTelegramConnection" +import { useTelegramAlerts } from "@/hooks/useTelegramAlerts" +import type { TelegramConnectionState } from "@/types/telegram" +import { getErrorMessage } from "@/utils/errors" + +const resolveConnectionState = ( + isConnected: boolean, + isLoading: boolean, + isError: boolean +): TelegramConnectionState => { + if (isError) { + return "error" + } + if (isLoading) { + return "connecting" + } + return isConnected ? "connected" : "not_connected" +} + +export default function TelegramAlertsPage() { + const { status, health, connectToken, disconnect } = useTelegramConnection() + const alerts = useTelegramAlerts() + const [isInstructionsOpen, setInstructionsOpen] = useState(false) + + const isConnected = status.data?.isConnected ?? false + const connectionState = resolveConnectionState( + isConnected, + status.isLoading || connectToken.isPending, + status.isError + ) + const botUsername = + status.data?.botUsername ?? connectToken.data?.botUsername ?? "@bazarsync_bot" + + const healthLabel = + health.isLoading + ? "Checking bot health..." + : health.isError || health.data?.status !== "ok" + ? "Bot offline" + : "Bot online" + + return ( +
+ + +
+
+ connectToken.mutate()} + onDisconnect={() => disconnect.mutate()} + onShowInstructions={() => setInstructionsOpen(true)} + /> + alerts.refetch()} + /> +
+ +
+
+
+
+

+ Connection status +

+

+ Track your Telegram connection health in real time. +

+
+ +
+
+
+ Bot + {botUsername} +
+
+ Health + {healthLabel} +
+
+ Connected since + + {status.data?.connectedAt + ? new Date(status.data.connectedAt).toLocaleString() + : "Not connected"} + +
+
+
+ + + +
+
+
+ +
+
+

+ Alert preferences +

+

+ Control which alerts are delivered to Telegram. Preferences will be + persisted in a future release. +

+
+
+
+ {[ + { id: "pricing-alerts", label: "Pricing and undercut alerts" }, + { id: "stock-alerts", label: "Stock risk alerts" }, + { id: "opportunity-alerts", label: "Revenue opportunity alerts" }, + ].map((item) => ( + + ))} +
+
+
+
+ + setInstructionsOpen(false)} + token={connectToken.data?.token} + expiresAt={connectToken.data?.expiresAt ?? null} + botUsername={botUsername} + isLoading={connectToken.isPending} + error={connectToken.error} + onRefresh={() => connectToken.mutate()} + /> +
+ ) +} diff --git a/frontend/src/pages/war-room-page.tsx b/frontend/src/pages/war-room-page.tsx new file mode 100644 index 0000000..309f3ae --- /dev/null +++ b/frontend/src/pages/war-room-page.tsx @@ -0,0 +1,325 @@ +import { useMemo } from "react" +import { useQueryClient } from "@tanstack/react-query" + +import { ErrorState } from "@/components/common/error-state" +import { DashboardSummaryHeader } from "@/components/dashboard/dashboard-summary-header" +import { UrgencyScoreWidget } from "@/components/dashboard/urgency-score-widget" +import { OpportunityMetricsPanel } from "@/components/dashboard/opportunity-metrics-panel" +import { CompetitorPriceChart } from "@/components/dashboard/competitor-price-chart" +import { MarketTrendWidget } from "@/components/dashboard/market-trend-widget" +import { MarketOpportunityCard } from "@/components/dashboard/market-opportunity-card" +import { RecommendationCard } from "@/components/dashboard/recommendation-card" +import { RealtimeActivityFeed } from "@/components/dashboard/realtime-feed" +import { StockAlertCard } from "@/components/dashboard/stock-alert-card" +import { AlertHistoryPanel } from "@/components/dashboard/alert-history-panel" +import { CompetitorTable } from "@/components/dashboard/competitor-table" +import { Button } from "@/components/ui/button" +import { useAlerts, useResolveAlert } from "@/hooks/useAlerts" +import { useCompetitors } from "@/hooks/useCompetitors" +import { useDashboard } from "@/hooks/useDashboard" +import { useLiveDashboard } from "@/hooks/useLiveDashboard" +import { useAuth } from "@/hooks/use-auth" + +export default function WarRoomPage() { + const queryClient = useQueryClient() + const { user } = useAuth() + const realtimeStatus = useLiveDashboard({ sellerId: user?.id }) + const { + summary, + marketTrends, + opportunityMetrics, + marketOpportunities, + recommendations, + activity, + competitorPriceTrend, + } = useDashboard() + const competitors = useCompetitors() + const { activeAlerts, alertHistory } = useAlerts() + const resolveAlert = useResolveAlert() + + const isRefreshing = + summary.isFetching || + marketTrends.isFetching || + opportunityMetrics.isFetching || + marketOpportunities.isFetching || + recommendations.isFetching || + activity.isFetching || + competitorPriceTrend.isFetching || + competitors.isFetching || + activeAlerts.isFetching || + alertHistory.isFetching + + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: ["war-room"] }) + } + + const topOpportunities = useMemo( + () => (marketOpportunities.data ?? []).slice(0, 3), + [marketOpportunities.data] + ) + const topRecommendations = useMemo( + () => (recommendations.data ?? []).slice(0, 2), + [recommendations.data] + ) + const recommendationHistory = useMemo( + () => (recommendations.data ?? []).slice(0, 4), + [recommendations.data] + ) + + return ( +
+ {summary.isError && !summary.data ? ( + summary.refetch()}> + Retry + + } + /> + ) : ( + + )} + +
+
+
+ + {opportunityMetrics.isError ? ( + opportunityMetrics.refetch()} + > + Retry + + } + /> + ) : ( + + )} +
+ +
+ competitorPriceTrend.refetch()} + /> + marketTrends.refetch()} + /> +
+ +
+
+
+

+ Market opportunities +

+ + {marketOpportunities.data?.length ?? 0} total + +
+ {marketOpportunities.isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ) : marketOpportunities.isError ? ( +
+ marketOpportunities.refetch()} + > + Retry + + } + /> +
+ ) : topOpportunities.length === 0 ? ( +
+ No live opportunities detected yet. +
+ ) : ( +
+ {topOpportunities.map((opportunity) => ( + + ))} +
+ )} +
+ +
+
+

+ Priority recommendations +

+ + {recommendations.data?.length ?? 0} total + +
+ {recommendations.isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ ) : recommendations.isError ? ( +
+ recommendations.refetch()} + > + Retry + + } + /> +
+ ) : topRecommendations.length === 0 ? ( +
+ AI recommendations will appear as they are generated. +
+ ) : ( +
+ {topRecommendations.map((recommendation) => ( + + ))} +
+ )} +
+
+
+ +
+ activity.refetch()} + /> + activeAlerts.refetch()} + /> +
+
+ +
+
+ alertHistory.refetch()} + onResolve={(alertId) => resolveAlert.mutate(alertId)} + resolvingId={ + resolveAlert.isPending ? resolveAlert.variables ?? null : null + } + /> +
+
+

+ Recommendation history +

+ + {recommendations.data?.length ?? 0} total + +
+ {recommendations.isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ) : recommendations.isError ? ( +
+ recommendations.refetch()} + > + Retry + + } + /> +
+ ) : recommendationHistory.length === 0 ? ( +
+ Recommendation history will populate as signals are issued. +
+ ) : ( +
+ {recommendationHistory.map((recommendation) => ( + + ))} +
+ )} +
+
+ competitors.refetch()} + /> +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index d65629d..9fcc3df 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -38,6 +38,12 @@ const router = createBrowserRouter([ .default, }), }, + { + path: "war-room", + lazy: async () => ({ + Component: (await import("@/pages/war-room-page")).default, + }), + }, { path: "competitors", lazy: async () => ({ @@ -86,11 +92,15 @@ const router = createBrowserRouter([ }), }, { - path: "alerts", + path: "telegram-alerts", lazy: async () => ({ - Component: (await import("@/pages/alerts/alerts-page")).default, + Component: (await import("@/pages/telegram-alerts-page")).default, }), }, + { + path: "alerts", + loader: () => redirect("/app/telegram-alerts"), + }, { path: "realtime", lazy: async () => ({ diff --git a/frontend/src/routes/nav-config.ts b/frontend/src/routes/nav-config.ts index ee71928..0d81338 100644 --- a/frontend/src/routes/nav-config.ts +++ b/frontend/src/routes/nav-config.ts @@ -1,9 +1,10 @@ import { Activity, - Bell, LayoutDashboard, LineChart, + MessageCircle, Package, + Shield, Sparkles, Users, } from "lucide-react" @@ -17,6 +18,12 @@ export const appNavItems: NavItem[] = [ icon: LayoutDashboard, description: "Core marketplace pulse", }, + { + label: "War Room", + path: "/app/war-room", + icon: Shield, + description: "Realtime competitor intelligence", + }, { label: "Products", path: "/app/products", @@ -40,17 +47,19 @@ export const appNavItems: NavItem[] = [ path: "/app/pricing", icon: LineChart, description: "Price elasticity insights", + comingSoon: true, }, { - label: "WhatsApp Alerts", - path: "/app/alerts", - icon: Bell, - description: "Seller notifications", + label: "Telegram Alerts", + path: "/app/telegram-alerts", + icon: MessageCircle, + description: "BazarSync AI Telegram alerts", }, { label: "Realtime Monitoring", path: "/app/realtime", icon: Activity, description: "Live marketplace tracking", + comingSoon: true, }, ] diff --git a/frontend/src/services/alert-service.ts b/frontend/src/services/alert-service.ts new file mode 100644 index 0000000..32e07e7 --- /dev/null +++ b/frontend/src/services/alert-service.ts @@ -0,0 +1,198 @@ +import apiClient from "@/api/client" +import { + AlertCategory, + AlertChannel, + AlertSeverity, + AlertStatus, +} from "@/types/alert" +import type { Alert, AlertSummary } from "@/types/alert" + +type PaginationMeta = { + total: number + page: number + page_size: number + total_pages: number + has_next: boolean + has_prev: boolean +} + +type PaginatedResponse = { + items: T[] + meta: PaginationMeta +} + +type ApiAlertRecord = { + alert_id: string + product_id: string + product_title: string + alert_type: string + urgency_level: string + action_type: string + confidence: string + delivery_status: string + delivery_timestamp?: string | null + telegram_reference?: string | null + is_read: boolean + read_at?: string | null + created_at: string +} + +const formatLabel = (value: string | null | undefined) => { + if (!value) { + return "alert" + } + return value.replace(/_/g, " ").toLowerCase() +} + +const mapSeverity = (urgencyLevel: string): AlertSeverity => { + switch (urgencyLevel?.toUpperCase()) { + case "CRITICAL": + return AlertSeverity.Critical + case "HIGH": + return AlertSeverity.High + case "MEDIUM": + return AlertSeverity.Medium + default: + return AlertSeverity.Low + } +} + +const mapStatus = (record: ApiAlertRecord): AlertStatus => { + if (record.is_read || record.read_at) { + return AlertStatus.Resolved + } + if (record.delivery_status === "delivered") { + return AlertStatus.Acknowledged + } + return AlertStatus.New +} + +const mapCategory = (record: ApiAlertRecord): AlertCategory => { + const actionType = record.action_type?.toUpperCase() + if (actionType === "STOCK_WARNING" || actionType === "REORDER") { + return AlertCategory.StockRisk + } + if (actionType?.startsWith("PRICE")) { + return AlertCategory.PriceWar + } + if (actionType === "OPPORTUNITY_CAPTURE") { + return AlertCategory.Opportunity + } + if (record.alert_type?.toLowerCase().includes("system")) { + return AlertCategory.System + } + return AlertCategory.CompetitorMove +} + +const mapChannel = (record: ApiAlertRecord): AlertChannel => { + if (record.telegram_reference) { + return AlertChannel.Telegram + } + return AlertChannel.Dashboard +} + +const mapUrgencyScore = (urgencyLevel: string) => { + switch (urgencyLevel?.toUpperCase()) { + case "CRITICAL": + return 90 + case "HIGH": + return 75 + case "MEDIUM": + return 55 + default: + return 30 + } +} + +const mapAlert = (alert: ApiAlertRecord): Alert => ({ + id: alert.alert_id, + title: `${formatLabel(alert.action_type).replace( + /^./, + (char) => char.toUpperCase() + )} for ${alert.product_title}`, + message: `${formatLabel(alert.alert_type)} alert, ${formatLabel( + alert.urgency_level + )} urgency.`, + severity: mapSeverity(alert.urgency_level), + status: mapStatus(alert), + category: mapCategory(alert), + channel: mapChannel(alert), + product: alert.product_title, + competitor: null, + urgencyScore: mapUrgencyScore(alert.urgency_level), + createdAt: alert.created_at, +}) + +const fetchAlerts = async ( + params: Record, + signal?: AbortSignal +) => { + const { data } = await apiClient.get>( + "/dashboard/alerts", + { + signal, + params, + } + ) + return data +} + +export async function getActiveAlerts( + signal?: AbortSignal +): Promise { + const data = await fetchAlerts( + { + is_read: false, + sort_by: "created_at", + sort_direction: "desc", + page_size: 100, + }, + signal + ) + return data.items.map(mapAlert) +} + +export async function getAlertHistory( + signal?: AbortSignal +): Promise { + const data = await fetchAlerts( + { + sort_by: "created_at", + sort_direction: "desc", + page_size: 200, + }, + signal + ) + return data.items.map(mapAlert) +} + +export async function getAlertSummary( + signal?: AbortSignal +): Promise { + const [active, critical, latest] = await Promise.all([ + fetchAlerts({ is_read: false, page_size: 1 }, signal), + fetchAlerts({ urgency_level: "CRITICAL", page_size: 1 }, signal), + fetchAlerts( + { sort_by: "created_at", sort_direction: "desc", page_size: 1 }, + signal + ), + ]) + return { + activeCount: active.meta.total, + criticalCount: critical.meta.total, + lastTriggeredAt: latest.items[0]?.created_at ?? null, + } +} + +export async function resolveAlert( + alertId: string +): Promise<{ id: string; status: AlertStatus }> { + const { data } = await apiClient.patch<{ id: string; status: string }>( + `/dashboard/alerts/${alertId}/resolve` + ) + return { + id: data.id, + status: + data.status === "resolved" ? AlertStatus.Resolved : AlertStatus.New, + } +} \ No newline at end of file diff --git a/frontend/src/services/auth-service.ts b/frontend/src/services/auth-service.ts index 1a945d9..d9b0804 100644 --- a/frontend/src/services/auth-service.ts +++ b/frontend/src/services/auth-service.ts @@ -1,6 +1,7 @@ import apiClient from "@/api/client" import type { AuthResponse, + DeleteAccountInput, LoginInput, ProfileInput, RegisterInput, @@ -95,3 +96,14 @@ export async function updateProfile(input: ProfileInput): Promise { const { data } = await apiClient.patch("/auth/profile", payload) return mapUser(data) } + +type ApiDeleteAccountPayload = { + password: string +} + +export async function deleteAccount(input: DeleteAccountInput): Promise { + const payload: ApiDeleteAccountPayload = { + password: input.password, + } + await apiClient.post("/auth/delete-account", payload) +} diff --git a/frontend/src/services/dashboard-service.ts b/frontend/src/services/dashboard-service.ts index 13e139e..16f420e 100644 --- a/frontend/src/services/dashboard-service.ts +++ b/frontend/src/services/dashboard-service.ts @@ -1,7 +1,382 @@ import apiClient from "@/api/client" -import type { DashboardSummary } from "@/types/dashboard" +import { SystemStatus } from "@/types/dashboard" +import { + CompetitorStatus, + CompetitorTrend, + StockStatus, +} from "@/types/competitor" +import { + RecommendationAction, + RecommendationPriority, + RecommendationStatus, +} from "@/types/recommendation" +import type { + ActivityItem, + DashboardSummary, + Insight, + MarketOpportunity, + MarketTrendPoint, + OpportunityMetric, +} from "@/types/dashboard" +import type { Competitor, CompetitorPricePoint } from "@/types/competitor" +import type { Recommendation } from "@/types/recommendation" -export async function getDashboardSummary(): Promise { - const { data } = await apiClient.get("/dashboard/summary") - return data +type PaginationMeta = { + total: number + page: number + page_size: number + total_pages: number + has_next: boolean + has_prev: boolean +} + +type PaginatedResponse = { + items: T[] + meta: PaginationMeta +} + +type ApiDashboardSummary = { + total_monitored_products: number + active_alerts: number + opportunity_count: number + high_risk_stock_count: number + average_urgency_score: number + average_competitor_price_gap: number + market_trend_summary: string + realtime_activity: { + recommendations_last_hour: number + alerts_last_hour: number + competitor_updates_last_hour: number + stock_risk_events_last_hour: number + } + generated_at: string +} + +type ApiRecommendationRecord = { + recommendation_id: string + product_title: string + action_type: string + confidence: string + urgency_score?: number | null + price_gap_percent?: number | null + stock_risk_score?: number | null + reasoning?: string | null + revenue_opportunity?: number | null + created_at: string +} + +type ApiCompetitorRecord = { + competitor_id: string + product_title: string + competitor_title: string + competitor_price?: number | null + seller_price?: number | null + price_gap_percent?: number | null + stock_status?: string | null + price_trend?: string | null + created_at: string +} + +type ApiOpportunityRecord = { + recommendation_id: string + product_title: string + revenue_opportunity?: number | null + inventory_capture_units?: number | null + projected_demand_shift?: number | null + estimated_profit_impact?: number | null + competitor_oos_ratio?: number | null + market_opportunity_score?: number | null + created_at: string +} + +const mapSystemStatus = (summary: ApiDashboardSummary): SystemStatus => { + if (summary.high_risk_stock_count > 0 || summary.active_alerts > 0) { + return SystemStatus.Degraded + } + return SystemStatus.Healthy +} + +const buildInsights = (summary: ApiDashboardSummary): Insight[] => { + const insights: Insight[] = [] + if (summary.market_trend_summary) { + const severity: Insight["severity"] = + summary.average_urgency_score >= 75 + ? "high" + : summary.average_urgency_score >= 55 + ? "medium" + : "low" + insights.push({ + id: "market-trend", + title: "Market trend", + summary: summary.market_trend_summary, + severity, + }) + } + const activity = summary.realtime_activity + if (activity) { + insights.push({ + id: "realtime-activity", + title: "Last hour activity", + summary: `Recommendations ${activity.recommendations_last_hour}, alerts ${activity.alerts_last_hour}, competitor updates ${activity.competitor_updates_last_hour}.`, + severity: "low", + }) + } + return insights +} + +const mapRecommendationAction = (action: string): RecommendationAction => { + switch (action?.toUpperCase()) { + case "LOWER": + return RecommendationAction.Undercut + case "RAISE": + return RecommendationAction.RaisePrice + case "REORDER": + return RecommendationAction.Restock + case "HOLD": + default: + return RecommendationAction.Hold + } +} + +const mapRecommendationPriority = ( + urgencyScore?: number | null +): RecommendationPriority => { + const score = urgencyScore ?? 0 + if (score >= 80) return RecommendationPriority.Critical + if (score >= 60) return RecommendationPriority.High + if (score >= 40) return RecommendationPriority.Medium + return RecommendationPriority.Low +} + +const mapRecommendationConfidence = (confidence: string): number => { + switch (confidence?.toUpperCase()) { + case "HIGH": + return 0.9 + case "MEDIUM": + return 0.7 + case "LOW": + return 0.5 + default: + return 0.6 + } +} + +const formatRecommendationTitle = (actionType: string) => { + switch (actionType?.toUpperCase()) { + case "LOWER": + return "Lower price recommendation" + case "RAISE": + return "Raise price recommendation" + case "REORDER": + return "Restock recommendation" + default: + return "Hold price recommendation" + } +} + +const mapStockStatus = (status?: string | null): StockStatus => { + switch (status) { + case "out_of_stock": + return StockStatus.OutOfStock + case "low_stock": + return StockStatus.LowStock + default: + return StockStatus.InStock + } +} + +const mapCompetitorTrend = (trend?: string | null): CompetitorTrend => { + switch (trend) { + case "down": + return CompetitorTrend.Down + case "up": + return CompetitorTrend.Up + default: + return CompetitorTrend.Flat + } +} + +const mapCompetitorStatus = (): CompetitorStatus => CompetitorStatus.Active + +const toNumber = (value?: number | null) => (value == null ? 0 : Number(value)) + +const computeCompetitorUrgencyScore = ( + priceGapPercent?: number | null, + stockStatus?: string | null +) => { + const base = Math.min(Math.abs(priceGapPercent ?? 0) * 2, 100) + if (stockStatus === "out_of_stock") { + return Math.max(base, 85) + } + if (stockStatus === "low_stock") { + return Math.max(base, 65) + } + return Math.round(base) +} + +const resolveRecommendationActionLabel = ( + sellerPrice?: number | null, + competitorPrice?: number | null +) => { + if (sellerPrice == null || competitorPrice == null) { + return "Hold" + } + if (sellerPrice > competitorPrice) { + return "Lower price" + } + if (sellerPrice < competitorPrice) { + return "Raise price" + } + return "Hold" +} + +const resolveOpportunityCategory = ( + record: ApiOpportunityRecord +): MarketOpportunity["category"] => { + const oosRatio = record.competitor_oos_ratio ?? 0 + const demandShift = record.projected_demand_shift ?? 0 + if (oosRatio >= 0.5) { + return "inventory" + } + if (demandShift >= 15) { + return "promotion" + } + return "pricing" +} + +const buildOpportunityDescription = (record: ApiOpportunityRecord) => { + const units = Math.round(record.inventory_capture_units ?? 0) + const profitImpact = Math.round(record.estimated_profit_impact ?? 0) + return `Capture ${units} units, est. profit impact ${profitImpact}` +} + +const buildOpportunityTimeWindow = (createdAt: string) => { + if (!createdAt) { + return "Rolling" + } + return new Date(createdAt).toLocaleDateString() +} + +export async function getDashboardSummary( + signal?: AbortSignal +): Promise { + const { data } = await apiClient.get("/dashboard/summary", { + signal, + }) + return { + kpis: [], + insights: buildInsights(data), + activeAlerts: data.active_alerts, + liveCompetitors: data.total_monitored_products, + aiRecommendations: data.realtime_activity.recommendations_last_hour, + revenueOpportunities: data.opportunity_count, + urgencyScore: data.average_urgency_score, + systemStatus: mapSystemStatus(data), + lastSyncedAt: data.generated_at, + } +} + +export async function getMarketTrends( + signal?: AbortSignal +): Promise { + void signal + return [] +} + +export async function getOpportunityMetrics( + signal?: AbortSignal +): Promise { + void signal + return [] +} + +export async function getMarketOpportunities( + signal?: AbortSignal +): Promise { + const { data } = await apiClient.get>( + "/dashboard/market-opportunities", + { signal } + ) + return data.items.map((opportunity) => ({ + id: opportunity.recommendation_id, + title: opportunity.product_title, + description: buildOpportunityDescription(opportunity), + potentialRevenue: toNumber(opportunity.revenue_opportunity), + probability: opportunity.competitor_oos_ratio ?? 0, + urgencyScore: Math.min( + Math.max(Math.round(opportunity.market_opportunity_score ?? 0), 0), + 100 + ), + timeWindow: buildOpportunityTimeWindow(opportunity.created_at), + category: resolveOpportunityCategory(opportunity), + })) +} + +export async function getActivityFeed( + signal?: AbortSignal +): Promise { + void signal + return [] +} + +export async function getRecommendations( + signal?: AbortSignal +): Promise { + const { data } = await apiClient.get>( + "/dashboard/recommendations", + { signal } + ) + return data.items.map((rec) => ({ + id: rec.recommendation_id, + title: formatRecommendationTitle(rec.action_type), + summary: rec.reasoning || "Recommendation generated from live signals.", + action: mapRecommendationAction(rec.action_type), + status: RecommendationStatus.New, + priority: mapRecommendationPriority(rec.urgency_score), + confidence: mapRecommendationConfidence(rec.confidence), + expectedLift: Math.min(Math.max((rec.urgency_score ?? 0) / 100, 0), 1), + product: rec.product_title, + competitor: null, + createdAt: rec.created_at, + })) +} + +export async function getCompetitors( + signal?: AbortSignal +): Promise { + const { data } = await apiClient.get>( + "/dashboard/competitors", + { signal } + ) + return data.items.map((competitor) => { + const competitorPrice = toNumber(competitor.competitor_price) + const sellerPrice = toNumber(competitor.seller_price) + return { + id: competitor.competitor_id, + product: competitor.product_title, + competitor: competitor.competitor_title, + competitorPrice, + sellerPrice, + priceGap: sellerPrice - competitorPrice, + stockStatus: mapStockStatus(competitor.stock_status), + urgencyScore: computeCompetitorUrgencyScore( + competitor.price_gap_percent, + competitor.stock_status + ), + recommendationAction: resolveRecommendationActionLabel( + competitor.seller_price, + competitor.competitor_price + ), + trend: mapCompetitorTrend(competitor.price_trend), + lastUpdated: competitor.created_at, + status: mapCompetitorStatus(), + } + }) +} + +export async function getCompetitorPriceTrend( + signal?: AbortSignal +): Promise { + void signal + return [] } diff --git a/frontend/src/services/socket-service.ts b/frontend/src/services/socket-service.ts new file mode 100644 index 0000000..3d0c8c3 --- /dev/null +++ b/frontend/src/services/socket-service.ts @@ -0,0 +1,67 @@ +import type { Socket } from "socket.io-client" + +import { getSocket, refreshSocketAuth } from "@/lib/socket" +import type { + RoomSubscriptionRequest, + RealtimeClientEvents, + RealtimeServerEvents, +} from "@/types/realtime" + +export type RealtimeHandlers = Partial + +export const connectRealtime = (): Socket< + RealtimeServerEvents, + RealtimeClientEvents +> => { + const socket = getSocket() + refreshSocketAuth() + + if (!socket.connected) { + socket.connect() + } + + return socket +} + +export const disconnectRealtime = () => { + const socket = getSocket() + if (socket.connected) { + socket.disconnect() + } +} + +export const subscribeRooms = (payload: RoomSubscriptionRequest) => { + const socket = getSocket() + socket.emit("subscribe_rooms", payload) +} + +export const unsubscribeRooms = (payload: RoomSubscriptionRequest) => { + const socket = getSocket() + socket.emit("unsubscribe_rooms", payload) +} + +export const sendHeartbeat = () => { + const socket = getSocket() + socket.emit("client_ping", { timestamp: Date.now() }) +} + +export const registerRealtimeHandlers = (handlers: RealtimeHandlers) => { + const socket = getSocket() + const entries = Object.entries(handlers) as Array< + [keyof RealtimeServerEvents, RealtimeHandlers[keyof RealtimeServerEvents]] + > + + entries.forEach(([event, handler]) => { + if (handler) { + socket.on(event, handler) + } + }) + + return () => { + entries.forEach(([event, handler]) => { + if (handler) { + socket.off(event, handler) + } + }) + } +} \ No newline at end of file diff --git a/frontend/src/services/telegram-service.ts b/frontend/src/services/telegram-service.ts new file mode 100644 index 0000000..4cf2e90 --- /dev/null +++ b/frontend/src/services/telegram-service.ts @@ -0,0 +1,194 @@ +import apiClient from "@/api/client" +import type { + PaginationMeta, + TelegramAlertHistoryResponse, + TelegramAlertRecord, + TelegramConnectToken, + TelegramConnectionStatus, + TelegramHealth, + TelegramTestAlertResult, +} from "@/types/telegram" + +type ApiTelegramConnectToken = { + token: string + expires_at: string + bot_username?: string | null +} + +type ApiTelegramConnectionStatus = { + is_connected: boolean + connected_at?: string | null + telegram_username?: string | null + telegram_first_name?: string | null + telegram_last_name?: string | null + last_alert_sent_at?: string | null + bot_username?: string | null +} + +type ApiTelegramHealth = { + status: string + bot_username?: string | null + bot_id?: number | null +} + +type ApiTelegramDisconnectResponse = { + status: string +} + +type ApiPaginationMeta = { + total: number + page: number + page_size: number + total_pages: number + has_next: boolean + has_prev: boolean +} + +type ApiAlertRecord = { + alert_id: string + product_id: string + product_title: string + alert_type: string + urgency_level: string + action_type: string + confidence: string + delivery_status: string + delivery_timestamp?: string | null + telegram_reference?: string | null + is_read: boolean + read_at?: string | null + created_at: string +} + +type ApiAlertHistoryResponse = { + items: ApiAlertRecord[] + meta: ApiPaginationMeta +} + +type ApiTelegramTestAlertResponse = { + success: boolean + status: string + detail?: string | null +} + +const mapConnectToken = (data: ApiTelegramConnectToken): TelegramConnectToken => ({ + token: data.token, + expiresAt: data.expires_at, + botUsername: data.bot_username ?? null, +}) + +const mapStatus = ( + data: ApiTelegramConnectionStatus +): TelegramConnectionStatus => ({ + isConnected: data.is_connected, + connectedAt: data.connected_at ?? null, + telegramUsername: data.telegram_username ?? null, + telegramFirstName: data.telegram_first_name ?? null, + telegramLastName: data.telegram_last_name ?? null, + lastAlertSentAt: data.last_alert_sent_at ?? null, + botUsername: data.bot_username ?? null, +}) + +const mapHealth = (data: ApiTelegramHealth): TelegramHealth => ({ + status: data.status, + botUsername: data.bot_username ?? null, + botId: data.bot_id ?? null, +}) + +const formatLabel = (value: string | null | undefined) => { + if (!value) { + return "alert" + } + return value.replace(/_/g, " ").toLowerCase() +} + +const mapAlertRecord = (record: ApiAlertRecord): TelegramAlertRecord => ({ + id: record.alert_id, + alertType: formatLabel(record.alert_type), + product: record.product_title, + action: formatLabel(record.action_type), + sentAt: record.delivery_timestamp ?? record.created_at, + deliveryStatus: record.delivery_status, + isTelegram: Boolean(record.telegram_reference), +}) + +const mapPaginationMeta = (meta: ApiPaginationMeta): PaginationMeta => ({ + total: meta.total, + page: meta.page, + pageSize: meta.page_size, + totalPages: meta.total_pages, + hasNext: meta.has_next, + hasPrev: meta.has_prev, +}) + +export async function getTelegramStatus( + signal?: AbortSignal +): Promise { + const { data } = await apiClient.get( + "/telegram/status", + { signal } + ) + return mapStatus(data) +} + +export async function createTelegramConnectToken(): Promise { + const { data } = await apiClient.post( + "/telegram/connect-token" + ) + return mapConnectToken(data) +} + +export async function disconnectTelegram(): Promise { + const { data } = await apiClient.post( + "/telegram/disconnect" + ) + return data +} + +export async function getTelegramHealth( + signal?: AbortSignal +): Promise { + const { data } = await apiClient.get("/telegram/health", { + signal, + }) + return mapHealth(data) +} + +export async function getTelegramAlertHistory( + { + page, + pageSize, + }: { + page: number + pageSize: number + }, + signal?: AbortSignal +): Promise { + const { data } = await apiClient.get("/dashboard/alerts", { + signal, + params: { + sort_by: "created_at", + sort_direction: "desc", + page, + page_size: pageSize, + }, + }) + return { + items: data.items.map(mapAlertRecord), + meta: mapPaginationMeta(data.meta), + } +} + +export async function sendTelegramTestAlert( + customMessage?: string +): Promise { + const { data } = await apiClient.post("/alerts/test", { + custom_message: + customMessage ?? "BazarSync AI Telegram alert test for your account.", + }) + return { + success: data.success, + status: data.status, + detail: data.detail ?? null, + } +} diff --git a/frontend/src/services/telegram-snapshot-service.ts b/frontend/src/services/telegram-snapshot-service.ts new file mode 100644 index 0000000..131f9ef --- /dev/null +++ b/frontend/src/services/telegram-snapshot-service.ts @@ -0,0 +1,29 @@ +import apiClient from "@/api/client" +import type { ProductSnapshotSendResponse } from "@/types/product-snapshot" + +type ApiProductSnapshotResponse = { + success: boolean + message: string + sent_at?: string | null + delivery_status?: string | null +} + +export async function sendProductSnapshot( + productId: string +): Promise { + const { data } = await apiClient.post( + "/telegram/send-product-snapshot", + { + product_id: productId, + }, + { + timeout: 30000, + } + ) + return { + success: data.success, + message: data.message, + sentAt: data.sent_at ?? null, + deliveryStatus: data.delivery_status ?? null, + } +} diff --git a/frontend/src/types/alert.ts b/frontend/src/types/alert.ts new file mode 100644 index 0000000..27bb65c --- /dev/null +++ b/frontend/src/types/alert.ts @@ -0,0 +1,54 @@ +export const AlertSeverity = { + Low: "low", + Medium: "medium", + High: "high", + Critical: "critical", +} as const + +export type AlertSeverity = (typeof AlertSeverity)[keyof typeof AlertSeverity] + +export const AlertStatus = { + New: "new", + Acknowledged: "acknowledged", + Resolved: "resolved", +} as const + +export type AlertStatus = (typeof AlertStatus)[keyof typeof AlertStatus] + +export const AlertCategory = { + StockRisk: "stock_risk", + PriceWar: "price_war", + CompetitorMove: "competitor_move", + Opportunity: "opportunity", + System: "system", +} as const + +export type AlertCategory = (typeof AlertCategory)[keyof typeof AlertCategory] + +export const AlertChannel = { + Telegram: "telegram", + Dashboard: "dashboard", + Email: "email", +} as const + +export type AlertChannel = (typeof AlertChannel)[keyof typeof AlertChannel] + +export type Alert = { + id: string + title: string + message: string + severity: AlertSeverity + status: AlertStatus + category: AlertCategory + channel: AlertChannel + product?: string | null + competitor?: string | null + urgencyScore: number + createdAt: string +} + +export type AlertSummary = { + activeCount: number + criticalCount: number + lastTriggeredAt: string | null +} \ No newline at end of file diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index dea139e..dd8bbb1 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -30,6 +30,10 @@ export type AuthResponse = { user: User } +export type DeleteAccountInput = { + password: string +} + export type ProfileInput = { fullName: string businessName: string @@ -52,5 +56,6 @@ export type AuthContextValue = AuthState & { login: (input: LoginInput) => Promise register: (input: RegisterInput) => Promise updateProfile: (input: ProfileInput) => Promise + deleteAccount: (input: DeleteAccountInput) => Promise logout: () => void } diff --git a/frontend/src/types/competitor.ts b/frontend/src/types/competitor.ts new file mode 100644 index 0000000..950b8d7 --- /dev/null +++ b/frontend/src/types/competitor.ts @@ -0,0 +1,47 @@ +export const StockStatus = { + InStock: "in_stock", + LowStock: "low_stock", + OutOfStock: "out_of_stock", +} as const + +export type StockStatus = (typeof StockStatus)[keyof typeof StockStatus] + +export const CompetitorTrend = { + Up: "up", + Down: "down", + Flat: "flat", +} as const + +export type CompetitorTrend = + (typeof CompetitorTrend)[keyof typeof CompetitorTrend] + +export const CompetitorStatus = { + Active: "active", + Monitoring: "monitoring", + Paused: "paused", +} as const + +export type CompetitorStatus = + (typeof CompetitorStatus)[keyof typeof CompetitorStatus] + +export type Competitor = { + id: string + product: string + competitor: string + competitorPrice: number + sellerPrice: number + priceGap: number + stockStatus: StockStatus + urgencyScore: number + recommendationAction: string + trend: CompetitorTrend + lastUpdated: string + status: CompetitorStatus +} + +export type CompetitorPricePoint = { + timestamp: string + competitorPrice: number + sellerPrice: number + marketAverage: number +} diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 39c41d2..2eef98a 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -1,4 +1,18 @@ -export type KpiTrend = "up" | "down" | "flat" +export const KpiTrend = { + Up: "up", + Down: "down", + Flat: "flat", +} as const + +export type KpiTrend = (typeof KpiTrend)[keyof typeof KpiTrend] + +export const SystemStatus = { + Healthy: "healthy", + Degraded: "degraded", + Offline: "offline", +} as const + +export type SystemStatus = (typeof SystemStatus)[keyof typeof SystemStatus] export type KpiMetric = { label: string @@ -7,14 +21,94 @@ export type KpiMetric = { trend: KpiTrend } +export type InsightSeverity = "low" | "medium" | "high" + export type Insight = { id: string title: string summary: string - severity: "low" | "medium" | "high" + severity: InsightSeverity } export type DashboardSummary = { kpis: KpiMetric[] insights: Insight[] + activeAlerts: number + liveCompetitors: number + aiRecommendations: number + revenueOpportunities: number + urgencyScore: number + systemStatus: SystemStatus + lastSyncedAt: string +} + +export type MarketTrendPoint = { + timestamp: string + demandIndex: number + urgencyIndex: number + opportunityIndex: number + priceIndex: number +} + +export type OpportunityMetric = { + id: string + label: string + value: string + delta: string + trend: KpiTrend +} + +export type MarketOpportunity = { + id: string + title: string + description: string + potentialRevenue: number + probability: number + urgencyScore: number + timeWindow: string + category: "pricing" | "inventory" | "promotion" | "expansion" +} + +export const ActivitySeverity = { + Info: "info", + Warning: "warning", + Critical: "critical", +} as const + +export type ActivitySeverity = + (typeof ActivitySeverity)[keyof typeof ActivitySeverity] + +export type ActivityItem = { + id: string + title: string + description: string + timestamp: string + category: "recommendation" | "alert" | "competitor" | "system" + severity: ActivitySeverity +} + +export type RealtimeStatus = { + connected: boolean + reconnecting: boolean + lastEventAt: string | null + transport?: string + latencyMs?: number | null + sessionId?: string + rooms?: string[] + lastError?: string +} + +export type StockRiskEvent = { + alertId: string + product: string + competitor?: string | null + riskScore: number + message: string + createdAt: string +} + +export type SystemStatusUpdate = { + status: SystemStatus + message: string + updatedAt: string } diff --git a/frontend/src/types/navigation.ts b/frontend/src/types/navigation.ts index 995ebb3..140e039 100644 --- a/frontend/src/types/navigation.ts +++ b/frontend/src/types/navigation.ts @@ -5,4 +5,5 @@ export type NavItem = { path: string icon: LucideIcon description?: string + comingSoon?: boolean } diff --git a/frontend/src/types/product-snapshot.ts b/frontend/src/types/product-snapshot.ts new file mode 100644 index 0000000..b614c92 --- /dev/null +++ b/frontend/src/types/product-snapshot.ts @@ -0,0 +1,12 @@ +export type ProductSnapshotSendResponse = { + success: boolean + message: string + sentAt?: string | null + deliveryStatus?: string | null +} + +export type ProductSnapshotErrorResponse = { + success: boolean + detail: string + errorCode?: string | null +} diff --git a/frontend/src/types/realtime.ts b/frontend/src/types/realtime.ts new file mode 100644 index 0000000..02bfdd3 --- /dev/null +++ b/frontend/src/types/realtime.ts @@ -0,0 +1 @@ +export * from "@/types/websocket" diff --git a/frontend/src/types/recommendation.ts b/frontend/src/types/recommendation.ts new file mode 100644 index 0000000..c0de883 --- /dev/null +++ b/frontend/src/types/recommendation.ts @@ -0,0 +1,45 @@ +export const RecommendationAction = { + RaisePrice: "raise_price", + MatchPrice: "match_price", + Undercut: "undercut", + Bundle: "bundle", + Restock: "restock", + Hold: "hold", +} as const + +export type RecommendationAction = + (typeof RecommendationAction)[keyof typeof RecommendationAction] + +export const RecommendationStatus = { + New: "new", + Reviewed: "reviewed", + Implemented: "implemented", + Dismissed: "dismissed", +} as const + +export type RecommendationStatus = + (typeof RecommendationStatus)[keyof typeof RecommendationStatus] + +export const RecommendationPriority = { + Low: "low", + Medium: "medium", + High: "high", + Critical: "critical", +} as const + +export type RecommendationPriority = + (typeof RecommendationPriority)[keyof typeof RecommendationPriority] + +export type Recommendation = { + id: string + title: string + summary: string + action: RecommendationAction + status: RecommendationStatus + priority: RecommendationPriority + confidence: number + expectedLift: number + product: string + competitor?: string | null + createdAt: string +} \ No newline at end of file diff --git a/frontend/src/types/telegram.ts b/frontend/src/types/telegram.ts new file mode 100644 index 0000000..0f33785 --- /dev/null +++ b/frontend/src/types/telegram.ts @@ -0,0 +1,57 @@ +export type TelegramConnectToken = { + token: string + expiresAt: string + botUsername?: string | null +} + +export type TelegramConnectionStatus = { + isConnected: boolean + connectedAt?: string | null + telegramUsername?: string | null + telegramFirstName?: string | null + telegramLastName?: string | null + lastAlertSentAt?: string | null + botUsername?: string | null +} + +export type TelegramHealth = { + status: string + botUsername?: string | null + botId?: number | null +} + +export type TelegramConnectionState = + | "not_connected" + | "connecting" + | "connected" + | "error" + +export type TelegramAlertRecord = { + id: string + alertType: string + product: string + action: string + sentAt: string + deliveryStatus: string + isTelegram: boolean +} + +export type PaginationMeta = { + total: number + page: number + pageSize: number + totalPages: number + hasNext: boolean + hasPrev: boolean +} + +export type TelegramAlertHistoryResponse = { + items: TelegramAlertRecord[] + meta: PaginationMeta +} + +export type TelegramTestAlertResult = { + success: boolean + status: string + detail?: string | null +} diff --git a/frontend/src/types/websocket.ts b/frontend/src/types/websocket.ts new file mode 100644 index 0000000..2bae96e --- /dev/null +++ b/frontend/src/types/websocket.ts @@ -0,0 +1,245 @@ +import type { Alert } from "@/types/alert" +import type { Competitor } from "@/types/competitor" +import type { DashboardSummary, MarketOpportunity } from "@/types/dashboard" +import type { Recommendation } from "@/types/recommendation" + +export const RealtimeEventName = { + RecommendationCreated: "recommendation_created", + AlertSent: "alert_sent", + ProductSnapshotSent: "product_snapshot_sent", + AlertFailed: "alert_failed", + CompetitorUpdated: "competitor_updated", + StockRiskDetected: "stock_risk_detected", + UrgencyScoreUpdated: "urgency_score_updated", + DashboardSummaryUpdated: "dashboard_summary_updated", + MarketOpportunityDetected: "market_opportunity_detected", + TelegramConnected: "telegram_connected", + TelegramDisconnected: "telegram_disconnected", + ConnectionEstablished: "connection_established", + RoomsUpdated: "rooms_updated", + ServerPong: "server_pong", + RealtimeError: "realtime_error", +} as const + +export type RealtimeEventName = + (typeof RealtimeEventName)[keyof typeof RealtimeEventName] + +export type RealtimeEventEnvelope = { + event: TEvent + eventId: string + version: number + timestamp: string + sellerId?: string | null + productId?: string | null + payload: TPayload +} + +export type RecommendationCreatedPayload = { + recommendation: Recommendation + urgencyScore: number + confidence: "LOW" | "MEDIUM" | "HIGH" + opportunityScore: number +} + +export type AlertSentPayload = { + alert: Alert +} + +export type ProductSnapshotSentPayload = { + sellerId: string + productId: string + timestamp: string + productTitle?: string | null +} + +export type AlertFailedPayload = { + alertId: string + status: string + reason?: string | null + telegramChatId?: string | null +} + +export type CompetitorUpdatedPayload = { + competitor: Competitor +} + +export type StockRiskDetectedPayload = { + alert: Alert + riskScore: number +} + +export type UrgencyScoreUpdatedPayload = { + urgencyScore: number + previousScore?: number | null + delta?: number | null + updatedAt: string + reason?: string | null +} + +export type DashboardSummaryUpdatedPayload = { + summary: DashboardSummary +} + +export type MarketOpportunityDetectedPayload = { + opportunity: MarketOpportunity +} + +export type TelegramConnectionSnapshot = { + id: string + isConnected: boolean + telegramUsername?: string | null + telegramFirstName?: string | null + telegramLastName?: string | null + connectedAt?: string | null + lastAlertSentAt?: string | null +} + +export type TelegramConnectionPayload = { + connection: TelegramConnectionSnapshot +} + +export type ConnectionEstablishedPayload = { + sessionId: string + sid: string + rooms: string[] + heartbeatInterval: number + heartbeatTimeout: number +} + +export type RoomsUpdatedPayload = { + rooms: string[] + action: "joined" | "left" | "restored" +} + +export type ServerPongPayload = { + clientTimestamp: number + serverTimestamp: number + latencyMs?: number | null +} + +export type RealtimeErrorPayload = { + code: string + message: string + details?: Record +} + +export type RecommendationCreatedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.RecommendationCreated, + RecommendationCreatedPayload +> +export type AlertSentEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.AlertSent, + AlertSentPayload +> +export type ProductSnapshotSentEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.ProductSnapshotSent, + ProductSnapshotSentPayload +> +export type AlertFailedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.AlertFailed, + AlertFailedPayload +> +export type CompetitorUpdatedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.CompetitorUpdated, + CompetitorUpdatedPayload +> +export type StockRiskDetectedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.StockRiskDetected, + StockRiskDetectedPayload +> +export type UrgencyScoreUpdatedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.UrgencyScoreUpdated, + UrgencyScoreUpdatedPayload +> +export type DashboardSummaryUpdatedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.DashboardSummaryUpdated, + DashboardSummaryUpdatedPayload +> +export type MarketOpportunityDetectedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.MarketOpportunityDetected, + MarketOpportunityDetectedPayload +> +export type TelegramConnectedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.TelegramConnected, + TelegramConnectionPayload +> +export type TelegramDisconnectedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.TelegramDisconnected, + TelegramConnectionPayload +> +export type ConnectionEstablishedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.ConnectionEstablished, + ConnectionEstablishedPayload +> +export type RoomsUpdatedEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.RoomsUpdated, + RoomsUpdatedPayload +> +export type ServerPongEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.ServerPong, + ServerPongPayload +> +export type RealtimeErrorEvent = RealtimeEventEnvelope< + typeof RealtimeEventName.RealtimeError, + RealtimeErrorPayload +> + +export type RealtimeRoomScope = + | "seller" + | "product" + | "alerts" + | "dashboard" + | "activity" + | "custom" + +export type RealtimeRoomTarget = { + scope: RealtimeRoomScope + id?: string +} + +export type RoomSubscriptionRequest = { + rooms: RealtimeRoomTarget[] +} + +export type RealtimeServerEvents = { + [RealtimeEventName.RecommendationCreated]: ( + payload: RecommendationCreatedEvent + ) => void + [RealtimeEventName.AlertSent]: (payload: AlertSentEvent) => void + [RealtimeEventName.ProductSnapshotSent]: ( + payload: ProductSnapshotSentEvent + ) => void + [RealtimeEventName.AlertFailed]: (payload: AlertFailedEvent) => void + [RealtimeEventName.CompetitorUpdated]: ( + payload: CompetitorUpdatedEvent + ) => void + [RealtimeEventName.StockRiskDetected]: ( + payload: StockRiskDetectedEvent + ) => void + [RealtimeEventName.UrgencyScoreUpdated]: ( + payload: UrgencyScoreUpdatedEvent + ) => void + [RealtimeEventName.DashboardSummaryUpdated]: ( + payload: DashboardSummaryUpdatedEvent + ) => void + [RealtimeEventName.MarketOpportunityDetected]: ( + payload: MarketOpportunityDetectedEvent + ) => void + [RealtimeEventName.TelegramConnected]: (payload: TelegramConnectedEvent) => void + [RealtimeEventName.TelegramDisconnected]: ( + payload: TelegramDisconnectedEvent + ) => void + [RealtimeEventName.ConnectionEstablished]: ( + payload: ConnectionEstablishedEvent + ) => void + [RealtimeEventName.RoomsUpdated]: (payload: RoomsUpdatedEvent) => void + [RealtimeEventName.ServerPong]: (payload: ServerPongEvent) => void + [RealtimeEventName.RealtimeError]: (payload: RealtimeErrorEvent) => void +} + +export type RealtimeClientEvents = { + subscribe_rooms: (payload: RoomSubscriptionRequest) => void + unsubscribe_rooms: (payload: RoomSubscriptionRequest) => void + subscribe_war_room: (payload: { scope: "seller"; sellerId?: string }) => void + client_ping: (payload: { timestamp: number }) => void +} diff --git a/frontend/src/vendor/es-toolkit-compat/get.ts b/frontend/src/vendor/es-toolkit-compat/get.ts new file mode 100644 index 0000000..4116ce6 --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/get.ts @@ -0,0 +1,3 @@ +import { get } from "es-toolkit/compat" + +export default get diff --git a/frontend/src/vendor/es-toolkit-compat/isPlainObject.ts b/frontend/src/vendor/es-toolkit-compat/isPlainObject.ts new file mode 100644 index 0000000..fd2223a --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/isPlainObject.ts @@ -0,0 +1,3 @@ +import { isPlainObject } from "es-toolkit/compat" + +export default isPlainObject diff --git a/frontend/src/vendor/es-toolkit-compat/last.ts b/frontend/src/vendor/es-toolkit-compat/last.ts new file mode 100644 index 0000000..e943b51 --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/last.ts @@ -0,0 +1,3 @@ +import { last } from "es-toolkit/compat" + +export default last diff --git a/frontend/src/vendor/es-toolkit-compat/maxBy.ts b/frontend/src/vendor/es-toolkit-compat/maxBy.ts new file mode 100644 index 0000000..bb79c28 --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/maxBy.ts @@ -0,0 +1,3 @@ +import { maxBy } from "es-toolkit/compat" + +export default maxBy diff --git a/frontend/src/vendor/es-toolkit-compat/minBy.ts b/frontend/src/vendor/es-toolkit-compat/minBy.ts new file mode 100644 index 0000000..8bcf4ba --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/minBy.ts @@ -0,0 +1,3 @@ +import { minBy } from "es-toolkit/compat" + +export default minBy diff --git a/frontend/src/vendor/es-toolkit-compat/omit.ts b/frontend/src/vendor/es-toolkit-compat/omit.ts new file mode 100644 index 0000000..3f9bc3b --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/omit.ts @@ -0,0 +1,3 @@ +import { omit } from "es-toolkit/compat" + +export default omit diff --git a/frontend/src/vendor/es-toolkit-compat/range.ts b/frontend/src/vendor/es-toolkit-compat/range.ts new file mode 100644 index 0000000..f5ee37a --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/range.ts @@ -0,0 +1,3 @@ +import { range } from "es-toolkit/compat" + +export default range diff --git a/frontend/src/vendor/es-toolkit-compat/sortBy.ts b/frontend/src/vendor/es-toolkit-compat/sortBy.ts new file mode 100644 index 0000000..8cc7292 --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/sortBy.ts @@ -0,0 +1,3 @@ +import { sortBy } from "es-toolkit/compat" + +export default sortBy diff --git a/frontend/src/vendor/es-toolkit-compat/sumBy.ts b/frontend/src/vendor/es-toolkit-compat/sumBy.ts new file mode 100644 index 0000000..bb3fe78 --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/sumBy.ts @@ -0,0 +1,3 @@ +import { sumBy } from "es-toolkit/compat" + +export default sumBy diff --git a/frontend/src/vendor/es-toolkit-compat/throttle.ts b/frontend/src/vendor/es-toolkit-compat/throttle.ts new file mode 100644 index 0000000..1efe836 --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/throttle.ts @@ -0,0 +1,3 @@ +import { throttle } from "es-toolkit/compat" + +export default throttle diff --git a/frontend/src/vendor/es-toolkit-compat/uniqBy.ts b/frontend/src/vendor/es-toolkit-compat/uniqBy.ts new file mode 100644 index 0000000..3637c63 --- /dev/null +++ b/frontend/src/vendor/es-toolkit-compat/uniqBy.ts @@ -0,0 +1,3 @@ +import { uniqBy } from "es-toolkit/compat" + +export default uniqBy diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4b6b7ba..abe6f93 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,8 +5,15 @@ import path from 'path' export default defineConfig({ plugins: [react()], resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, + alias: [ + { + find: /^es-toolkit\/compat\/(.+)$/, + replacement: path.resolve( + __dirname, + "./src/vendor/es-toolkit-compat/$1" + ), + }, + { find: "@", replacement: path.resolve(__dirname, "./src") }, + ], }, }) \ No newline at end of file