From 79ece0fe2e0f76cb1bead5dc8280560a5ffdf1ca Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 26 Jul 2026 03:17:40 +0200 Subject: [PATCH] Phase 4: Webhooks, Backup/Restore UI, Onboarding/Tutorial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Webhooks Backend: model, schema, service (HMAC-SHA256, httpx), routes, event bus dispatcher, migration 0042 - Webhooks Frontend: SettingsWebhooksPage (CRUD, test button, event multi-select), API client - Backup/Restore Backend: model, schema, service (pg_dump/pg_restore), routes (admin-only), migration 0043 - Backup/Restore Frontend: SettingsBackupPage (create, list, restore dialog with RESTORE confirmation, auto-refresh) - Onboarding: OnboardingTour (8 steps, custom CSS overlay), WelcomeDialog, onboardingStore (zustand + localStorage) - Onboarding integrated into AppShell - Routes: /settings/webhooks, /settings/backup registered - Settings nav: Webhooks, Backup & Restore entries added - Migration conflict fixed: 0042_webhooks → 0043_backups chain --- alembic/versions/0042_webhooks.py | 69 ++ alembic/versions/0043_backups.py | 64 ++ app/core/webhook_dispatcher.py | 98 +++ app/main.py | 4 + app/models/__init__.py | 4 + app/models/backup.py | 46 ++ app/models/webhook.py | 43 ++ app/routes/backups.py | 104 +++ app/routes/webhooks.py | 161 +++++ app/schemas/backup.py | 31 + app/schemas/webhook.py | 49 ++ app/services/backup_service.py | 268 ++++++++ app/services/webhook_service.py | 183 +++++ frontend/src/api/backups.ts | 96 +++ frontend/src/api/webhooks.ts | 126 ++++ frontend/src/components/layout/AppShell.tsx | 4 + .../components/onboarding/OnboardingTour.tsx | 372 +++++++++++ .../components/onboarding/WelcomeDialog.tsx | 137 ++++ frontend/src/pages/Settings.tsx | 2 + frontend/src/pages/SettingsBackup.tsx | 499 ++++++++++++++ frontend/src/pages/SettingsWebhooks.tsx | 628 ++++++++++++++++++ frontend/src/routes/index.tsx | 4 + frontend/src/store/onboardingStore.ts | 102 +++ 23 files changed, 3094 insertions(+) create mode 100644 alembic/versions/0042_webhooks.py create mode 100644 alembic/versions/0043_backups.py create mode 100644 app/core/webhook_dispatcher.py create mode 100644 app/models/backup.py create mode 100644 app/models/webhook.py create mode 100644 app/routes/backups.py create mode 100644 app/routes/webhooks.py create mode 100644 app/schemas/backup.py create mode 100644 app/schemas/webhook.py create mode 100644 app/services/backup_service.py create mode 100644 app/services/webhook_service.py create mode 100644 frontend/src/api/backups.ts create mode 100644 frontend/src/api/webhooks.ts create mode 100644 frontend/src/components/onboarding/OnboardingTour.tsx create mode 100644 frontend/src/components/onboarding/WelcomeDialog.tsx create mode 100644 frontend/src/pages/SettingsBackup.tsx create mode 100644 frontend/src/pages/SettingsWebhooks.tsx create mode 100644 frontend/src/store/onboardingStore.ts diff --git a/alembic/versions/0042_webhooks.py b/alembic/versions/0042_webhooks.py new file mode 100644 index 0000000..e192cee --- /dev/null +++ b/alembic/versions/0042_webhooks.py @@ -0,0 +1,69 @@ +"""Create webhooks table for outgoing webhook subscriptions. + +Revision ID: 0042_webhooks +Revises: 0041_custom_field_definitions +Create Date: 2026-07-26 + +Stores outgoing webhook subscriptions with target URL, event subscriptions, +and delivery settings (retry count, timeout, HMAC secret). +""" + +from __future__ import annotations + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers +revision: str = "0042_webhooks" +down_revision: Union[str, None] = "0041_custom_field_definitions" +branch_labels: Union[str, None] = None +depends_on: Union[str, None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + conn.execute( + sa.text( + """ + CREATE TABLE IF NOT EXISTS webhooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + url VARCHAR(500) NOT NULL, + events JSONB NOT NULL DEFAULT '[]', + secret VARCHAR(255), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + retry_count INTEGER NOT NULL DEFAULT 3, + timeout_seconds INTEGER NOT NULL DEFAULT 30, + created_by UUID, + updated_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ) + """ + ) + ) + + # Indexes + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS ix_webhooks_tenant " + "ON webhooks (tenant_id)" + ) + ) + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS ix_webhooks_tenant_active " + "ON webhooks (tenant_id, is_active)" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text("DROP INDEX IF EXISTS ix_webhooks_tenant_active")) + conn.execute(sa.text("DROP INDEX IF EXISTS ix_webhooks_tenant")) + conn.execute(sa.text("DROP TABLE IF EXISTS webhooks")) diff --git a/alembic/versions/0043_backups.py b/alembic/versions/0043_backups.py new file mode 100644 index 0000000..abcad97 --- /dev/null +++ b/alembic/versions/0043_backups.py @@ -0,0 +1,64 @@ +"""Create backups table for database backup tracking. + +Revision ID: 0042_backups +Revises: 0041_custom_field_definitions +Create Date: 2026-07-26 + +Stores database backup records per tenant with status tracking. +""" + +from __future__ import annotations + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers +revision: str = "0043_backups" +down_revision: Union[str, None] = "0042_webhooks" +branch_labels: Union[str, None] = None +depends_on: Union[str, None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + conn.execute( + sa.text( + """ + CREATE TABLE IF NOT EXISTS backups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + filename VARCHAR(255) NOT NULL, + size_bytes BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + error_message TEXT, + created_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ + ) + """ + ) + ) + + # Indexes + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS ix_backups_tenant " + "ON backups (tenant_id)" + ) + ) + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS ix_backups_tenant_status " + "ON backups (tenant_id, status)" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text("DROP INDEX IF EXISTS ix_backups_tenant_status")) + conn.execute(sa.text("DROP INDEX IF EXISTS ix_backups_tenant")) + conn.execute(sa.text("DROP TABLE IF EXISTS backups")) diff --git a/app/core/webhook_dispatcher.py b/app/core/webhook_dispatcher.py new file mode 100644 index 0000000..de69156 --- /dev/null +++ b/app/core/webhook_dispatcher.py @@ -0,0 +1,98 @@ +"""Webhook dispatcher — subscribes to the event bus and dispatches to matching webhooks.""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.core.db import get_engine, get_session_factory +from app.core.event_bus import EventBus, get_event_bus +from app.models.webhook import Webhook +from app.services.webhook_service import send_webhook + +logger = logging.getLogger(__name__) + + +async def _dispatch_event(payload: dict[str, Any]) -> None: + """Handle an event from the event bus: find matching webhooks and dispatch. + + The payload is expected to contain: + - event_name: str + - tenant_id: str (UUID) + - data: dict (the actual event data) + """ + event_name = payload.get("event_name", "") + tenant_id_str = payload.get("tenant_id", "") + data = payload.get("data", {}) + + if not event_name or not tenant_id_str: + logger.warning("webhook_dispatcher: missing event_name or tenant_id in payload") + return + + try: + tenant_id = uuid.UUID(tenant_id_str) + except (ValueError, TypeError): + logger.warning(f"webhook_dispatcher: invalid tenant_id: {tenant_id_str}") + return + + # Find active webhooks for this tenant that subscribe to this event + session_factory = get_session_factory() + async with session_factory() as db: + stmt = select(Webhook).where( + Webhook.tenant_id == tenant_id, + Webhook.is_active == True, # noqa: E712 + Webhook.events.any(event_name), + ) + result = await db.execute(stmt) + webhooks = list(result.scalars().all()) + + if not webhooks: + return + + # Dispatch to all matching webhooks concurrently + tasks = [] + for webhook in webhooks: + tasks.append(_dispatch_single(webhook, event_name, data)) + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + +async def _dispatch_single( + webhook: Webhook, + event_name: str, + data: dict[str, Any], +) -> None: + """Send a webhook and log the result.""" + try: + result = await send_webhook(webhook, event_name, data) + if result["success"]: + logger.info( + f"Webhook {webhook.id} sent successfully to {webhook.url} " + f"for event {event_name} (status={result['status_code']})" + ) + else: + logger.warning( + f"Webhook {webhook.id} failed for {webhook.url} " + f"event {event_name}: {result.get('error')}" + ) + except Exception as exc: + logger.error( + f"Webhook {webhook.id} dispatch error for {webhook.url}: {exc}" + ) + + +def register_webhook_event_handlers(event_bus: EventBus | None = None) -> None: + """Register webhook dispatcher on the global event bus. + + Subscribes to all events via the wildcard '*' handler and filters + by event name internally. Should be called during application startup. + """ + bus = event_bus or get_event_bus() + bus.subscribe("*", _dispatch_event) + logger.info("Webhook dispatcher registered on event bus (wildcard '*' handler)") diff --git a/app/main.py b/app/main.py index f45c8f5..faa3102 100644 --- a/app/main.py +++ b/app/main.py @@ -52,6 +52,8 @@ from app.routes import ( custom_field_definitions, custom_fields, saved_filters, + webhooks, + backups, ) @@ -331,9 +333,11 @@ def create_app() -> FastAPI: app.include_router(addresses.router) app.include_router(bank_accounts.router) app.include_router(audit.router) + app.include_router(backups.router) app.include_router(custom_field_definitions.router) app.include_router(custom_fields.router) app.include_router(saved_filters.router) + app.include_router(webhooks.router) # ── Register plugin routes for all built-in plugins ── # Routes are registered here (before app start); activation status diff --git a/app/models/__init__.py b/app/models/__init__.py index 6db7d43..c5ac6d6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -21,7 +21,9 @@ from app.models.system_settings import SystemSettings from app.models.tax import TaxRate from app.models.tenant import Tenant from app.models.user import User, UserTenant +from app.models.backup import Backup from app.models.custom_field_definition import CustomFieldDefinition +from app.models.webhook import Webhook from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory __all__ = [ @@ -55,7 +57,9 @@ __all__ = [ "PluginMigration", "AIConversation", "AIMessage", + "Backup", "CustomFieldDefinition", + "Webhook", "Workflow", "WorkflowInstance", "WorkflowStepHistory", diff --git a/app/models/backup.py b/app/models/backup.py new file mode 100644 index 0000000..e356749 --- /dev/null +++ b/app/models/backup.py @@ -0,0 +1,46 @@ +"""Backup model — tracks database backup records per tenant.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, String, Text +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, TenantMixin + + +class Backup(Base, TenantMixin): + """Database backup record scoped to a tenant. + + Tracks pg_dump backups with status, file location, and error details. + """ + + __tablename__ = "backups" + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + + # ── Identity ── + filename: Mapped[str] = mapped_column(String(255), nullable=False) + size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True, default=None) + + # ── Status ── + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="pending" + ) # pending, completed, failed + error_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) + + # ── Audit ── + created_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.utcnow + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) diff --git a/app/models/webhook.py b/app/models/webhook.py new file mode 100644 index 0000000..6f51f6a --- /dev/null +++ b/app/models/webhook.py @@ -0,0 +1,43 @@ +"""Webhook model — outgoing webhook subscriptions for event-driven notifications.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Integer, JSON, String +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, TenantMixin + + +class Webhook(Base, TenantMixin): + """Outgoing webhook subscription. + + Each webhook defines a target URL, a list of events to subscribe to, + and delivery settings (retry count, timeout, HMAC secret). + """ + + __tablename__ = "webhooks" + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + + # ── Target ── + url: Mapped[str] = mapped_column(String(500), nullable=False) + events: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + secret: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + + # ── Delivery Settings ── + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=3) + timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=30) + + # ── Audit ── + created_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), nullable=True + ) + updated_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), nullable=True + ) diff --git a/app/routes/backups.py b/app/routes/backups.py new file mode 100644 index 0000000..407fb52 --- /dev/null +++ b/app/routes/backups.py @@ -0,0 +1,104 @@ +"""API routes for Backup management.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.deps import get_current_user, require_permission +from app.schemas.backup import BackupListResponse, BackupResponse +from app.services import backup_service + +router = APIRouter(prefix="/api/v1/backups", tags=["backups"]) + + +@router.get( + "", + response_model=BackupListResponse, + dependencies=[Depends(require_permission("automation:admin"))], +) +async def list_backups( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List all backups for the current tenant.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + backups = await backup_service.list_backups(db, tenant_id) + return BackupListResponse( + backups=[BackupResponse.model_validate(b) for b in backups], + total=len(backups), + ) + + +@router.post( + "", + response_model=BackupResponse, + status_code=201, + dependencies=[Depends(require_permission("automation:admin"))], +) +async def create_backup( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new database backup.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + backup = await backup_service.create_backup(db, tenant_id, user_id=user_id) + return backup + + +@router.post( + "/{backup_id}/restore", + response_model=BackupResponse, + dependencies=[Depends(require_permission("automation:admin"))], +) +async def restore_backup( + backup_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Restore a database backup. + + WARNING: This is a destructive operation. It drops and recreates the database. + """ + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + b_id = uuid.UUID(backup_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid backup_id", "code": "invalid_id"}) from None + + try: + backup = await backup_service.restore_backup(db, tenant_id, b_id) + return backup + except ValueError as exc: + raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_state"}) from exc + except FileNotFoundError as exc: + raise HTTPException(404, detail={"detail": str(exc), "code": "file_not_found"}) from exc + except RuntimeError as exc: + raise HTTPException(500, detail={"detail": str(exc), "code": "restore_failed"}) from exc + + +@router.delete( + "/{backup_id}", + status_code=204, + dependencies=[Depends(require_permission("automation:admin"))], +) +async def delete_backup( + backup_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a backup record and its file.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + b_id = uuid.UUID(backup_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid backup_id", "code": "invalid_id"}) from None + + deleted = await backup_service.delete_backup(db, tenant_id, b_id) + if not deleted: + raise HTTPException(404, detail={"detail": "Backup not found", "code": "not_found"}) + return None diff --git a/app/routes/webhooks.py b/app/routes/webhooks.py new file mode 100644 index 0000000..067dd2c --- /dev/null +++ b/app/routes/webhooks.py @@ -0,0 +1,161 @@ +"""API routes for Webhook CRUD and testing.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.deps import get_current_user, require_permission +from app.schemas.webhook import ( + WebhookCreate, + WebhookResponse, + WebhookUpdate, +) +from app.services import webhook_service + +router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"]) + + +@router.get( + "", + response_model=list[WebhookResponse], + dependencies=[Depends(require_permission("automation:read"))], +) +async def list_webhooks( + event: str | None = Query(None, description="Filter by event name"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List all webhooks for the current tenant.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + webhooks = await webhook_service.list_webhooks(db, tenant_id, event=event) + return webhooks + + +@router.post( + "", + response_model=WebhookResponse, + status_code=201, + dependencies=[Depends(require_permission("automation:write"))], +) +async def create_webhook( + body: WebhookCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a new webhook subscription.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + webhook = await webhook_service.create_webhook( + db, tenant_id, user_id, body.model_dump() + ) + return webhook + + +@router.get( + "/{webhook_id}", + response_model=WebhookResponse, + dependencies=[Depends(require_permission("automation:read"))], +) +async def get_webhook( + webhook_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get a single webhook by ID.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wh_id = uuid.UUID(webhook_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None + + webhook = await webhook_service.get_webhook(db, tenant_id, wh_id) + if webhook is None: + raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) + return webhook + + +@router.patch( + "/{webhook_id}", + response_model=WebhookResponse, + dependencies=[Depends(require_permission("automation:write"))], +) +async def update_webhook( + webhook_id: str, + body: WebhookUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update an existing webhook subscription.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + try: + wh_id = uuid.UUID(webhook_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None + + update_data = {k: v for k, v in body.model_dump().items() if v is not None} + if not update_data: + raise HTTPException(400, detail={"detail": "No fields to update", "code": "no_updates"}) + + webhook = await webhook_service.update_webhook( + db, tenant_id, wh_id, update_data, user_id=user_id + ) + if webhook is None: + raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) + return webhook + + +@router.delete( + "/{webhook_id}", + status_code=204, + dependencies=[Depends(require_permission("automation:write"))], +) +async def delete_webhook( + webhook_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete a webhook subscription.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wh_id = uuid.UUID(webhook_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None + + deleted = await webhook_service.delete_webhook(db, tenant_id, wh_id) + if not deleted: + raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) + return None + + +@router.post( + "/{webhook_id}/test", + dependencies=[Depends(require_permission("automation:write"))], +) +async def test_webhook( + webhook_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Send a test payload to a webhook to verify connectivity.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wh_id = uuid.UUID(webhook_id) + except (ValueError, TypeError): + raise HTTPException(400, detail={"detail": "Invalid webhook_id", "code": "invalid_id"}) from None + + webhook = await webhook_service.get_webhook(db, tenant_id, wh_id) + if webhook is None: + raise HTTPException(404, detail={"detail": "Webhook not found", "code": "not_found"}) + + test_payload = { + "type": "test", + "message": "This is a test webhook from LeoCRM", + "webhook_id": str(webhook.id), + } + result = await webhook_service.send_webhook(webhook, "webhook.test", test_payload) + return result diff --git a/app/schemas/backup.py b/app/schemas/backup.py new file mode 100644 index 0000000..bd462cb --- /dev/null +++ b/app/schemas/backup.py @@ -0,0 +1,31 @@ +"""Pydantic schemas for Backup CRUD.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class BackupResponse(BaseModel): + """Schema for returning a backup record.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tenant_id: uuid.UUID + filename: str + size_bytes: int | None = None + status: str # pending, completed, failed + error_message: str | None = None + created_by: uuid.UUID | None = None + created_at: datetime + completed_at: datetime | None = None + + +class BackupListResponse(BaseModel): + """Schema for listing backups.""" + + backups: list[BackupResponse] + total: int diff --git a/app/schemas/webhook.py b/app/schemas/webhook.py new file mode 100644 index 0000000..49eec94 --- /dev/null +++ b/app/schemas/webhook.py @@ -0,0 +1,49 @@ +"""Pydantic schemas for Webhook CRUD.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class WebhookCreate(BaseModel): + """Schema for creating a new webhook.""" + + url: str = Field(..., min_length=1, max_length=500, description="Target URL for the webhook") + events: list[str] = Field(..., min_length=1, description="List of event names to subscribe to") + secret: str | None = Field(default=None, max_length=255, description="HMAC secret for payload signing") + is_active: bool = Field(default=True, description="Whether the webhook is active") + retry_count: int = Field(default=3, ge=0, le=10, description="Number of retry attempts on failure") + timeout_seconds: int = Field(default=30, ge=1, le=120, description="Request timeout in seconds") + + +class WebhookUpdate(BaseModel): + """Schema for updating an existing webhook.""" + + url: str | None = Field(default=None, max_length=500) + events: list[str] | None = Field(default=None, min_length=1) + secret: str | None = Field(default=None, max_length=255) + is_active: bool | None = Field(default=None) + retry_count: int | None = Field(default=None, ge=0, le=10) + timeout_seconds: int | None = Field(default=None, ge=1, le=120) + + +class WebhookResponse(BaseModel): + """Schema for returning a webhook.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tenant_id: uuid.UUID + url: str + events: list[str] + secret: str | None = None + is_active: bool = True + retry_count: int = 3 + timeout_seconds: int = 30 + created_by: uuid.UUID | None = None + updated_by: uuid.UUID | None = None + created_at: datetime + updated_at: datetime diff --git a/app/services/backup_service.py b/app/services/backup_service.py new file mode 100644 index 0000000..8c80203 --- /dev/null +++ b/app/services/backup_service.py @@ -0,0 +1,268 @@ +"""Service for database backup and restore operations.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from datetime import datetime +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.backup import Backup + +logger = logging.getLogger(__name__) + +BACKUP_DIR = Path("/tmp/leocrm-backups") + + +def _ensure_backup_dir() -> None: + """Ensure the backup directory exists.""" + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + + +def _get_database_url() -> str: + """Get DATABASE_URL from environment.""" + url = os.environ.get("DATABASE_URL") + if not url: + raise RuntimeError("DATABASE_URL environment variable is not set") + return url + + +def _parse_pg_url(url: str) -> dict[str, str]: + """Parse a PostgreSQL connection URL into components for pg_dump/pg_restore. + + Handles formats: + postgresql://user:pass@host:port/dbname + postgresql+asyncpg://user:pass@host:port/dbname + """ + # Remove async driver prefix if present + if "+asyncpg" in url: + url = url.replace("+asyncpg", "") + if "+psycopg2" in url: + url = url.replace("+psycopg2", "") + + # Parse the URL manually to avoid dependency on urllib parsing quirks + # Format: postgresql://user:pass@host:port/dbname + rest = url.split("://", 1)[1] if "://" in url else url + user_info, rest = rest.split("@", 1) if "@" in rest else ("", rest) + user = "" + password = "" + if ":" in user_info: + user, password = user_info.split(":", 1) + else: + user = user_info + + host_port, dbname = rest.split("/", 1) if "/" in rest else (rest, "") + host = host_port + port = "5432" + if ":" in host_port: + host, port = host_port.split(":", 1) + + return { + "host": host, + "port": port, + "user": user, + "password": password, + "dbname": dbname, + } + + +async def list_backups( + db: AsyncSession, + tenant_id: uuid.UUID, +) -> list[Backup]: + """List all backups for a tenant, ordered by creation date descending.""" + stmt = ( + select(Backup) + .where(Backup.tenant_id == tenant_id) + .order_by(Backup.created_at.desc()) + ) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def create_backup( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID | None = None, +) -> Backup: + """Create a database backup using pg_dump. + + Creates a backup record, runs pg_dump to a file, then updates the record + with the file size and status. + """ + _ensure_backup_dir() + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + filename = f"leocrm_backup_{tenant_id}_{timestamp}.dump" + filepath = BACKUP_DIR / filename + + # Create initial pending record + backup = Backup( + tenant_id=tenant_id, + filename=filename, + status="pending", + created_by=user_id, + ) + db.add(backup) + await db.flush() + await db.refresh(backup) + + try: + database_url = _get_database_url() + pg = _parse_pg_url(database_url) + + # Build pg_dump command + env = os.environ.copy() + if pg["password"]: + env["PGPASSWORD"] = pg["password"] + + cmd = [ + "pg_dump", + "--host", pg["host"], + "--port", pg["port"], + "--username", pg["user"], + "--format", "custom", + "--file", str(filepath), + pg["dbname"], + ] + + logger.info("Running pg_dump: %s to %s", cmd, filepath) + + process = await asyncio.create_subprocess_exec( + *cmd, + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode() if stderr else "pg_dump failed with unknown error" + logger.error("pg_dump failed: %s", error_msg) + backup.status = "failed" + backup.error_message = error_msg + await db.flush() + await db.refresh(backup) + return backup + + # Get file size + size_bytes = filepath.stat().st_size if filepath.exists() else 0 + + # Update backup record + backup.status = "completed" + backup.size_bytes = size_bytes + backup.completed_at = datetime.utcnow() + await db.flush() + await db.refresh(backup) + + logger.info("Backup completed: %s (%d bytes)", filename, size_bytes) + return backup + + except Exception as exc: + logger.exception("Backup creation failed") + backup.status = "failed" + backup.error_message = str(exc) + await db.flush() + await db.refresh(backup) + return backup + + +async def restore_backup( + db: AsyncSession, + tenant_id: uuid.UUID, + backup_id: uuid.UUID, +) -> Backup: + """Restore a database backup using pg_restore. + + WARNING: This is a destructive operation. It drops and recreates the database. + """ + stmt = select(Backup).where( + Backup.id == backup_id, + Backup.tenant_id == tenant_id, + ) + result = await db.execute(stmt) + backup = result.scalar_one_or_none() + + if backup is None: + raise ValueError("Backup not found") + + if backup.status != "completed": + raise ValueError(f"Backup status is '{backup.status}', cannot restore") + + filepath = BACKUP_DIR / backup.filename + if not filepath.exists(): + raise FileNotFoundError(f"Backup file not found: {filepath}") + + try: + database_url = _get_database_url() + pg = _parse_pg_url(database_url) + + env = os.environ.copy() + if pg["password"]: + env["PGPASSWORD"] = pg["password"] + + cmd = [ + "pg_restore", + "--host", pg["host"], + "--port", pg["port"], + "--username", pg["user"], + "--dbname", pg["dbname"], + "--clean", + "--if-exists", + "--no-owner", + "--no-acl", + str(filepath), + ] + + logger.info("Running pg_restore: %s", cmd) + + process = await asyncio.create_subprocess_exec( + *cmd, + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error_msg = stderr.decode() if stderr else "pg_restore failed with unknown error" + logger.error("pg_restore failed: %s", error_msg) + raise RuntimeError(f"Restore failed: {error_msg}") + + logger.info("Restore completed from backup: %s", backup.filename) + return backup + + except Exception as exc: + logger.exception("Restore failed") + raise + + +async def delete_backup( + db: AsyncSession, + tenant_id: uuid.UUID, + backup_id: uuid.UUID, +) -> bool: + """Delete a backup record and its file.""" + stmt = select(Backup).where( + Backup.id == backup_id, + Backup.tenant_id == tenant_id, + ) + result = await db.execute(stmt) + backup = result.scalar_one_or_none() + + if backup is None: + return False + + # Delete the file if it exists + filepath = BACKUP_DIR / backup.filename + if filepath.exists(): + filepath.unlink() + + await db.delete(backup) + await db.flush() + return True diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py new file mode 100644 index 0000000..77febf7 --- /dev/null +++ b/app/services/webhook_service.py @@ -0,0 +1,183 @@ +"""CRUD service for Webhook subscriptions and delivery.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import uuid +from typing import Any + +import httpx +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.webhook import Webhook + +logger = logging.getLogger(__name__) + + +async def list_webhooks( + db: AsyncSession, + tenant_id: uuid.UUID, + event: str | None = None, +) -> list[Webhook]: + """List webhooks for a tenant, optionally filtered by event.""" + stmt = select(Webhook).where( + Webhook.tenant_id == tenant_id, + ) + if event: + stmt = stmt.where(Webhook.events.any(event)) + stmt = stmt.order_by(Webhook.created_at.desc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def get_webhook( + db: AsyncSession, + tenant_id: uuid.UUID, + webhook_id: uuid.UUID, +) -> Webhook | None: + """Get a single webhook by ID.""" + stmt = select(Webhook).where( + Webhook.id == webhook_id, + Webhook.tenant_id == tenant_id, + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def create_webhook( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + data: dict[str, Any], +) -> Webhook: + """Create a new webhook subscription.""" + webhook = Webhook( + tenant_id=tenant_id, + url=data["url"], + events=data["events"], + secret=data.get("secret"), + is_active=data.get("is_active", True), + retry_count=data.get("retry_count", 3), + timeout_seconds=data.get("timeout_seconds", 30), + created_by=user_id, + updated_by=user_id, + ) + db.add(webhook) + await db.flush() + await db.refresh(webhook) + return webhook + + +async def update_webhook( + db: AsyncSession, + tenant_id: uuid.UUID, + webhook_id: uuid.UUID, + data: dict[str, Any], + user_id: uuid.UUID | None = None, +) -> Webhook | None: + """Update an existing webhook subscription.""" + webhook = await get_webhook(db, tenant_id, webhook_id) + if webhook is None: + return None + + update_fields = ["url", "events", "secret", "is_active", "retry_count", "timeout_seconds"] + for field in update_fields: + if field in data: + setattr(webhook, field, data[field]) + + if user_id is not None: + webhook.updated_by = user_id + + await db.flush() + await db.refresh(webhook) + return webhook + + +async def delete_webhook( + db: AsyncSession, + tenant_id: uuid.UUID, + webhook_id: uuid.UUID, +) -> bool: + """Delete a webhook subscription.""" + stmt = select(Webhook).where( + Webhook.id == webhook_id, + Webhook.tenant_id == tenant_id, + ) + result = await db.execute(stmt) + webhook = result.scalar_one_or_none() + if webhook is None: + return False + await db.delete(webhook) + await db.flush() + return True + + +def _sign_payload(payload: bytes, secret: str) -> str: + """Create HMAC-SHA256 signature for a payload.""" + return hmac.new( + secret.encode("utf-8"), + payload, + hashlib.sha256, + ).hexdigest() + + +async def send_webhook( + webhook: Webhook, + event_name: str, + payload: dict[str, Any], +) -> dict[str, Any]: + """Send a webhook HTTP POST with HMAC signature. + + Returns a dict with 'success' (bool), 'status_code' (int | None), + and 'error' (str | None). + """ + body = { + "event": event_name, + "payload": payload, + "timestamp": __import__("datetime").datetime.utcnow().isoformat() + "Z", + } + body_bytes = json.dumps(body, default=str).encode("utf-8") + + headers = { + "Content-Type": "application/json", + "User-Agent": "LeoCRM-Webhook/1.0", + } + + if webhook.secret: + signature = _sign_payload(body_bytes, webhook.secret) + headers["X-Webhook-Signature"] = f"sha256={signature}" + + try: + async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client: + response = await client.post( + webhook.url, + content=body_bytes, + headers=headers, + ) + return { + "success": response.is_success, + "status_code": response.status_code, + "error": None, + } + except httpx.TimeoutException: + return {"success": False, "status_code": None, "error": "timeout"} + except httpx.RequestError as exc: + return {"success": False, "status_code": None, "error": str(exc)} + + +async def retry_webhook( + db: AsyncSession, + tenant_id: uuid.UUID, + webhook_id: uuid.UUID, + event_name: str, + payload: dict[str, Any], +) -> dict[str, Any]: + """Retry sending a webhook with the given event and payload.""" + webhook = await get_webhook(db, tenant_id, webhook_id) + if webhook is None: + return {"success": False, "status_code": None, "error": "webhook_not_found"} + return await send_webhook(webhook, event_name, payload) diff --git a/frontend/src/api/backups.ts b/frontend/src/api/backups.ts new file mode 100644 index 0000000..b39f085 --- /dev/null +++ b/frontend/src/api/backups.ts @@ -0,0 +1,96 @@ +/** + * Backup API client — database backup management. + * + * Features: + * - List backups + * - Create backup + * - Restore backup + * - Delete backup + */ + +import { apiGet, apiPost, apiDelete } from './client'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface Backup { + id: string; + tenant_id: string; + filename: string; + size_bytes: number | null; + status: 'pending' | 'completed' | 'failed'; + error_message: string | null; + created_by: string | null; + created_at: string; + completed_at: string | null; +} + +export interface BackupListResponse { + backups: Backup[]; + total: number; +} + +// ─── API Functions ────────────────────────────────────────────────────────── + +export async function fetchBackups(): Promise { + return apiGet('/backups'); +} + +export async function createBackup(): Promise { + return apiPost('/backups'); +} + +export async function restoreBackup(backupId: string): Promise { + return apiPost(`/backups/${backupId}/restore`); +} + +export async function deleteBackup(backupId: string): Promise { + return apiDelete(`/backups/${backupId}`); +} + +// ─── React Query Hooks ───────────────────────────────────────────────────── + +export function useBackups() { + return useQuery({ + queryKey: ['backups'], + queryFn: fetchBackups, + refetchInterval: (query) => { + // Auto-refresh while any backup is pending + const data = query.state.data; + if (data && data.backups.some((b) => b.status === 'pending')) { + return 3000; // Poll every 3 seconds + } + return false; + }, + }); +} + +export function useCreateBackup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createBackup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backups'] }); + }, + }); +} + +export function useRestoreBackup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: restoreBackup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backups'] }); + }, + }); +} + +export function useDeleteBackup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteBackup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backups'] }); + }, + }); +} diff --git a/frontend/src/api/webhooks.ts b/frontend/src/api/webhooks.ts new file mode 100644 index 0000000..8708919 --- /dev/null +++ b/frontend/src/api/webhooks.ts @@ -0,0 +1,126 @@ +/** + * Webhooks API client and React Query hooks. + */ + +import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +export interface Webhook { + id: string; + tenant_id: string; + url: string; + events: string[]; + secret: string | null; + is_active: boolean; + retry_count: number; + timeout_seconds: number; + created_by: string | null; + updated_by: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateWebhookPayload { + url: string; + events: string[]; + secret?: string | null; + is_active?: boolean; + retry_count?: number; + timeout_seconds?: number; +} + +export interface UpdateWebhookPayload { + url?: string; + events?: string[]; + secret?: string | null; + is_active?: boolean; + retry_count?: number; + timeout_seconds?: number; +} + +export interface WebhookTestResult { + success: boolean; + status_code: number | null; + error: string | null; +} + +// ─── API Functions ────────────────────────────────────────────────────────── + +export async function fetchWebhooks(event?: string): Promise { + const params = event ? `?event=${encodeURIComponent(event)}` : ''; + return apiGet(`/webhooks${params}`); +} + +export async function fetchWebhook(id: string): Promise { + return apiGet(`/webhooks/${id}`); +} + +export async function createWebhook(data: CreateWebhookPayload): Promise { + return apiPost('/webhooks', data); +} + +export async function updateWebhook(id: string, data: UpdateWebhookPayload): Promise { + return apiPatch(`/webhooks/${id}`, data); +} + +export async function deleteWebhook(id: string): Promise { + return apiDelete(`/webhooks/${id}`); +} + +export async function testWebhook(id: string): Promise { + return apiPost(`/webhooks/${id}/test`); +} + +// ─── React Query Hooks ─────────────────────────────────────────────────────── + +export function useWebhooks(event?: string) { + return useQuery({ + queryKey: ['webhooks', event], + queryFn: () => fetchWebhooks(event), + }); +} + +export function useWebhook(id: string) { + return useQuery({ + queryKey: ['webhooks', id], + queryFn: () => fetchWebhook(id), + enabled: !!id, + }); +} + +export function useCreateWebhook() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateWebhookPayload) => createWebhook(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['webhooks'] }); + }, + }); +} + +export function useUpdateWebhook() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateWebhookPayload }) => + updateWebhook(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['webhooks'] }); + }, + }); +} + +export function useDeleteWebhook() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteWebhook(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['webhooks'] }); + }, + }); +} + +export function useTestWebhook() { + return useMutation({ + mutationFn: (id: string) => testWebhook(id), + }); +} diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index d912362..a09020b 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -11,6 +11,8 @@ import { PluginRegistry } from '@/components/plugins/PluginRegistry'; import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator'; import { WindowContainer } from '@/components/window/WindowContainer'; import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { WelcomeDialog } from '@/components/onboarding/WelcomeDialog'; +import { OnboardingTour } from '@/components/onboarding/OnboardingTour'; export function AppShell() { const location = useLocation(); @@ -50,6 +52,8 @@ export function AppShell() { + + ); } diff --git a/frontend/src/components/onboarding/OnboardingTour.tsx b/frontend/src/components/onboarding/OnboardingTour.tsx new file mode 100644 index 0000000..dbf7ffd --- /dev/null +++ b/frontend/src/components/onboarding/OnboardingTour.tsx @@ -0,0 +1,372 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOnboardingStore } from '@/store/onboardingStore'; +import { useUpsertUserPreference } from '@/api/userPreferences'; +import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react'; + +/** + * OnboardingTour — Custom guided tour with CSS overlay and positioned tooltips. + * No external dependency (react-joyride types unavailable for TS strict mode). + * + * 8 steps targeting sidebar items, search, contacts, calendar, AI assistant, settings. + * Each step highlights a target element via a cut-out overlay and shows a tooltip. + */ + +interface TourStep { + target: string; + titleKey: string; + titleFallback: string; + descKey: string; + descFallback: string; + placement?: 'bottom' | 'top' | 'left' | 'right' | 'center'; +} + +const TOUR_STEPS: TourStep[] = [ + { + target: '[data-testid="sidebar"]', + titleKey: 'onboarding.step1Title', + titleFallback: 'Navigation', + descKey: 'onboarding.step1Desc', + descFallback: 'In der Seitenleiste finden Sie alle Hauptbereiche von LeoCRM. Klicken Sie auf einen Eintrag, um dorthin zu navigieren.', + placement: 'right', + }, + { + target: 'a[href="/dashboard"]', + titleKey: 'onboarding.step2Title', + titleFallback: 'Dashboard', + descKey: 'onboarding.step2Desc', + descFallback: 'Das Dashboard gibt Ihnen einen Überblick über wichtige Kennzahlen und aktuelle Aktivitäten.', + placement: 'right', + }, + { + target: 'a[href="/contacts"]', + titleKey: 'onboarding.step3Title', + titleFallback: 'Kontakte', + descKey: 'onboarding.step3Desc', + descFallback: 'Verwalten Sie hier alle Firmen und Personen. Legen Sie neue Kontakte an oder bearbeiten Sie bestehende.', + placement: 'right', + }, + { + target: '[data-testid="search-dropdown"]', + titleKey: 'onboarding.step4Title', + titleFallback: 'Globale Suche', + descKey: 'onboarding.step4Desc', + descFallback: 'Mit der globalen Suche finden Sie schnell Kontakte, Termine und andere Einträge in LeoCRM.', + placement: 'bottom', + }, + { + target: 'a[href="/calendar"]', + titleKey: 'onboarding.step5Title', + titleFallback: 'Kalender', + descKey: 'onboarding.step5Desc', + descFallback: 'Im Kalender verwalten Sie Termine, Veranstaltungen und Wochenpläne.', + placement: 'right', + }, + { + target: 'a[href="/ai-assistant"]', + titleKey: 'onboarding.step6Title', + titleFallback: 'KI-Assistent', + descKey: 'onboarding.step6Desc', + descFallback: 'Der KI-Assistent hilft Ihnen bei Automatisierung, Texterstellung und intelligente Vorschläge.', + placement: 'right', + }, + { + target: '[data-testid="topbar"]', + titleKey: 'onboarding.step7Title', + titleFallback: 'Top-Bar', + descKey: 'onboarding.step7Desc', + descFallback: 'Hier erreichen Sie Benachrichtigungen, Ihr Benutzerprofil und weitere Einstellungen.', + placement: 'bottom', + }, + { + target: 'a[href="/settings"]', + titleKey: 'onboarding.step8Title', + titleFallback: 'Einstellungen', + descKey: 'onboarding.step8Desc', + descFallback: 'Passen Sie LeoCRM an Ihre Bedürfnisse an: Profil, Sprache, Design und mehr.', + placement: 'right', + }, +]; + +interface Rect { + top: number; + left: number; + width: number; + height: number; +} + +const HIGHLIGHT_PADDING = 8; + +export function OnboardingTour() { + const { t } = useTranslation(); + const { isActive, step, next, prev, skip, complete } = useOnboardingStore(); + const upsertPreference = useUpsertUserPreference(); + + const [targetRect, setTargetRect] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null); + const [targetMissing, setTargetMissing] = useState(false); + + const currentStep = TOUR_STEPS[step]; + const totalSteps = TOUR_STEPS.length; + const isLastStep = step >= totalSteps - 1; + + const updatePosition = useCallback(() => { + if (!isActive || !currentStep) return; + + const el = document.querySelector(currentStep.target) as HTMLElement | null; + if (!el) { + setTargetMissing(true); + setTargetRect(null); + setTooltipPos({ top: window.innerHeight / 2 - 120, left: window.innerWidth / 2 - 200 }); + return; + } + + setTargetMissing(false); + const rect = el.getBoundingClientRect(); + const padded: Rect = { + top: rect.top - HIGHLIGHT_PADDING, + left: rect.left - HIGHLIGHT_PADDING, + width: rect.width + HIGHLIGHT_PADDING * 2, + height: rect.height + HIGHLIGHT_PADDING * 2, + }; + setTargetRect(padded); + + // Calculate tooltip position based on placement + const tooltipWidth = 360; + const tooltipHeight = 220; + const margin = 16; + let top: number; + let left: number; + + const placement = currentStep.placement || 'bottom'; + + switch (placement) { + case 'right': + top = rect.top + rect.height / 2 - tooltipHeight / 2; + left = rect.right + margin; + break; + case 'left': + top = rect.top + rect.height / 2 - tooltipHeight / 2; + left = rect.left - tooltipWidth - margin; + break; + case 'top': + top = rect.top - tooltipHeight - margin; + left = rect.left + rect.width / 2 - tooltipWidth / 2; + break; + case 'bottom': + top = rect.bottom + margin; + left = rect.left + rect.width / 2 - tooltipWidth / 2; + break; + default: + top = window.innerHeight / 2 - tooltipHeight / 2; + left = window.innerWidth / 2 - tooltipWidth / 2; + } + + // Clamp to viewport + top = Math.max(margin, Math.min(top, window.innerHeight - tooltipHeight - margin)); + left = Math.max(margin, Math.min(left, window.innerWidth - tooltipWidth - margin)); + + setTooltipPos({ top, left }); + }, [isActive, currentStep, step]); + + useEffect(() => { + if (!isActive) return; + // Small delay to allow DOM to settle after potential route changes + const timer = setTimeout(updatePosition, 50); + return () => clearTimeout(timer); + }, [isActive, step, updatePosition]); + + useEffect(() => { + if (!isActive) return; + const handler = () => updatePosition(); + window.addEventListener('resize', handler); + window.addEventListener('scroll', handler, true); + return () => { + window.removeEventListener('resize', handler); + window.removeEventListener('scroll', handler, true); + }; + }, [isActive, updatePosition]); + + // Keyboard navigation + useEffect(() => { + if (!isActive) return; + const handler = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowRight': + case 'Enter': + e.preventDefault(); + handleNext(); + break; + case 'ArrowLeft': + e.preventDefault(); + if (step > 0) prev(); + break; + case 'Escape': + e.preventDefault(); + handleSkip(); + break; + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive, step]); + + const handleNext = useCallback(() => { + if (isLastStep) { + complete(); + upsertPreference.mutate({ key: 'onboarding_completed', value: true }); + } else { + next(); + } + }, [isLastStep, complete, next, upsertPreference]); + + const handleSkip = useCallback(() => { + skip(); + upsertPreference.mutate({ key: 'onboarding_completed', value: true }); + }, [skip, upsertPreference]); + + if (!isActive || !currentStep) return null; + + const title = t(currentStep.titleKey, currentStep.titleFallback); + const description = t(currentStep.descKey, currentStep.descFallback); + + return ( + <> + {/* Dark overlay with cut-out for highlighted element */} +