mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
commit
006dccbe4b
72 changed files with 2172 additions and 362 deletions
|
|
@ -33,6 +33,8 @@ REDIS_APP_URL=redis://localhost:6379/0
|
|||
# # Run every 2 hours
|
||||
# SCHEDULE_CHECKER_INTERVAL=2h
|
||||
SCHEDULE_CHECKER_INTERVAL=5m
|
||||
# How often the Stripe reconciliation beat task runs
|
||||
STRIPE_RECONCILIATION_INTERVAL=10m
|
||||
|
||||
SECRET_KEY=SECRET
|
||||
|
||||
|
|
@ -42,6 +44,20 @@ SECRET_KEY=SECRET
|
|||
|
||||
NEXT_FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Stripe Checkout for pay-as-you-go page packs
|
||||
# Configure STRIPE_PRICE_ID to point at your 1,000-page price in Stripe.
|
||||
# Pages granted per purchase = quantity * STRIPE_PAGES_PER_UNIT.
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
STRIPE_PRICE_ID=price_...
|
||||
STRIPE_PAGES_PER_UNIT=1000
|
||||
# Set FALSE to disable new checkout session creation temporarily
|
||||
STRIPE_PAGE_BUYING_ENABLED=TRUE
|
||||
# Periodic Stripe safety net for purchases left in PENDING (minutes old)
|
||||
STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10
|
||||
# Max pending purchases to check per reconciliation run
|
||||
STRIPE_RECONCILIATION_BATCH_SIZE=100
|
||||
|
||||
# Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS)
|
||||
# BACKEND_URL=https://api.yourdomain.com
|
||||
|
||||
|
|
@ -149,6 +165,14 @@ STT_SERVICE=local/base
|
|||
# STT_SERVICE_API_KEY=""
|
||||
# STT_SERVICE_API_BASE=
|
||||
|
||||
# Video presentation defaults
|
||||
# Maximum number of generated slides for a single video presentation.
|
||||
VIDEO_PRESENTATION_MAX_SLIDES=30
|
||||
# Frames per second used for slide timing calculations.
|
||||
VIDEO_PRESENTATION_FPS=30
|
||||
# Minimum duration per slide when audio is missing or very short.
|
||||
VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES=300
|
||||
|
||||
|
||||
# (Optional) Maximum pages limit per user for ETL services (default: `999999999` for unlimited in OSS version)
|
||||
PAGES_LIMIT=500
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Revises: 112
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "113"
|
||||
|
|
@ -25,8 +26,7 @@ def upgrade() -> None:
|
|||
" ON prompts (is_public) WHERE is_public = true"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
|
||||
" default_prompt_slug VARCHAR(100)"
|
||||
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS default_prompt_slug VARCHAR(100)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
"""add page purchases table for Stripe-backed page packs
|
||||
|
||||
Revision ID: 115
|
||||
Revises: 114
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "115"
|
||||
down_revision: str | None = "114"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create page_purchases table and supporting enum/indexes."""
|
||||
conn = op.get_bind()
|
||||
|
||||
enum_exists = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_type WHERE typname = 'pagepurchasestatus'")
|
||||
).fetchone()
|
||||
if not enum_exists:
|
||||
page_purchase_status_enum = postgresql.ENUM(
|
||||
"PENDING",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
name="pagepurchasestatus",
|
||||
create_type=False,
|
||||
)
|
||||
page_purchase_status_enum.create(conn, checkfirst=True)
|
||||
|
||||
table_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'page_purchases'"
|
||||
)
|
||||
).fetchone()
|
||||
if not table_exists:
|
||||
op.create_table(
|
||||
"page_purchases",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column(
|
||||
"stripe_checkout_session_id",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"stripe_payment_intent_id",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("quantity", sa.Integer(), nullable=False),
|
||||
sa.Column("pages_granted", sa.Integer(), nullable=False),
|
||||
sa.Column("amount_total", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=10), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
postgresql.ENUM(
|
||||
"PENDING",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
name="pagepurchasestatus",
|
||||
create_type=False,
|
||||
),
|
||||
nullable=False,
|
||||
server_default=sa.text("'PENDING'::pagepurchasestatus"),
|
||||
),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["user.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"stripe_checkout_session_id",
|
||||
name="uq_page_purchases_stripe_checkout_session_id",
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_page_purchases_user_id ON page_purchases (user_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_page_purchases_stripe_checkout_session_id "
|
||||
"ON page_purchases (stripe_checkout_session_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_page_purchases_stripe_payment_intent_id "
|
||||
"ON page_purchases (stripe_payment_intent_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_page_purchases_status ON page_purchases (status)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_page_purchases_created_at "
|
||||
"ON page_purchases (created_at)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop page_purchases table and enum."""
|
||||
op.execute("DROP INDEX IF EXISTS ix_page_purchases_created_at")
|
||||
op.execute("DROP INDEX IF EXISTS ix_page_purchases_status")
|
||||
op.execute("DROP INDEX IF EXISTS ix_page_purchases_stripe_payment_intent_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_page_purchases_stripe_checkout_session_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_page_purchases_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS page_purchases")
|
||||
postgresql.ENUM(name="pagepurchasestatus").drop(op.get_bind(), checkfirst=True)
|
||||
|
|
@ -145,8 +145,7 @@ def create_create_dropbox_file_tool(
|
|||
"name": item["name"],
|
||||
}
|
||||
for item in items
|
||||
if item.get(".tag") == "folder"
|
||||
and item.get("name")
|
||||
if item.get(".tag") == "folder" and item.get("name")
|
||||
]
|
||||
except Exception:
|
||||
logger.warning(
|
||||
|
|
@ -239,12 +238,12 @@ def create_create_dropbox_file_tool(
|
|||
client = DropboxClient(session=db_session, connector_id=connector.id)
|
||||
|
||||
parent_path = final_parent_folder_path or ""
|
||||
file_path = f"{parent_path}/{final_name}" if parent_path else f"/{final_name}"
|
||||
file_path = (
|
||||
f"{parent_path}/{final_name}" if parent_path else f"/{final_name}"
|
||||
)
|
||||
|
||||
if final_file_type == "paper":
|
||||
created = await client.create_paper_doc(
|
||||
file_path, final_content or ""
|
||||
)
|
||||
created = await client.create_paper_doc(file_path, final_content or "")
|
||||
file_id = created.get("file_id", "")
|
||||
web_url = created.get("url", "")
|
||||
else:
|
||||
|
|
@ -255,9 +254,7 @@ def create_create_dropbox_file_tool(
|
|||
file_id = created.get("id", "")
|
||||
web_url = ""
|
||||
|
||||
logger.info(
|
||||
f"Dropbox file created: id={file_id}, name={final_name}"
|
||||
)
|
||||
logger.info(f"Dropbox file created: id={file_id}, name={final_name}")
|
||||
|
||||
kb_message_suffix = ""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -248,9 +248,7 @@ def create_delete_dropbox_file_tool(
|
|||
f"Deleting Dropbox file: path='{final_file_path}', connector={actual_connector_id}"
|
||||
)
|
||||
|
||||
client = DropboxClient(
|
||||
session=db_session, connector_id=actual_connector_id
|
||||
)
|
||||
client = DropboxClient(session=db_session, connector_id=actual_connector_id)
|
||||
await client.delete_file(final_file_path)
|
||||
|
||||
logger.info(f"Dropbox file deleted: path={final_file_path}")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import datetime
|
||||
|
||||
# TODO: move these to config file
|
||||
MAX_SLIDES = 5
|
||||
FPS = 30
|
||||
DEFAULT_DURATION_IN_FRAMES = 300
|
||||
from app.config import config as app_config
|
||||
|
||||
MAX_SLIDES = app_config.VIDEO_PRESENTATION_MAX_SLIDES
|
||||
FPS = app_config.VIDEO_PRESENTATION_FPS
|
||||
DEFAULT_DURATION_IN_FRAMES = app_config.VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES
|
||||
|
||||
THEME_PRESETS = [
|
||||
"TERRA",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "surfsense")
|
|||
# Format: "<number><unit>" where unit is 'm' (minutes) or 'h' (hours)
|
||||
# Examples: "1m" (every minute), "5m" (every 5 minutes), "1h" (every hour)
|
||||
SCHEDULE_CHECKER_INTERVAL = os.getenv("SCHEDULE_CHECKER_INTERVAL", "2m")
|
||||
STRIPE_RECONCILIATION_INTERVAL = os.getenv("STRIPE_RECONCILIATION_INTERVAL", "10m")
|
||||
|
||||
|
||||
def parse_schedule_interval(interval: str) -> dict:
|
||||
|
|
@ -68,6 +69,9 @@ def parse_schedule_interval(interval: str) -> dict:
|
|||
|
||||
# Parse the schedule interval
|
||||
schedule_params = parse_schedule_interval(SCHEDULE_CHECKER_INTERVAL)
|
||||
stripe_reconciliation_schedule_params = parse_schedule_interval(
|
||||
STRIPE_RECONCILIATION_INTERVAL
|
||||
)
|
||||
|
||||
# Create Celery app
|
||||
celery_app = Celery(
|
||||
|
|
@ -82,6 +86,7 @@ celery_app = Celery(
|
|||
"app.tasks.celery_tasks.schedule_checker_task",
|
||||
"app.tasks.celery_tasks.document_reindex_tasks",
|
||||
"app.tasks.celery_tasks.stale_notification_cleanup_task",
|
||||
"app.tasks.celery_tasks.stripe_reconciliation_task",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -169,4 +174,12 @@ celery_app.conf.beat_schedule = {
|
|||
"expires": 60, # Task expires after 60 seconds if not picked up
|
||||
},
|
||||
},
|
||||
# Reconcile Stripe purchases that were paid but remained pending
|
||||
"reconcile-pending-stripe-page-purchases": {
|
||||
"task": "reconcile_pending_stripe_page_purchases",
|
||||
"schedule": crontab(**stripe_reconciliation_schedule_params),
|
||||
"options": {
|
||||
"expires": 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,21 @@ class Config:
|
|||
# Backend URL to override the http to https in the OAuth redirect URI
|
||||
BACKEND_URL = os.getenv("BACKEND_URL")
|
||||
|
||||
# Stripe checkout for pay-as-you-go page packs
|
||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
|
||||
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
||||
STRIPE_PRICE_ID = os.getenv("STRIPE_PRICE_ID")
|
||||
STRIPE_PAGES_PER_UNIT = int(os.getenv("STRIPE_PAGES_PER_UNIT", "1000"))
|
||||
STRIPE_PAGE_BUYING_ENABLED = (
|
||||
os.getenv("STRIPE_PAGE_BUYING_ENABLED", "TRUE").upper() == "TRUE"
|
||||
)
|
||||
STRIPE_RECONCILIATION_LOOKBACK_MINUTES = int(
|
||||
os.getenv("STRIPE_RECONCILIATION_LOOKBACK_MINUTES", "10")
|
||||
)
|
||||
STRIPE_RECONCILIATION_BATCH_SIZE = int(
|
||||
os.getenv("STRIPE_RECONCILIATION_BATCH_SIZE", "100")
|
||||
)
|
||||
|
||||
# Auth
|
||||
AUTH_TYPE = os.getenv("AUTH_TYPE")
|
||||
REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE"
|
||||
|
|
@ -400,6 +415,15 @@ class Config:
|
|||
STT_SERVICE_API_BASE = os.getenv("STT_SERVICE_API_BASE")
|
||||
STT_SERVICE_API_KEY = os.getenv("STT_SERVICE_API_KEY")
|
||||
|
||||
# Video presentation defaults
|
||||
VIDEO_PRESENTATION_MAX_SLIDES = int(
|
||||
os.getenv("VIDEO_PRESENTATION_MAX_SLIDES", "30")
|
||||
)
|
||||
VIDEO_PRESENTATION_FPS = int(os.getenv("VIDEO_PRESENTATION_FPS", "30"))
|
||||
VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES = int(
|
||||
os.getenv("VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES", "300")
|
||||
)
|
||||
|
||||
# Validation Checks
|
||||
# Check embedding dimension
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -225,18 +225,14 @@ class DropboxClient:
|
|||
|
||||
return all_items, None
|
||||
|
||||
async def get_metadata(
|
||||
self, path: str
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
async def get_metadata(self, path: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
resp = await self._request("/2/files/get_metadata", {"path": path})
|
||||
if resp.status_code != 200:
|
||||
return None, f"Failed to get metadata: {resp.status_code} - {resp.text}"
|
||||
return resp.json(), None
|
||||
|
||||
async def download_file(self, path: str) -> tuple[bytes | None, str | None]:
|
||||
resp = await self._content_request(
|
||||
"/2/files/download", {"path": path}
|
||||
)
|
||||
resp = await self._content_request("/2/files/download", {"path": path})
|
||||
if resp.status_code != 200:
|
||||
return None, f"Download failed: {resp.status_code}"
|
||||
return resp.content, None
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import contextlib
|
|||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .client import DropboxClient
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
|
|
@ -292,6 +293,12 @@ class IncentiveTaskType(StrEnum):
|
|||
# REFER_FRIEND = "REFER_FRIEND"
|
||||
|
||||
|
||||
class PagePurchaseStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
# Centralized configuration for incentive tasks
|
||||
# This makes it easy to add new tasks without changing code in multiple places
|
||||
INCENTIVE_TASKS_CONFIG = {
|
||||
|
|
@ -1643,6 +1650,39 @@ class UserIncentiveTask(BaseModel, TimestampMixin):
|
|||
user = relationship("User", back_populates="incentive_tasks")
|
||||
|
||||
|
||||
class PagePurchase(Base, TimestampMixin):
|
||||
"""Tracks Stripe checkout sessions used to grant additional page credits."""
|
||||
|
||||
__tablename__ = "page_purchases"
|
||||
__allow_unmapped__ = True
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
stripe_checkout_session_id = Column(
|
||||
String(255), nullable=False, unique=True, index=True
|
||||
)
|
||||
stripe_payment_intent_id = Column(String(255), nullable=True, index=True)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
pages_granted = Column(Integer, nullable=False)
|
||||
amount_total = Column(Integer, nullable=True)
|
||||
currency = Column(String(10), nullable=True)
|
||||
status = Column(
|
||||
SQLAlchemyEnum(PagePurchaseStatus),
|
||||
nullable=False,
|
||||
default=PagePurchaseStatus.PENDING,
|
||||
server_default=text("'PENDING'::pagepurchasestatus"),
|
||||
index=True,
|
||||
)
|
||||
completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="page_purchases")
|
||||
|
||||
|
||||
class SearchSpaceRole(BaseModel, TimestampMixin):
|
||||
"""
|
||||
Custom roles that can be defined per search space.
|
||||
|
|
@ -1910,6 +1950,11 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
page_purchases = relationship(
|
||||
"PagePurchase",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Page usage tracking for ETL services
|
||||
pages_limit = Column(
|
||||
|
|
@ -2019,6 +2064,11 @@ else:
|
|||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
page_purchases = relationship(
|
||||
"PagePurchase",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Page usage tracking for ETL services
|
||||
pages_limit = Column(
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from .clickup_add_connector_route import router as clickup_add_connector_router
|
|||
from .composio_routes import router as composio_router
|
||||
from .confluence_add_connector_route import router as confluence_add_connector_router
|
||||
from .discord_add_connector_route import router as discord_add_connector_router
|
||||
from .dropbox_add_connector_route import router as dropbox_add_connector_router
|
||||
from .documents_routes import router as documents_router
|
||||
from .dropbox_add_connector_route import router as dropbox_add_connector_router
|
||||
from .editor_routes import router as editor_router
|
||||
from .folders_routes import router as folders_router
|
||||
from .google_calendar_add_connector_route import (
|
||||
|
|
@ -44,6 +44,7 @@ from .sandbox_routes import router as sandbox_router
|
|||
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||
from .search_spaces_routes import router as search_spaces_router
|
||||
from .slack_add_connector_route import router as slack_add_connector_router
|
||||
from .stripe_routes import router as stripe_router
|
||||
from .surfsense_docs_routes import router as surfsense_docs_router
|
||||
from .teams_add_connector_route import router as teams_add_connector_router
|
||||
from .video_presentations_routes import router as video_presentations_router
|
||||
|
|
@ -91,5 +92,6 @@ router.include_router(notifications_router) # Notifications with Zero sync
|
|||
router.include_router(composio_router) # Composio OAuth and toolkit management
|
||||
router.include_router(public_chat_router) # Public chat sharing and cloning
|
||||
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages
|
||||
router.include_router(stripe_router) # Stripe checkout for additional page packs
|
||||
router.include_router(youtube_router) # YouTube playlist resolution
|
||||
router.include_router(prompts_router)
|
||||
|
|
|
|||
|
|
@ -72,9 +72,7 @@ async def connect_dropbox(space_id: int, user: User = Depends(current_active_use
|
|||
if not space_id:
|
||||
raise HTTPException(status_code=400, detail="space_id is required")
|
||||
if not config.DROPBOX_APP_KEY:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Dropbox OAuth not configured."
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Dropbox OAuth not configured.")
|
||||
if not config.SECRET_KEY:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="SECRET_KEY not configured for OAuth security."
|
||||
|
|
|
|||
|
|
@ -117,8 +117,10 @@ async def complete_task(
|
|||
)
|
||||
session.add(new_task)
|
||||
|
||||
# Update user's pages_limit
|
||||
user.pages_limit += pages_reward
|
||||
# pages_used can exceed pages_limit when a document's final page count is
|
||||
# determined after processing. Base the new limit on the higher of the two
|
||||
# so the rewarded pages are fully usable above the current high-water mark.
|
||||
user.pages_limit = max(user.pages_used, user.pages_limit) + pages_reward
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
|
|
|||
371
surfsense_backend/app/routes/stripe_routes.py
Normal file
371
surfsense_backend/app/routes/stripe_routes.py
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"""Stripe routes for pay-as-you-go page purchases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from stripe import SignatureVerificationError, StripeClient, StripeError
|
||||
|
||||
from app.config import config
|
||||
from app.db import PagePurchase, PagePurchaseStatus, User, get_async_session
|
||||
from app.schemas.stripe import (
|
||||
CreateCheckoutSessionRequest,
|
||||
CreateCheckoutSessionResponse,
|
||||
PagePurchaseHistoryResponse,
|
||||
StripeStatusResponse,
|
||||
StripeWebhookResponse,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/stripe", tags=["stripe"])
|
||||
|
||||
|
||||
def get_stripe_client() -> StripeClient:
|
||||
"""Return a configured Stripe client or raise if Stripe is disabled."""
|
||||
if not config.STRIPE_SECRET_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Stripe checkout is not configured.",
|
||||
)
|
||||
return StripeClient(config.STRIPE_SECRET_KEY)
|
||||
|
||||
|
||||
def _ensure_page_buying_enabled() -> None:
|
||||
if not config.STRIPE_PAGE_BUYING_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Page purchases are temporarily unavailable.",
|
||||
)
|
||||
|
||||
|
||||
def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
|
||||
if not config.NEXT_FRONTEND_URL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NEXT_FRONTEND_URL is not configured.",
|
||||
)
|
||||
|
||||
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
|
||||
success_url = f"{base_url}/dashboard/{search_space_id}/purchase-success"
|
||||
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
|
||||
return success_url, cancel_url
|
||||
|
||||
|
||||
def _get_required_stripe_price_id() -> str:
|
||||
if not config.STRIPE_PRICE_ID:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="STRIPE_PRICE_ID is not configured.",
|
||||
)
|
||||
return config.STRIPE_PRICE_ID
|
||||
|
||||
|
||||
def _normalize_optional_string(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return getattr(value, "id", str(value))
|
||||
|
||||
|
||||
def _get_metadata(checkout_session: Any) -> dict[str, str]:
|
||||
metadata = getattr(checkout_session, "metadata", None) or {}
|
||||
if isinstance(metadata, dict):
|
||||
return {str(key): str(value) for key, value in metadata.items()}
|
||||
return dict(metadata)
|
||||
|
||||
|
||||
async def _get_or_create_purchase_from_checkout_session(
|
||||
db_session: AsyncSession,
|
||||
checkout_session: Any,
|
||||
) -> PagePurchase | None:
|
||||
"""Look up a PagePurchase by checkout session ID (with FOR UPDATE lock).
|
||||
|
||||
If the row doesn't exist yet (e.g. the webhook arrived before the API
|
||||
response committed), create one from the Stripe session metadata.
|
||||
"""
|
||||
checkout_session_id = str(checkout_session.id)
|
||||
purchase = (
|
||||
await db_session.execute(
|
||||
select(PagePurchase)
|
||||
.where(PagePurchase.stripe_checkout_session_id == checkout_session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if purchase is not None:
|
||||
return purchase
|
||||
|
||||
metadata = _get_metadata(checkout_session)
|
||||
user_id = metadata.get("user_id")
|
||||
quantity = int(metadata.get("quantity", "0"))
|
||||
pages_per_unit = int(metadata.get("pages_per_unit", "0"))
|
||||
|
||||
if not user_id or quantity <= 0 or pages_per_unit <= 0:
|
||||
logger.error(
|
||||
"Skipping Stripe fulfillment for session %s due to incomplete metadata: %s",
|
||||
checkout_session_id,
|
||||
metadata,
|
||||
)
|
||||
return None
|
||||
|
||||
purchase = PagePurchase(
|
||||
user_id=uuid.UUID(user_id),
|
||||
stripe_checkout_session_id=checkout_session_id,
|
||||
stripe_payment_intent_id=_normalize_optional_string(
|
||||
getattr(checkout_session, "payment_intent", None)
|
||||
),
|
||||
quantity=quantity,
|
||||
pages_granted=quantity * pages_per_unit,
|
||||
amount_total=getattr(checkout_session, "amount_total", None),
|
||||
currency=getattr(checkout_session, "currency", None),
|
||||
status=PagePurchaseStatus.PENDING,
|
||||
)
|
||||
db_session.add(purchase)
|
||||
await db_session.flush()
|
||||
return purchase
|
||||
|
||||
|
||||
async def _mark_purchase_failed(
|
||||
db_session: AsyncSession, checkout_session_id: str
|
||||
) -> StripeWebhookResponse:
|
||||
purchase = (
|
||||
await db_session.execute(
|
||||
select(PagePurchase)
|
||||
.where(PagePurchase.stripe_checkout_session_id == checkout_session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if purchase is not None and purchase.status == PagePurchaseStatus.PENDING:
|
||||
purchase.status = PagePurchaseStatus.FAILED
|
||||
await db_session.commit()
|
||||
|
||||
return StripeWebhookResponse()
|
||||
|
||||
|
||||
async def _fulfill_completed_purchase(
|
||||
db_session: AsyncSession, checkout_session: Any
|
||||
) -> StripeWebhookResponse:
|
||||
"""Grant pages to the user after a confirmed Stripe payment.
|
||||
|
||||
Uses SELECT ... FOR UPDATE on both the PagePurchase and User rows to
|
||||
prevent double-granting when Stripe retries the webhook concurrently.
|
||||
"""
|
||||
purchase = await _get_or_create_purchase_from_checkout_session(
|
||||
db_session, checkout_session
|
||||
)
|
||||
if purchase is None:
|
||||
return StripeWebhookResponse()
|
||||
|
||||
if purchase.status == PagePurchaseStatus.COMPLETED:
|
||||
return StripeWebhookResponse()
|
||||
|
||||
user = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(User).where(User.id == purchase.user_id).with_for_update(of=User)
|
||||
)
|
||||
)
|
||||
.unique()
|
||||
.scalar_one_or_none()
|
||||
)
|
||||
if user is None:
|
||||
logger.error(
|
||||
"Skipping Stripe fulfillment for session %s because user %s was not found.",
|
||||
purchase.stripe_checkout_session_id,
|
||||
purchase.user_id,
|
||||
)
|
||||
return StripeWebhookResponse()
|
||||
|
||||
purchase.status = PagePurchaseStatus.COMPLETED
|
||||
purchase.completed_at = datetime.now(UTC)
|
||||
purchase.amount_total = getattr(checkout_session, "amount_total", None)
|
||||
purchase.currency = getattr(checkout_session, "currency", None)
|
||||
purchase.stripe_payment_intent_id = _normalize_optional_string(
|
||||
getattr(checkout_session, "payment_intent", None)
|
||||
)
|
||||
# pages_used can exceed pages_limit when a document's final page count is
|
||||
# determined after processing. Base the new limit on the higher of the two
|
||||
# so the purchased pages are fully usable above the current high-water mark.
|
||||
user.pages_limit = max(user.pages_used, user.pages_limit) + purchase.pages_granted
|
||||
|
||||
await db_session.commit()
|
||||
return StripeWebhookResponse()
|
||||
|
||||
|
||||
@router.post("/create-checkout-session", response_model=CreateCheckoutSessionResponse)
|
||||
async def create_checkout_session(
|
||||
body: CreateCheckoutSessionRequest,
|
||||
user: User = Depends(current_active_user),
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
) -> CreateCheckoutSessionResponse:
|
||||
"""Create a Stripe Checkout Session for buying page packs."""
|
||||
_ensure_page_buying_enabled()
|
||||
stripe_client = get_stripe_client()
|
||||
price_id = _get_required_stripe_price_id()
|
||||
success_url, cancel_url = _get_checkout_urls(body.search_space_id)
|
||||
pages_granted = body.quantity * config.STRIPE_PAGES_PER_UNIT
|
||||
|
||||
try:
|
||||
checkout_session = stripe_client.v1.checkout.sessions.create(
|
||||
params={
|
||||
"mode": "payment",
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
"line_items": [
|
||||
{
|
||||
"price": price_id,
|
||||
"quantity": body.quantity,
|
||||
}
|
||||
],
|
||||
"client_reference_id": str(user.id),
|
||||
"customer_email": user.email,
|
||||
"metadata": {
|
||||
"user_id": str(user.id),
|
||||
"quantity": str(body.quantity),
|
||||
"pages_per_unit": str(config.STRIPE_PAGES_PER_UNIT),
|
||||
"purchase_type": "page_packs",
|
||||
},
|
||||
}
|
||||
)
|
||||
except StripeError as exc:
|
||||
logger.exception(
|
||||
"Failed to create Stripe checkout session for user %s", user.id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Unable to create Stripe checkout session.",
|
||||
) from exc
|
||||
|
||||
checkout_url = getattr(checkout_session, "url", None)
|
||||
if not checkout_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Stripe checkout session did not return a URL.",
|
||||
)
|
||||
|
||||
db_session.add(
|
||||
PagePurchase(
|
||||
user_id=user.id,
|
||||
stripe_checkout_session_id=str(checkout_session.id),
|
||||
stripe_payment_intent_id=_normalize_optional_string(
|
||||
getattr(checkout_session, "payment_intent", None)
|
||||
),
|
||||
quantity=body.quantity,
|
||||
pages_granted=pages_granted,
|
||||
amount_total=getattr(checkout_session, "amount_total", None),
|
||||
currency=getattr(checkout_session, "currency", None),
|
||||
status=PagePurchaseStatus.PENDING,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
return CreateCheckoutSessionResponse(checkout_url=checkout_url)
|
||||
|
||||
|
||||
@router.get("/status", response_model=StripeStatusResponse)
|
||||
async def get_stripe_status() -> StripeStatusResponse:
|
||||
"""Return page-buying availability for frontend feature gating."""
|
||||
return StripeStatusResponse(page_buying_enabled=config.STRIPE_PAGE_BUYING_ENABLED)
|
||||
|
||||
|
||||
@router.post("/webhook", response_model=StripeWebhookResponse)
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
) -> StripeWebhookResponse:
|
||||
"""Handle Stripe webhooks and grant purchased pages after payment."""
|
||||
if not config.STRIPE_WEBHOOK_SECRET:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Stripe webhook handling is not configured.",
|
||||
)
|
||||
|
||||
stripe_client = get_stripe_client()
|
||||
payload = await request.body()
|
||||
signature = request.headers.get("Stripe-Signature")
|
||||
|
||||
if not signature:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing Stripe-Signature header.",
|
||||
)
|
||||
|
||||
try:
|
||||
event = stripe_client.construct_event(
|
||||
payload,
|
||||
signature,
|
||||
config.STRIPE_WEBHOOK_SECRET,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Stripe webhook payload.",
|
||||
) from exc
|
||||
except SignatureVerificationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Stripe webhook signature.",
|
||||
) from exc
|
||||
|
||||
if event.type in {
|
||||
"checkout.session.completed",
|
||||
"checkout.session.async_payment_succeeded",
|
||||
}:
|
||||
checkout_session = event.data.object
|
||||
payment_status = getattr(checkout_session, "payment_status", None)
|
||||
|
||||
if event.type == "checkout.session.completed" and payment_status not in {
|
||||
"paid",
|
||||
"no_payment_required",
|
||||
}:
|
||||
logger.info(
|
||||
"Received checkout.session.completed for unpaid session %s; waiting for async success.",
|
||||
checkout_session.id,
|
||||
)
|
||||
return StripeWebhookResponse()
|
||||
|
||||
return await _fulfill_completed_purchase(db_session, checkout_session)
|
||||
|
||||
if event.type in {
|
||||
"checkout.session.async_payment_failed",
|
||||
"checkout.session.expired",
|
||||
}:
|
||||
checkout_session = event.data.object
|
||||
return await _mark_purchase_failed(db_session, str(checkout_session.id))
|
||||
|
||||
return StripeWebhookResponse()
|
||||
|
||||
|
||||
@router.get("/purchases", response_model=PagePurchaseHistoryResponse)
|
||||
async def get_page_purchases(
|
||||
user: User = Depends(current_active_user),
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
) -> PagePurchaseHistoryResponse:
|
||||
"""Return the authenticated user's page-purchase history."""
|
||||
limit = min(limit, 100)
|
||||
purchases = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(PagePurchase)
|
||||
.where(PagePurchase.user_id == user.id)
|
||||
.order_by(PagePurchase.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
return PagePurchaseHistoryResponse(purchases=purchases)
|
||||
|
|
@ -110,6 +110,14 @@ from .search_space import (
|
|||
SearchSpaceUpdate,
|
||||
SearchSpaceWithStats,
|
||||
)
|
||||
from .stripe import (
|
||||
CreateCheckoutSessionRequest,
|
||||
CreateCheckoutSessionResponse,
|
||||
PagePurchaseHistoryResponse,
|
||||
PagePurchaseRead,
|
||||
StripeStatusResponse,
|
||||
StripeWebhookResponse,
|
||||
)
|
||||
from .users import UserCreate, UserRead, UserUpdate
|
||||
from .video_presentations import (
|
||||
VideoPresentationBase,
|
||||
|
|
@ -128,6 +136,8 @@ __all__ = [
|
|||
"ChunkCreate",
|
||||
"ChunkRead",
|
||||
"ChunkUpdate",
|
||||
"CreateCheckoutSessionRequest",
|
||||
"CreateCheckoutSessionResponse",
|
||||
"DefaultSystemInstructionsResponse",
|
||||
# Document schemas
|
||||
"DocumentBase",
|
||||
|
|
@ -207,6 +217,8 @@ __all__ = [
|
|||
"NewLLMConfigPublic",
|
||||
"NewLLMConfigRead",
|
||||
"NewLLMConfigUpdate",
|
||||
"PagePurchaseHistoryResponse",
|
||||
"PagePurchaseRead",
|
||||
"PaginatedResponse",
|
||||
"PermissionInfo",
|
||||
"PermissionsListResponse",
|
||||
|
|
@ -236,6 +248,8 @@ __all__ = [
|
|||
"SearchSpaceRead",
|
||||
"SearchSpaceUpdate",
|
||||
"SearchSpaceWithStats",
|
||||
"StripeStatusResponse",
|
||||
"StripeWebhookResponse",
|
||||
"ThreadHistoryLoadResponse",
|
||||
"ThreadListItem",
|
||||
"ThreadListResponse",
|
||||
|
|
|
|||
56
surfsense_backend/app/schemas/stripe.py
Normal file
56
surfsense_backend/app/schemas/stripe.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Schemas for Stripe-backed page purchases."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.db import PagePurchaseStatus
|
||||
|
||||
|
||||
class CreateCheckoutSessionRequest(BaseModel):
|
||||
"""Request body for creating a page-purchase checkout session."""
|
||||
|
||||
quantity: int = Field(ge=1, le=100)
|
||||
search_space_id: int = Field(ge=1)
|
||||
|
||||
|
||||
class CreateCheckoutSessionResponse(BaseModel):
|
||||
"""Response containing the Stripe-hosted checkout URL."""
|
||||
|
||||
checkout_url: str
|
||||
|
||||
|
||||
class StripeStatusResponse(BaseModel):
|
||||
"""Response describing Stripe page-buying availability."""
|
||||
|
||||
page_buying_enabled: bool
|
||||
|
||||
|
||||
class PagePurchaseRead(BaseModel):
|
||||
"""Serialized page-purchase record for purchase history."""
|
||||
|
||||
id: uuid.UUID
|
||||
stripe_checkout_session_id: str
|
||||
stripe_payment_intent_id: str | None = None
|
||||
quantity: int
|
||||
pages_granted: int
|
||||
amount_total: int | None = None
|
||||
currency: str | None = None
|
||||
status: PagePurchaseStatus
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PagePurchaseHistoryResponse(BaseModel):
|
||||
"""Response containing the authenticated user's page purchases."""
|
||||
|
||||
purchases: list[PagePurchaseRead]
|
||||
|
||||
|
||||
class StripeWebhookResponse(BaseModel):
|
||||
"""Generic acknowledgement for Stripe webhook delivery."""
|
||||
|
||||
received: bool = True
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
"""Reconcile pending Stripe page purchases that might miss webhook fulfillment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from stripe import StripeClient, StripeError
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.config import config
|
||||
from app.db import PagePurchase, PagePurchaseStatus
|
||||
from app.routes import stripe_routes
|
||||
from app.tasks.celery_tasks import get_celery_session_maker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_stripe_client() -> StripeClient | None:
|
||||
"""Return a Stripe client for reconciliation, or None when disabled."""
|
||||
if not config.STRIPE_SECRET_KEY:
|
||||
logger.warning(
|
||||
"Stripe reconciliation skipped because STRIPE_SECRET_KEY is not configured."
|
||||
)
|
||||
return None
|
||||
return StripeClient(config.STRIPE_SECRET_KEY)
|
||||
|
||||
|
||||
@celery_app.task(name="reconcile_pending_stripe_page_purchases")
|
||||
def reconcile_pending_stripe_page_purchases_task():
|
||||
"""Recover paid purchases that were left pending due to missed webhook handling."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(_reconcile_pending_page_purchases())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _reconcile_pending_page_purchases() -> None:
|
||||
"""Reconcile stale pending page purchases against Stripe source of truth.
|
||||
|
||||
Stripe retries webhook delivery automatically, but best practice is to add an
|
||||
application-level reconciliation path in case all retries fail or the endpoint
|
||||
is unavailable for an extended window.
|
||||
"""
|
||||
stripe_client = get_stripe_client()
|
||||
if stripe_client is None:
|
||||
return
|
||||
|
||||
lookback_minutes = max(config.STRIPE_RECONCILIATION_LOOKBACK_MINUTES, 0)
|
||||
batch_size = max(config.STRIPE_RECONCILIATION_BATCH_SIZE, 1)
|
||||
cutoff = datetime.now(UTC) - timedelta(minutes=lookback_minutes)
|
||||
|
||||
async with get_celery_session_maker()() as db_session:
|
||||
pending_purchases = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(PagePurchase)
|
||||
.where(
|
||||
PagePurchase.status == PagePurchaseStatus.PENDING,
|
||||
PagePurchase.created_at <= cutoff,
|
||||
)
|
||||
.order_by(PagePurchase.created_at.asc())
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not pending_purchases:
|
||||
logger.debug(
|
||||
"Stripe reconciliation found no pending purchases older than %s minutes.",
|
||||
lookback_minutes,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Stripe reconciliation checking %s pending purchases (cutoff=%s, batch=%s).",
|
||||
len(pending_purchases),
|
||||
lookback_minutes,
|
||||
batch_size,
|
||||
)
|
||||
|
||||
fulfilled_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for purchase in pending_purchases:
|
||||
checkout_session_id = purchase.stripe_checkout_session_id
|
||||
|
||||
try:
|
||||
checkout_session = stripe_client.v1.checkout.sessions.retrieve(
|
||||
checkout_session_id
|
||||
)
|
||||
except StripeError:
|
||||
logger.exception(
|
||||
"Stripe reconciliation failed to retrieve checkout session %s",
|
||||
checkout_session_id,
|
||||
)
|
||||
await db_session.rollback()
|
||||
continue
|
||||
|
||||
payment_status = getattr(checkout_session, "payment_status", None)
|
||||
session_status = getattr(checkout_session, "status", None)
|
||||
|
||||
try:
|
||||
if payment_status in {"paid", "no_payment_required"}:
|
||||
await stripe_routes._fulfill_completed_purchase(
|
||||
db_session, checkout_session
|
||||
)
|
||||
fulfilled_count += 1
|
||||
elif session_status == "expired":
|
||||
await stripe_routes._mark_purchase_failed(
|
||||
db_session, str(checkout_session.id)
|
||||
)
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Stripe reconciliation failed while processing checkout session %s",
|
||||
checkout_session_id,
|
||||
)
|
||||
await db_session.rollback()
|
||||
|
||||
logger.info(
|
||||
"Stripe reconciliation completed. fulfilled=%s failed=%s checked=%s",
|
||||
fulfilled_count,
|
||||
failed_count,
|
||||
len(pending_purchases),
|
||||
)
|
||||
|
|
@ -466,7 +466,9 @@ async def index_dropbox_files(
|
|||
|
||||
folders = items_dict.get("folders", [])
|
||||
for folder in folders:
|
||||
folder_path = folder.get("path", folder.get("path_lower", folder.get("id", "")))
|
||||
folder_path = folder.get(
|
||||
"path", folder.get("path_lower", folder.get("id", ""))
|
||||
)
|
||||
folder_name = folder.get("name", "Root")
|
||||
|
||||
logger.info(f"Using full scan for folder {folder_name}")
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ dependencies = [
|
|||
"langgraph>=1.1.3",
|
||||
"langchain-community>=0.4.1",
|
||||
"deepagents>=0.4.12",
|
||||
"stripe>=15.0.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,490 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
|
||||
from app.app import app
|
||||
from app.routes import stripe_routes
|
||||
from app.tasks.celery_tasks import stripe_reconciliation_task
|
||||
from tests.conftest import TEST_DATABASE_URL
|
||||
from tests.utils.helpers import TEST_EMAIL, TEST_PASSWORD, auth_headers
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_ASYNCPG_URL = TEST_DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
|
||||
|
||||
|
||||
async def _execute(query: str, *args) -> None:
|
||||
conn = await asyncpg.connect(_ASYNCPG_URL)
|
||||
try:
|
||||
await conn.execute(query, *args)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _fetchrow(query: str, *args):
|
||||
conn = await asyncpg.connect(_ASYNCPG_URL)
|
||||
try:
|
||||
return await conn.fetchrow(query, *args)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _get_user_id(email: str) -> str:
|
||||
row = await _fetchrow('SELECT id FROM "user" WHERE email = $1', email)
|
||||
assert row is not None, f"User {email!r} not found"
|
||||
return str(row["id"])
|
||||
|
||||
|
||||
async def _get_pages_limit(email: str) -> int:
|
||||
row = await _fetchrow('SELECT pages_limit FROM "user" WHERE email = $1', email)
|
||||
assert row is not None, f"User {email!r} not found"
|
||||
return row["pages_limit"]
|
||||
|
||||
|
||||
def _extract_access_token(response: httpx.Response) -> str | None:
|
||||
if response.status_code == 200:
|
||||
return response.json()["access_token"]
|
||||
|
||||
if response.status_code == 302:
|
||||
location = response.headers.get("location", "")
|
||||
return parse_qs(urlparse(location).query).get("token", [None])[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _authenticate_test_user(client: httpx.AsyncClient) -> str:
|
||||
response = await client.post(
|
||||
"/auth/jwt/login",
|
||||
data={"username": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = _extract_access_token(response)
|
||||
if token:
|
||||
return token
|
||||
|
||||
reg_response = await client.post(
|
||||
"/auth/register",
|
||||
json={"email": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
)
|
||||
assert reg_response.status_code == 201, (
|
||||
f"Registration failed ({reg_response.status_code}): {reg_response.text}"
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/auth/jwt/login",
|
||||
data={"username": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = _extract_access_token(response)
|
||||
assert token, f"Login failed ({response.status_code}): {response.text}"
|
||||
return token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def auth_token(_ensure_tables) -> str:
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test", timeout=30.0
|
||||
) as client:
|
||||
return await _authenticate_test_user(client)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def headers(auth_token: str) -> dict[str, str]:
|
||||
return auth_headers(auth_token)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _cleanup_page_purchases():
|
||||
await _execute("DELETE FROM page_purchases")
|
||||
yield
|
||||
await _execute("DELETE FROM page_purchases")
|
||||
|
||||
|
||||
class _FakeCreateStripeClient:
|
||||
def __init__(self, checkout_session):
|
||||
self.checkout_session = checkout_session
|
||||
self.last_params = None
|
||||
self.v1 = SimpleNamespace(
|
||||
checkout=SimpleNamespace(
|
||||
sessions=SimpleNamespace(create=self._create_session)
|
||||
)
|
||||
)
|
||||
|
||||
def _create_session(self, *, params):
|
||||
self.last_params = params
|
||||
return self.checkout_session
|
||||
|
||||
|
||||
class _FakeWebhookStripeClient:
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
self.last_payload = None
|
||||
self.last_signature = None
|
||||
self.last_secret = None
|
||||
|
||||
def construct_event(self, payload, signature, secret):
|
||||
self.last_payload = payload
|
||||
self.last_signature = signature
|
||||
self.last_secret = secret
|
||||
return self.event
|
||||
|
||||
|
||||
class _FakeReconciliationStripeClient:
|
||||
def __init__(self, checkout_session):
|
||||
self.checkout_session = checkout_session
|
||||
self.requested_ids = []
|
||||
self.v1 = SimpleNamespace(
|
||||
checkout=SimpleNamespace(
|
||||
sessions=SimpleNamespace(retrieve=self._retrieve_session)
|
||||
)
|
||||
)
|
||||
|
||||
def _retrieve_session(self, checkout_session_id: str):
|
||||
self.requested_ids.append(checkout_session_id)
|
||||
return self.checkout_session
|
||||
|
||||
|
||||
class TestStripeCheckoutSessionCreation:
|
||||
async def test_get_status_reflects_backend_toggle(self, client, headers, monkeypatch):
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", False)
|
||||
disabled_response = await client.get("/api/v1/stripe/status", headers=headers)
|
||||
assert disabled_response.status_code == 200, disabled_response.text
|
||||
assert disabled_response.json() == {"page_buying_enabled": False}
|
||||
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", True)
|
||||
enabled_response = await client.get("/api/v1/stripe/status", headers=headers)
|
||||
assert enabled_response.status_code == 200, enabled_response.text
|
||||
assert enabled_response.json() == {"page_buying_enabled": True}
|
||||
|
||||
async def test_create_checkout_session_records_pending_purchase(
|
||||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
monkeypatch,
|
||||
):
|
||||
checkout_session = SimpleNamespace(
|
||||
id="cs_test_create_123",
|
||||
url="https://checkout.stripe.test/cs_test_create_123",
|
||||
payment_intent=None,
|
||||
amount_total=None,
|
||||
currency=None,
|
||||
)
|
||||
fake_client = _FakeCreateStripeClient(checkout_session)
|
||||
|
||||
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: fake_client)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000")
|
||||
monkeypatch.setattr(
|
||||
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
|
||||
)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 2, "search_space_id": search_space_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {"checkout_url": checkout_session.url}
|
||||
assert fake_client.last_params is not None
|
||||
assert fake_client.last_params["mode"] == "payment"
|
||||
assert fake_client.last_params["line_items"] == [
|
||||
{"price": "price_pages_1000", "quantity": 2}
|
||||
]
|
||||
assert (
|
||||
fake_client.last_params["success_url"]
|
||||
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-success"
|
||||
)
|
||||
assert (
|
||||
fake_client.last_params["cancel_url"]
|
||||
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-cancel"
|
||||
)
|
||||
|
||||
purchase = await _fetchrow(
|
||||
"""
|
||||
SELECT quantity, pages_granted, status
|
||||
FROM page_purchases
|
||||
WHERE stripe_checkout_session_id = $1
|
||||
""",
|
||||
checkout_session.id,
|
||||
)
|
||||
assert purchase is not None
|
||||
assert purchase["quantity"] == 2
|
||||
assert purchase["pages_granted"] == 2000
|
||||
assert purchase["status"] == "PENDING"
|
||||
|
||||
async def test_create_checkout_session_returns_503_when_buying_disabled(
|
||||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", False)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 2, "search_space_id": search_space_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 503, response.text
|
||||
assert response.json()["detail"] == "Page purchases are temporarily unavailable."
|
||||
|
||||
purchase_count = await _fetchrow("SELECT COUNT(*) AS count FROM page_purchases")
|
||||
assert purchase_count is not None
|
||||
assert purchase_count["count"] == 0
|
||||
|
||||
|
||||
class TestStripeWebhookFulfillment:
|
||||
async def test_webhook_grants_pages_once(
|
||||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
page_limits,
|
||||
monkeypatch,
|
||||
):
|
||||
await page_limits.set(pages_used=0, pages_limit=100)
|
||||
|
||||
checkout_session = SimpleNamespace(
|
||||
id="cs_test_webhook_123",
|
||||
url="https://checkout.stripe.test/cs_test_webhook_123",
|
||||
payment_intent=None,
|
||||
amount_total=None,
|
||||
currency=None,
|
||||
)
|
||||
create_client = _FakeCreateStripeClient(checkout_session)
|
||||
|
||||
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000")
|
||||
monkeypatch.setattr(
|
||||
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
|
||||
)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
|
||||
|
||||
create_response = await client.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 3, "search_space_id": search_space_id},
|
||||
)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
|
||||
initial_limit = await _get_pages_limit(TEST_EMAIL)
|
||||
assert initial_limit == 100
|
||||
|
||||
user_id = await _get_user_id(TEST_EMAIL)
|
||||
webhook_checkout_session = SimpleNamespace(
|
||||
id=checkout_session.id,
|
||||
payment_status="paid",
|
||||
payment_intent="pi_test_123",
|
||||
amount_total=300,
|
||||
currency="usd",
|
||||
metadata={
|
||||
"user_id": user_id,
|
||||
"quantity": "3",
|
||||
"pages_per_unit": "1000",
|
||||
},
|
||||
)
|
||||
event = SimpleNamespace(
|
||||
type="checkout.session.completed",
|
||||
data=SimpleNamespace(object=webhook_checkout_session),
|
||||
)
|
||||
webhook_client = _FakeWebhookStripeClient(event)
|
||||
|
||||
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: webhook_client)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_WEBHOOK_SECRET", "whsec_test")
|
||||
|
||||
first_response = await client.post(
|
||||
"/api/v1/stripe/webhook",
|
||||
headers={"Stripe-Signature": "sig_test"},
|
||||
content=b"{}",
|
||||
)
|
||||
assert first_response.status_code == 200, first_response.text
|
||||
|
||||
updated_limit = await _get_pages_limit(TEST_EMAIL)
|
||||
assert updated_limit == 3100
|
||||
|
||||
purchase = await _fetchrow(
|
||||
"""
|
||||
SELECT status, amount_total, currency, stripe_payment_intent_id
|
||||
FROM page_purchases
|
||||
WHERE stripe_checkout_session_id = $1
|
||||
""",
|
||||
checkout_session.id,
|
||||
)
|
||||
assert purchase is not None
|
||||
assert purchase["status"] == "COMPLETED"
|
||||
assert purchase["amount_total"] == 300
|
||||
assert purchase["currency"] == "usd"
|
||||
assert purchase["stripe_payment_intent_id"] == "pi_test_123"
|
||||
|
||||
second_response = await client.post(
|
||||
"/api/v1/stripe/webhook",
|
||||
headers={"Stripe-Signature": "sig_test"},
|
||||
content=b"{}",
|
||||
)
|
||||
assert second_response.status_code == 200, second_response.text
|
||||
|
||||
assert await _get_pages_limit(TEST_EMAIL) == 3100
|
||||
|
||||
|
||||
class TestStripeReconciliation:
|
||||
async def test_reconciliation_fulfills_paid_pending_purchase(
|
||||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
page_limits,
|
||||
monkeypatch,
|
||||
):
|
||||
await page_limits.set(pages_used=220, pages_limit=150)
|
||||
|
||||
checkout_session = SimpleNamespace(
|
||||
id="cs_test_reconcile_paid_123",
|
||||
url="https://checkout.stripe.test/cs_test_reconcile_paid_123",
|
||||
payment_intent=None,
|
||||
amount_total=None,
|
||||
currency=None,
|
||||
)
|
||||
create_client = _FakeCreateStripeClient(checkout_session)
|
||||
|
||||
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000")
|
||||
monkeypatch.setattr(
|
||||
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
|
||||
)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
|
||||
|
||||
create_response = await client.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 3, "search_space_id": search_space_id},
|
||||
)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
assert await _get_pages_limit(TEST_EMAIL) == 150
|
||||
|
||||
reconciled_session = SimpleNamespace(
|
||||
id=checkout_session.id,
|
||||
status="complete",
|
||||
payment_status="paid",
|
||||
payment_intent="pi_test_reconcile_123",
|
||||
amount_total=300,
|
||||
currency="usd",
|
||||
metadata={},
|
||||
)
|
||||
reconcile_client = _FakeReconciliationStripeClient(reconciled_session)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stripe_reconciliation_task, "get_stripe_client", lambda: reconcile_client
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stripe_reconciliation_task.config,
|
||||
"STRIPE_RECONCILIATION_LOOKBACK_MINUTES",
|
||||
0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stripe_reconciliation_task.config,
|
||||
"STRIPE_RECONCILIATION_BATCH_SIZE",
|
||||
20,
|
||||
)
|
||||
|
||||
await stripe_reconciliation_task._reconcile_pending_page_purchases()
|
||||
|
||||
assert reconcile_client.requested_ids == [checkout_session.id]
|
||||
assert await _get_pages_limit(TEST_EMAIL) == 3220
|
||||
|
||||
purchase = await _fetchrow(
|
||||
"""
|
||||
SELECT status, amount_total, currency, stripe_payment_intent_id
|
||||
FROM page_purchases
|
||||
WHERE stripe_checkout_session_id = $1
|
||||
""",
|
||||
checkout_session.id,
|
||||
)
|
||||
assert purchase is not None
|
||||
assert purchase["status"] == "COMPLETED"
|
||||
assert purchase["amount_total"] == 300
|
||||
assert purchase["currency"] == "usd"
|
||||
assert purchase["stripe_payment_intent_id"] == "pi_test_reconcile_123"
|
||||
|
||||
async def test_reconciliation_marks_expired_pending_purchase_failed(
|
||||
self,
|
||||
client,
|
||||
headers,
|
||||
search_space_id: int,
|
||||
page_limits,
|
||||
monkeypatch,
|
||||
):
|
||||
await page_limits.set(pages_used=0, pages_limit=500)
|
||||
|
||||
checkout_session = SimpleNamespace(
|
||||
id="cs_test_reconcile_expired_123",
|
||||
url="https://checkout.stripe.test/cs_test_reconcile_expired_123",
|
||||
payment_intent=None,
|
||||
amount_total=None,
|
||||
currency=None,
|
||||
)
|
||||
create_client = _FakeCreateStripeClient(checkout_session)
|
||||
|
||||
monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000")
|
||||
monkeypatch.setattr(
|
||||
stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000"
|
||||
)
|
||||
monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000)
|
||||
|
||||
create_response = await client.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
headers=headers,
|
||||
json={"quantity": 1, "search_space_id": search_space_id},
|
||||
)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
|
||||
expired_session = SimpleNamespace(
|
||||
id=checkout_session.id,
|
||||
status="expired",
|
||||
payment_status="unpaid",
|
||||
payment_intent=None,
|
||||
amount_total=100,
|
||||
currency="usd",
|
||||
metadata={},
|
||||
)
|
||||
reconcile_client = _FakeReconciliationStripeClient(expired_session)
|
||||
|
||||
monkeypatch.setattr(
|
||||
stripe_reconciliation_task, "get_stripe_client", lambda: reconcile_client
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stripe_reconciliation_task.config,
|
||||
"STRIPE_RECONCILIATION_LOOKBACK_MINUTES",
|
||||
0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stripe_reconciliation_task.config,
|
||||
"STRIPE_RECONCILIATION_BATCH_SIZE",
|
||||
20,
|
||||
)
|
||||
|
||||
await stripe_reconciliation_task._reconcile_pending_page_purchases()
|
||||
|
||||
assert await _get_pages_limit(TEST_EMAIL) == 500
|
||||
|
||||
purchase = await _fetchrow(
|
||||
"""
|
||||
SELECT status
|
||||
FROM page_purchases
|
||||
WHERE stripe_checkout_session_id = $1
|
||||
""",
|
||||
checkout_session.id,
|
||||
)
|
||||
assert purchase is not None
|
||||
assert purchase["status"] == "FAILED"
|
||||
15
surfsense_backend/uv.lock
generated
15
surfsense_backend/uv.lock
generated
|
|
@ -7918,6 +7918,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a9/d3/c238124fbf2dbe5eda203f0a1c4cd6c210e27993ed9780c4c1bf2ab0efbe/static_ffmpeg-3.0-py3-none-any.whl", hash = "sha256:79d9067264cefbb05e6b847be7d6cb7410b7b25adce40178a787f0137567c89f", size = 7927 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/5a/0cdea4b7911b8012936c765544109da27c0728f6911ec7aefe9d59e7a4f9/stripe-15.0.0.tar.gz", hash = "sha256:0717cd9ba8e8193cef8b1c488ce27836754df496ab6fb75864096e0cdf15e52d", size = 1486873 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/4a/4431c998c451cf07f8b4fed98f425b4aaf3d59cc4fb1e6f54d7713606688/stripe-15.0.0-py3-none-any.whl", hash = "sha256:434ec5267a7402a30b76786d159c18d0e138f89195969d6c56bea2e08d353be0", size = 2125454 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "surf-new-backend"
|
||||
version = "0.0.13"
|
||||
|
|
@ -7984,6 +7997,7 @@ dependencies = [
|
|||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "static-ffmpeg" },
|
||||
{ name = "stripe" },
|
||||
{ name = "tavily-python" },
|
||||
{ name = "tornado" },
|
||||
{ name = "trafilatura" },
|
||||
|
|
@ -8067,6 +8081,7 @@ requires-dist = [
|
|||
{ name = "sse-starlette", specifier = ">=3.1.1,<3.1.2" },
|
||||
{ name = "starlette", specifier = ">=0.40.0,<0.51.0" },
|
||||
{ name = "static-ffmpeg", specifier = ">=2.13" },
|
||||
{ name = "stripe", specifier = ">=15.0.0" },
|
||||
{ name = "tavily-python", specifier = ">=0.3.2" },
|
||||
{ name = "tornado", specifier = ">=6.5.5" },
|
||||
{ name = "trafilatura", specifier = ">=2.0.0" },
|
||||
|
|
|
|||
|
|
@ -150,10 +150,7 @@ export function LocalLoginForm() {
|
|||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -163,20 +160,17 @@ export function LocalLoginForm() {
|
|||
placeholder="you@example.com"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive"
|
||||
: "border-border focus:border-primary focus:ring-primary"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-background text-foreground transition-all ${
|
||||
error.title
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive"
|
||||
: "border-border focus:border-primary focus:ring-primary"
|
||||
}`}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
@ -223,10 +217,7 @@ export function LocalLoginForm() {
|
|||
<div className="mt-4 text-center text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("dont_have_account")}{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-primary hover:text-primary/90"
|
||||
>
|
||||
<Link href="/register" className="font-medium text-primary hover:text-primary/90">
|
||||
{t("sign_up")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { HeroSection } from "@/components/homepage/hero-section";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
const FeaturesCards = dynamic(
|
||||
() => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
|
||||
|
||||
export default function BuyPagesPage() {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-md space-y-6"
|
||||
>
|
||||
<BuyPagesContent />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { CircleSlash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function PurchaseCancelPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CircleSlash2 className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<CardTitle className="text-2xl">Checkout canceled</CardTitle>
|
||||
<CardDescription>No charge was made and your current pages are unchanged.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-muted-foreground">
|
||||
You can return to the pricing options and try again whenever you're ready.
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/more-pages`}>Back to Buy Pages</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function PurchaseSuccessPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
||||
<CardTitle className="text-2xl">Purchase complete</CardTitle>
|
||||
<CardDescription>
|
||||
Your additional pages are being applied to your account now.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your sidebar usage meter should refresh automatically in a moment.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/more-pages`}>Buy More Pages</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -105,20 +105,20 @@ export function CommunityPromptsContent() {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={copyingIds.has(prompt.id)}
|
||||
onClick={() => handleCopy(prompt.id)}
|
||||
>
|
||||
{copyingIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
Add to mine
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={copyingIds.has(prompt.id)}
|
||||
onClick={() => handleCopy(prompt.id)}
|
||||
>
|
||||
{copyingIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
Add to mine
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -230,7 +230,9 @@ export function PromptsContent() {
|
|||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving} className="relative">
|
||||
<span className={isSaving ? "opacity-0" : ""}>{editingId !== null ? "Update" : "Create"}</span>
|
||||
<span className={isSaving ? "opacity-0" : ""}>
|
||||
{editingId !== null ? "Update" : "Create"}
|
||||
</span>
|
||||
{isSaving && <Spinner className="size-3.5 absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -286,21 +288,21 @@ export function PromptsContent() {
|
|||
)}
|
||||
</div>
|
||||
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title={prompt.is_public ? "Make private" : "Share with community"}
|
||||
onClick={() => handleTogglePublic(prompt)}
|
||||
disabled={togglingPublicIds.has(prompt.id)}
|
||||
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{togglingPublicIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : prompt.is_public ? (
|
||||
<Lock className="size-3.5" />
|
||||
) : (
|
||||
<Globe className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={prompt.is_public ? "Make private" : "Share with community"}
|
||||
onClick={() => handleTogglePublic(prompt)}
|
||||
disabled={togglingPublicIds.has(prompt.id)}
|
||||
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{togglingPublicIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : prompt.is_public ? (
|
||||
<Lock className="size-3.5" />
|
||||
) : (
|
||||
<Globe className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -323,7 +325,10 @@ export function PromptsContent() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete prompt</AlertDialogTitle>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Receipt } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: string }> = {
|
||||
completed: { label: "Completed", className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600" },
|
||||
pending: { label: "Pending", className: "bg-yellow-600 text-white border-transparent hover:bg-yellow-600" },
|
||||
failed: { label: "Failed", className: "bg-destructive text-white border-transparent hover:bg-destructive" },
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(purchase: PagePurchase): string {
|
||||
if (purchase.amount_total == null) return "—";
|
||||
const dollars = purchase.amount_total / 100;
|
||||
const currency = (purchase.currency ?? "usd").toUpperCase();
|
||||
return `$${dollars.toFixed(2)} ${currency}`;
|
||||
}
|
||||
|
||||
export function PurchaseHistoryContent() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stripe-purchases"],
|
||||
queryFn: () => stripeApiService.getPurchases(),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const purchases = data?.purchases ?? [];
|
||||
|
||||
if (purchases.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<Receipt className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No purchases yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your page-pack purchases will appear here after checkout.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Pages</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchases.map((p) => {
|
||||
const style = STATUS_STYLES[p.status];
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="text-sm">
|
||||
{formatDate(p.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{p.pages_granted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[10px]", style.className)}>
|
||||
{style.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -22,6 +24,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
|
||||
setIsCheckingAuth(false);
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { userApiService } from "@/lib/apis/user-api.service";
|
||||
import { getBearerToken, isPublicRoute } from "@/lib/auth-utils";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
export const USER_QUERY_KEY = ["user", "me"] as const;
|
||||
const userQueryFn = () => userApiService.getMe();
|
||||
|
||||
export const currentUserAtom = atomWithQuery(() => {
|
||||
const pathname = typeof window !== "undefined" ? window.location.pathname : null;
|
||||
return {
|
||||
queryKey: USER_QUERY_KEY,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!getBearerToken() && pathname !== null && !isPublicRoute(pathname),
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: userQueryFn,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
useAui,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
CheckIcon,
|
||||
|
|
@ -18,6 +17,7 @@ import {
|
|||
MessageSquare,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
DeleteConfluencePageToolUI,
|
||||
UpdateConfluencePageToolUI,
|
||||
} from "@/components/tool-ui/confluence";
|
||||
import { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "@/components/tool-ui/dropbox";
|
||||
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
||||
|
|
@ -70,7 +71,6 @@ import {
|
|||
DeleteNotionPageToolUI,
|
||||
UpdateNotionPageToolUI,
|
||||
} from "@/components/tool-ui/notion";
|
||||
import { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "@/components/tool-ui/dropbox";
|
||||
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
|
||||
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
|
||||
import {
|
||||
|
|
@ -78,7 +78,13 @@ import {
|
|||
resolveSafeNavigationHref,
|
||||
} from "@/components/tool-ui/shared/media";
|
||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -298,11 +298,11 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
|
||||
const hasDriveItems = isDriveOrOneDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(prev => !prev);
|
||||
setShowDetails((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
|
|||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFolderTreeOpen(prev => !prev)}
|
||||
onClick={() => setIsFolderTreeOpen((prev) => !prev)}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
Change Selection
|
||||
|
|
|
|||
|
|
@ -287,47 +287,57 @@ export const DropboxConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCha
|
|||
<SelectValue placeholder="Select limit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="50" className="text-xs sm:text-sm">50 files</SelectItem>
|
||||
<SelectItem value="100" className="text-xs sm:text-sm">100 files</SelectItem>
|
||||
<SelectItem value="250" className="text-xs sm:text-sm">250 files</SelectItem>
|
||||
<SelectItem value="500" className="text-xs sm:text-sm">500 files</SelectItem>
|
||||
<SelectItem value="1000" className="text-xs sm:text-sm">1000 files</SelectItem>
|
||||
<SelectItem value="50" className="text-xs sm:text-sm">
|
||||
50 files
|
||||
</SelectItem>
|
||||
<SelectItem value="100" className="text-xs sm:text-sm">
|
||||
100 files
|
||||
</SelectItem>
|
||||
<SelectItem value="250" className="text-xs sm:text-sm">
|
||||
250 files
|
||||
</SelectItem>
|
||||
<SelectItem value="500" className="text-xs sm:text-sm">
|
||||
500 files
|
||||
</SelectItem>
|
||||
<SelectItem value="1000" className="text-xs sm:text-sm">
|
||||
1000 files
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="db-incremental-sync" className="text-sm font-medium">
|
||||
Incremental sync
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only sync changes since last index (faster). Disable for a full re-index.
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="db-incremental-sync" className="text-sm font-medium">
|
||||
Incremental sync
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only sync changes since last index (faster). Disable for a full re-index.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="db-incremental-sync"
|
||||
checked={indexingOptions.incremental_sync}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("incremental_sync", checked)}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="db-incremental-sync"
|
||||
checked={indexingOptions.incremental_sync}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("incremental_sync", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="db-include-subfolders" className="text-sm font-medium">
|
||||
Include subfolders
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recursively index files in subfolders of selected folders
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="db-include-subfolders" className="text-sm font-medium">
|
||||
Include subfolders
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recursively index files in subfolders of selected folders
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="db-include-subfolders"
|
||||
checked={indexingOptions.include_subfolders}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="db-include-subfolders"
|
||||
checked={indexingOptions.include_subfolders}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDetails(prev => !prev);
|
||||
setShowDetails((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{showDetails ? (
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
|
|||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFolderTreeOpen(prev => !prev)}
|
||||
onClick={() => setIsFolderTreeOpen((prev) => !prev)}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
Change Selection
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowApiKey(prev => !prev)}
|
||||
onClick={() => setShowApiKey((prev) => !prev)}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? "Hide" : "Show"}
|
||||
|
|
|
|||
|
|
@ -158,13 +158,13 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
|
|
@ -221,14 +221,14 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-muted">
|
||||
<Button
|
||||
onClick={onStartIndexing}
|
||||
disabled={isStartingIndexing}
|
||||
className="text-xs sm:text-sm relative"
|
||||
>
|
||||
<span className={isStartingIndexing ? "opacity-0" : ""}>Start Indexing</span>
|
||||
{isStartingIndexing && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onStartIndexing}
|
||||
disabled={isStartingIndexing}
|
||||
className="text-xs sm:text-sm relative"
|
||||
>
|
||||
<span className={isStartingIndexing ? "opacity-0" : ""}>Start Indexing</span>
|
||||
{isStartingIndexing && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -780,12 +780,12 @@ export const useConnectorDialog = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Handle Google Drive / OneDrive / Dropbox folder selection (regular and Composio)
|
||||
if (
|
||||
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "ONEDRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "DROPBOX_CONNECTOR") &&
|
||||
// Handle Google Drive / OneDrive / Dropbox folder selection (regular and Composio)
|
||||
if (
|
||||
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "ONEDRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "DROPBOX_CONNECTOR") &&
|
||||
indexingConnectorConfig
|
||||
) {
|
||||
const selectedFolders = indexingConnectorConfig.selected_folders as
|
||||
|
|
@ -1049,13 +1049,13 @@ export const useConnectorDialog = () => {
|
|||
if (!editingConnector.is_indexable) {
|
||||
// Non-indexable connectors (like Tavily API) don't need re-indexing
|
||||
indexingDescription = "Settings saved.";
|
||||
} else if (
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR"
|
||||
) {
|
||||
// Google Drive (both regular and Composio) / OneDrive / Dropbox uses folder selection from config, not date ranges
|
||||
} else if (
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR"
|
||||
) {
|
||||
// Google Drive (both regular and Composio) / OneDrive / Dropbox uses folder selection from config, not date ranges
|
||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
<div className="rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(prev => !prev)}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function CommentThread({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsRepliesExpanded(prev => !prev)}
|
||||
onClick={() => setIsRepliesExpanded((prev) => !prev)}
|
||||
>
|
||||
{isRepliesExpanded ? (
|
||||
<ChevronDown className="mr-1 size-3" />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertCircle, XIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { format } from "date-fns";
|
||||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { CalendarIcon, XIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
|
|||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="relative z-50 flex items-center justify-center p-2 -mr-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn
|
|||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setIsCollapsedState(prev => {
|
||||
setIsCollapsedState((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${next}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
|||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import {
|
||||
morePagesDialogAtom,
|
||||
searchSpaceSettingsDialogAtom,
|
||||
teamDialogAtom,
|
||||
userSettingsDialogAtom,
|
||||
|
|
@ -27,7 +26,6 @@ import {
|
|||
type Tab,
|
||||
} from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
|
||||
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||
import { TeamDialog } from "@/components/settings/team-dialog";
|
||||
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
||||
|
|
@ -203,8 +201,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||
const isInitialLoad = useRef(true);
|
||||
|
||||
const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
|
||||
|
||||
// Effect to show toast for new page_limit_exceeded notifications
|
||||
useEffect(() => {
|
||||
if (statusInbox.loading) return;
|
||||
|
|
@ -233,12 +229,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
duration: 8000,
|
||||
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
||||
action: {
|
||||
label: "View Plans",
|
||||
onClick: () => setMorePagesOpen(true),
|
||||
label: "Get More Pages",
|
||||
onClick: () => router.push(`/dashboard/${searchSpaceId}/more-pages`),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, setMorePagesOpen]);
|
||||
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
|
|
@ -837,7 +833,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
</Button>
|
||||
<Button
|
||||
onClick={confirmRenameChat}
|
||||
disabled={isRenamingChat || !newChatTitle.trim() || newChatTitle.trim() === chatToRename?.name}
|
||||
disabled={
|
||||
isRenamingChat || !newChatTitle.trim() || newChatTitle.trim() === chatToRename?.name
|
||||
}
|
||||
className="relative"
|
||||
>
|
||||
<span className={isRenamingChat ? "opacity-0" : ""}>
|
||||
|
|
@ -921,7 +919,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<UserSettingsDialog />
|
||||
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<MorePagesDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { PanelRight, PanelRightClose } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { startTransition, useEffect } from "react";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
|
|
@ -15,17 +15,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import { DocumentsSidebar } from "../sidebar";
|
||||
|
||||
const EditorPanelContent = dynamic(
|
||||
() => import("@/components/editor-panel/editor-panel").then((m) => ({ default: m.EditorPanelContent })),
|
||||
() =>
|
||||
import("@/components/editor-panel/editor-panel").then((m) => ({
|
||||
default: m.EditorPanelContent,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-96 w-full" /> }
|
||||
);
|
||||
|
||||
const HitlEditPanelContent = dynamic(
|
||||
() => import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({ default: m.HitlEditPanelContent })),
|
||||
() =>
|
||||
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
|
||||
default: m.HitlEditPanelContent,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-96 w-full" /> }
|
||||
);
|
||||
|
||||
const ReportPanelContent = dynamic(
|
||||
() => import("@/components/report-panel/report-panel").then((m) => ({ default: m.ReportPanelContent })),
|
||||
() =>
|
||||
import("@/components/report-panel/report-panel").then((m) => ({
|
||||
default: m.ReportPanelContent,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-96 w-full" /> }
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { Zap } from "lucide-react";
|
||||
import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
|
||||
interface PageUsageDisplayProps {
|
||||
pagesUsed: number;
|
||||
|
|
@ -12,12 +14,18 @@ interface PageUsageDisplayProps {
|
|||
}
|
||||
|
||||
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
||||
const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
|
||||
const params = useParams();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
||||
const { data: stripeStatus } = useQuery({
|
||||
queryKey: ["stripe-status"],
|
||||
queryFn: () => stripeApiService.getStatus(),
|
||||
});
|
||||
const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true;
|
||||
|
||||
return (
|
||||
<div className="px-3 py-3 border-t">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
|
||||
|
|
@ -25,19 +33,32 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
|
|||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMorePagesOpen(true)}
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/more-pages`}
|
||||
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
||||
<Zap className="h-3 w-3 shrink-0" />
|
||||
Upgrade to PRO
|
||||
Get Free Pages
|
||||
</span>
|
||||
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
||||
FREE
|
||||
</Badge>
|
||||
</button>
|
||||
</Link>
|
||||
{pageBuyingEnabled && (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/buy-pages`}
|
||||
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
||||
<CreditCard className="h-3 w-3 shrink-0" />
|
||||
Buy Pages
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
$1/1k
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,12 +72,7 @@ export function TabBar({ onTabSwitch, onNewChat, rightActions, className }: TabB
|
|||
if (tabs.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 flex h-9 items-center shrink-0 px-1 gap-0.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-2 flex h-9 items-center shrink-0 px-1 gap-0.5", className)}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex h-full items-center flex-1 gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-1"
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ interface ChunkCardProps {
|
|||
disableLayoutAnimation?: boolean;
|
||||
}
|
||||
|
||||
const ChunkCard = memo(forwardRef<HTMLDivElement, ChunkCardProps>(
|
||||
({ chunk, index, totalChunks, isCited }, ref) => {
|
||||
const ChunkCard = memo(
|
||||
forwardRef<HTMLDivElement, ChunkCardProps>(({ chunk, index, totalChunks, isCited }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -100,8 +100,8 @@ const ChunkCard = memo(forwardRef<HTMLDivElement, ChunkCardProps>(
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
));
|
||||
})
|
||||
);
|
||||
ChunkCard.displayName = "ChunkCard";
|
||||
|
||||
export function SourceDetailPanel({
|
||||
|
|
|
|||
|
|
@ -668,7 +668,7 @@ export function OnboardingTour() {
|
|||
const handleNext = useCallback(() => {
|
||||
retryCountRef.current = 0;
|
||||
setShouldAnimate(true);
|
||||
setStepIndex(prev => {
|
||||
setStepIndex((prev) => {
|
||||
if (prev < TOUR_STEPS.length - 1) {
|
||||
return prev + 1;
|
||||
} else {
|
||||
|
|
@ -686,7 +686,7 @@ export function OnboardingTour() {
|
|||
const handlePrev = useCallback(() => {
|
||||
retryCountRef.current = 0;
|
||||
setShouldAnimate(true);
|
||||
setStepIndex(prev => {
|
||||
setStepIndex((prev) => {
|
||||
if (prev > 0) {
|
||||
return prev - 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ const demoPlans = [
|
|||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "",
|
||||
billingText: "",
|
||||
billingText: "1,000 pages included",
|
||||
features: [
|
||||
"Self Hostable",
|
||||
"Upload and chat with 300+ pages of content",
|
||||
"Includes access to ChatGPT text and audio models",
|
||||
"1,000 pages included to start",
|
||||
"Earn up to 6,000+ bonus pages for free",
|
||||
"Includes access to OpenAI text, audio and image models",
|
||||
"Realtime Collaborative Group Chats with teammates",
|
||||
"Community support on Discord",
|
||||
],
|
||||
|
|
@ -22,21 +23,20 @@ const demoPlans = [
|
|||
isPopular: false,
|
||||
},
|
||||
{
|
||||
name: "PRO",
|
||||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "",
|
||||
billingText: "Free during beta",
|
||||
name: "PAY AS YOU GO",
|
||||
price: "1",
|
||||
yearlyPrice: "1",
|
||||
period: "1,000 pages",
|
||||
billingText: "No subscription, buy only when you need more",
|
||||
features: [
|
||||
"Everything in Free",
|
||||
"Includes 6000+ pages of content",
|
||||
"Access to more models and providers",
|
||||
"Buy 1,000-page packs at $1 each",
|
||||
"Priority support on Discord",
|
||||
],
|
||||
description: "",
|
||||
buttonText: "Get Started",
|
||||
href: "/login",
|
||||
isPopular: true,
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
name: "ENTERPRISE",
|
||||
|
|
@ -45,7 +45,7 @@ const demoPlans = [
|
|||
period: "",
|
||||
billingText: "",
|
||||
features: [
|
||||
"Everything in Pro",
|
||||
"Everything in Pay As You Go",
|
||||
"On-prem or VPC deployment",
|
||||
"Audit logs and compliance",
|
||||
"SSO, OIDC & SAML",
|
||||
|
|
@ -63,7 +63,11 @@ const demoPlans = [
|
|||
|
||||
function PricingBasic() {
|
||||
return (
|
||||
<Pricing plans={demoPlans} title="SurfSense Pricing" description="Choose what works for you" />
|
||||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="SurfSense Pricing"
|
||||
description="Start free with 1,000 pages. Earn up to 6,000+ more or buy as you go."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { schema } from "@/zero/schema";
|
|||
|
||||
const cacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848";
|
||||
|
||||
function ZeroAuthGuard({ children }: { children: React.ReactNode }) {
|
||||
function ZeroAuthSync() {
|
||||
const zero = useZero();
|
||||
const connectionState = useConnectionState();
|
||||
const isRefreshingRef = useRef(false);
|
||||
|
|
@ -37,7 +37,7 @@ function ZeroAuthGuard({ children }: { children: React.ReactNode }) {
|
|||
});
|
||||
}, [connectionState, zero]);
|
||||
|
||||
return <>{children}</>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ZeroProvider({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -59,7 +59,8 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) {
|
|||
|
||||
return (
|
||||
<ZeroReactProvider {...opts}>
|
||||
{hasUser ? <ZeroAuthGuard>{children}</ZeroAuthGuard> : children}
|
||||
{hasUser && <ZeroAuthSync />}
|
||||
{children}
|
||||
</ZeroReactProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
|
@ -12,13 +12,13 @@ import { MarkdownViewer } from "@/components/markdown-viewer";
|
|||
import { EXPORT_FILE_EXTENSIONS, ExportDropdownItems } from "@/components/shared/ExportMenuItems";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
|
|
|||
145
surfsense_web/components/settings/buy-pages-content.tsx
Normal file
145
surfsense_web/components/settings/buy-pages-content.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_PACK_SIZE = 1000;
|
||||
const PRICE_PER_PACK_USD = 1;
|
||||
const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const;
|
||||
|
||||
export function BuyPagesContent() {
|
||||
const params = useParams();
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const { data: stripeStatus } = useQuery({
|
||||
queryKey: ["stripe-status"],
|
||||
queryFn: () => stripeApiService.getStatus(),
|
||||
});
|
||||
|
||||
const purchaseMutation = useMutation({
|
||||
mutationFn: stripeApiService.createCheckoutSession,
|
||||
onSuccess: (response) => {
|
||||
window.location.assign(response.checkout_url);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof AppError && error.message) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to start checkout. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const hasValidSearchSpace = Number.isFinite(searchSpaceId) && searchSpaceId > 0;
|
||||
const totalPages = quantity * PAGE_PACK_SIZE;
|
||||
const totalPrice = quantity * PRICE_PER_PACK_USD;
|
||||
|
||||
if (stripeStatus && !stripeStatus.page_buying_enabled) {
|
||||
return (
|
||||
<div className="w-full space-y-3 text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Pages</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page purchases are temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleBuyNow = () => {
|
||||
if (!hasValidSearchSpace) {
|
||||
toast.error("Unable to determine the current workspace for checkout.");
|
||||
return;
|
||||
}
|
||||
purchaseMutation.mutate({
|
||||
quantity,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Pages</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
$1 per 1,000 pages, pay as you go
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
disabled={quantity <= 1 || purchaseMutation.isPending}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="min-w-28 text-center text-lg font-semibold tabular-nums">
|
||||
{totalPages.toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.min(100, q + 1))}
|
||||
disabled={quantity >= 100 || purchaseMutation.isPending}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick-pick presets */}
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{PRESET_MULTIPLIERS.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setQuantity(m)}
|
||||
disabled={purchaseMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium tabular-nums transition-colors disabled:opacity-60",
|
||||
quantity === m
|
||||
? "border-emerald-500 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-border hover:border-emerald-500/40 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
{(m * PAGE_PACK_SIZE).toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<span className="text-sm font-medium tabular-nums">{totalPages.toLocaleString()} pages</span>
|
||||
<span className="text-sm font-semibold tabular-nums">${totalPrice}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={purchaseMutation.isPending || !hasValidSearchSpace}
|
||||
onClick={handleBuyNow}
|
||||
>
|
||||
{purchaseMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
<>Buy {totalPages.toLocaleString()} Pages for ${totalPrice}</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Secure checkout via Stripe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,7 +50,9 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
}, [searchSpace?.name, searchSpace?.description]);
|
||||
|
||||
// Derive hasChanges during render
|
||||
const hasChanges = !!searchSpace && ((searchSpace.name || "") !== name || (searchSpace.description || "") !== description);
|
||||
const hasChanges =
|
||||
!!searchSpace &&
|
||||
((searchSpace.name || "") !== name || (searchSpace.description || "") !== description);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,11 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
};
|
||||
setAssignments(newAssignments);
|
||||
setHasChanges(false);
|
||||
}, [preferences?.agent_llm_id, preferences?.document_summary_llm_id, preferences?.image_generation_config_id]);
|
||||
}, [
|
||||
preferences?.agent_llm_id,
|
||||
preferences?.document_summary_llm_id,
|
||||
preferences?.image_generation_config_id,
|
||||
]);
|
||||
|
||||
const handleRoleAssignment = (prefKey: string, configId: string) => {
|
||||
const newAssignments = {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react";
|
||||
import { Check, ExternalLink, Mail } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -22,15 +18,14 @@ import {
|
|||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import {
|
||||
trackIncentiveContactOpened,
|
||||
trackIncentivePageViewed,
|
||||
trackIncentiveTaskClicked,
|
||||
trackIncentiveTaskCompleted,
|
||||
|
|
@ -38,7 +33,10 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function MorePagesContent() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
const [claimOpen, setClaimOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
|
|
@ -48,6 +46,11 @@ export function MorePagesContent() {
|
|||
queryKey: ["incentive-tasks"],
|
||||
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||
});
|
||||
const { data: stripeStatus } = useQuery({
|
||||
queryKey: ["stripe-status"],
|
||||
queryFn: () => stripeApiService.getStatus(),
|
||||
});
|
||||
const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true;
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
|
|
@ -59,7 +62,7 @@ export function MorePagesContent() {
|
|||
trackIncentiveTaskCompleted(taskType, task.pages_reward);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
|
|
@ -75,132 +78,138 @@ export function MorePagesContent() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
||||
<h2 className="text-xl font-bold tracking-tight">Get Free Pages</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Complete tasks to earn additional pages
|
||||
Claim your free page offer and earn bonus pages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Skeleton className="h-9 w-9 rounded-full bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted" />
|
||||
<Skeleton className="h-3 w-1/4 bg-muted" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data?.tasks.map((task) => (
|
||||
<Card
|
||||
key={task.task_type}
|
||||
className={cn("transition-colors bg-transparent", task.completed && "bg-muted/50")}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* 6k free offer */}
|
||||
<Card className="border-emerald-500/30 bg-emerald-500/5">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white text-xs font-bold">
|
||||
6k
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">Claim 6,000 Free Pages</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Limited offer. Schedule a meeting or email us to claim.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => setClaimOpen(true)}>
|
||||
Claim
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Free tasks */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Earn Bonus Pages</h3>
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{data?.tasks.map((task) => (
|
||||
<Card
|
||||
key={task.task_type}
|
||||
className={cn("transition-colors bg-transparent", task.completed && "bg-muted/50")}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{task.completed ? <Check className="h-3.5 w-3.5" /> : <span className="text-xs font-semibold">+{task.pages_reward}</span>}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
"min-w-0 flex-1 text-sm font-medium",
|
||||
task.completed && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={task.completed || completeMutation.isPending}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
asChild={!task.completed}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{task.completed ? (
|
||||
<span>Done</span>
|
||||
) : (
|
||||
<a
|
||||
href={task.action_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={task.completed || completeMutation.isPending}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
asChild={!task.completed}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{task.completed ? (
|
||||
<span>Done</span>
|
||||
) : (
|
||||
<a
|
||||
href={task.action_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card className="overflow-hidden border-emerald-500/20 bg-transparent">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-emerald-500" />
|
||||
<CardTitle className="text-base">Upgrade to PRO</CardTitle>
|
||||
<Badge className="bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
||||
FREE
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
For a limited time, get{" "}
|
||||
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
|
||||
cost. Contact us and we'll upgrade your account instantly.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-2">
|
||||
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700">
|
||||
<Mail className="h-4 w-4" />
|
||||
Contact us to Upgrade
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="select-none sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Get in Touch</DialogTitle>
|
||||
<DialogDescription>Pick the option that works best for you.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button asChild>
|
||||
<Link href="https://cal.com/mod-rohan" target="_blank" rel="noopener noreferrer">
|
||||
<IconCalendar className="h-4 w-4" />
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="mailto:rohan@surfsense.com">
|
||||
<IconMailFilled className="h-4 w-4" />
|
||||
rohan@surfsense.com
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/* Link to buy pages */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">Need more?</p>
|
||||
{pageBuyingEnabled ? (
|
||||
<Button asChild variant="link" className="text-emerald-600 dark:text-emerald-400">
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-pages`}>
|
||||
Buy page packs at $1 per 1,000
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page purchases are temporarily unavailable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim 6k dialog */}
|
||||
<Dialog open={claimOpen} onOpenChange={setClaimOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Claim 6,000 Free Pages</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send us an email to claim your free 6,000 pages. Include your account email and primary usecase for free pages.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button asChild className="w-full gap-2">
|
||||
<a href="mailto:rohan@surfsense.com?subject=Claim%206%2C000%20Free%20Pages&body=Hi%2C%20I'd%20like%20to%20claim%20the%206%2C000%20free%20pages%20offer.%0A%0AMy%20account%20email%3A%20">
|
||||
<Mail className="h-4 w-4" />
|
||||
rohan@surfsense.com
|
||||
</a>
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
}, [searchSpace?.qna_custom_instructions]);
|
||||
|
||||
// Derive hasChanges during render
|
||||
const hasChanges = !!searchSpace && (searchSpace.qna_custom_instructions || "") !== customInstructions;
|
||||
const hasChanges =
|
||||
!!searchSpace && (searchSpace.qna_custom_instructions || "") !== customInstructions;
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { Globe, KeyRound, Sparkles, User } from "lucide-react";
|
||||
import { Globe, KeyRound, Receipt, Sparkles, User } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
|
||||
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
|
||||
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
|
||||
import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent";
|
||||
import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent";
|
||||
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||
|
||||
|
|
@ -31,6 +32,11 @@ export function UserSettingsDialog() {
|
|||
label: "Community Prompts",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "purchases",
|
||||
label: "Purchase History",
|
||||
icon: <Receipt className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -47,6 +53,7 @@ export function UserSettingsDialog() {
|
|||
{state.initialTab === "api-key" && <ApiKeyContent />}
|
||||
{state.initialTab === "prompts" && <PromptsContent />}
|
||||
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
|
||||
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
|
||||
</div>
|
||||
</SettingsDialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -235,7 +235,13 @@ export function Audio({ id, src, title, durationMs, className }: AudioProps) {
|
|||
</Button>
|
||||
|
||||
<div className="group/volume flex items-center gap-1 sm:gap-1.5">
|
||||
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8" aria-label={isMuted ? "Unmute" : "Mute"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMute}
|
||||
className="size-7 sm:size-8"
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeXIcon className="size-3.5 sm:size-4" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -150,7 +150,8 @@ function ApprovalCard({
|
|||
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||
const canEdit = allowedDecisions.includes("edit");
|
||||
|
||||
const fileTypeLabel = supportedTypes.find((t) => t.value === selectedFileType)?.label ?? selectedFileType;
|
||||
const fileTypeLabel =
|
||||
supportedTypes.find((t) => t.value === selectedFileType)?.label ?? selectedFileType;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (phase !== "pending" || isPanelOpen || !canApprove) return;
|
||||
|
|
@ -450,7 +451,10 @@ function SuccessCard({ result }: { result: SuccessResult }) {
|
|||
export const CreateDropboxFileToolUI = ({
|
||||
args,
|
||||
result,
|
||||
}: ToolCallMessagePartProps<{ name: string; file_type?: string; content?: string }, CreateDropboxFileResult>) => {
|
||||
}: ToolCallMessagePartProps<
|
||||
{ name: string; file_type?: string; content?: string },
|
||||
CreateDropboxFileResult
|
||||
>) => {
|
||||
if (!result) return null;
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -116,7 +116,11 @@ function ApprovalCard({
|
|||
type: "approve",
|
||||
edited_action: {
|
||||
name: interruptData.action_requests[0].name,
|
||||
args: { file_path: file?.file_path, connector_id: account?.id, delete_from_kb: deleteFromKb },
|
||||
args: {
|
||||
file_path: file?.file_path,
|
||||
connector_id: account?.id,
|
||||
delete_from_kb: deleteFromKb,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [phase, setProcessing, onDecision, interruptData, file?.file_path, account?.id, deleteFromKb]);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
export { Audio } from "./audio";
|
||||
export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox";
|
||||
export {
|
||||
type GenerateImageArgs,
|
||||
GenerateImageArgsSchema,
|
||||
|
|
@ -32,7 +33,6 @@ export {
|
|||
UpdateLinearIssueToolUI,
|
||||
} from "./linear";
|
||||
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
|
||||
export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox";
|
||||
export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
|
||||
export {
|
||||
Plan,
|
||||
|
|
|
|||
40
surfsense_web/contracts/types/stripe.types.ts
Normal file
40
surfsense_web/contracts/types/stripe.types.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const pagePurchaseStatusEnum = z.enum(["pending", "completed", "failed"]);
|
||||
|
||||
export const createCheckoutSessionRequest = z.object({
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
search_space_id: z.number().int().min(1),
|
||||
});
|
||||
|
||||
export const createCheckoutSessionResponse = z.object({
|
||||
checkout_url: z.string(),
|
||||
});
|
||||
|
||||
export const stripeStatusResponse = z.object({
|
||||
page_buying_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const pagePurchase = z.object({
|
||||
id: z.uuid(),
|
||||
stripe_checkout_session_id: z.string(),
|
||||
stripe_payment_intent_id: z.string().nullable(),
|
||||
quantity: z.number(),
|
||||
pages_granted: z.number(),
|
||||
amount_total: z.number().nullable(),
|
||||
currency: z.string().nullable(),
|
||||
status: pagePurchaseStatusEnum,
|
||||
completed_at: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export const getPagePurchasesResponse = z.object({
|
||||
purchases: z.array(pagePurchase),
|
||||
});
|
||||
|
||||
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
|
||||
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
|
||||
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
|
||||
export type StripeStatusResponse = z.infer<typeof stripeStatusResponse>;
|
||||
export type PagePurchase = z.infer<typeof pagePurchase>;
|
||||
export type GetPagePurchasesResponse = z.infer<typeof getPagePurchasesResponse>;
|
||||
|
|
@ -185,7 +185,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
}
|
||||
|
||||
const newConnector = await response.json();
|
||||
setConnectors(prev => {
|
||||
setConnectors((prev) => {
|
||||
const updated = [...prev, newConnector];
|
||||
updateConnectorSourceItems(updated);
|
||||
return updated;
|
||||
|
|
@ -221,8 +221,8 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
}
|
||||
|
||||
const updatedConnector = await response.json();
|
||||
setConnectors(prev => {
|
||||
const updated = prev.map(c => c.id === connectorId ? updatedConnector : c);
|
||||
setConnectors((prev) => {
|
||||
const updated = prev.map((c) => (c.id === connectorId ? updatedConnector : c));
|
||||
updateConnectorSourceItems(updated);
|
||||
return updated;
|
||||
});
|
||||
|
|
@ -250,8 +250,8 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
throw new Error(`Failed to delete connector: ${response.statusText}`);
|
||||
}
|
||||
|
||||
setConnectors(prev => {
|
||||
const updated = prev.filter(c => c.id !== connectorId);
|
||||
setConnectors((prev) => {
|
||||
const updated = prev.filter((c) => c.id !== connectorId);
|
||||
updateConnectorSourceItems(updated);
|
||||
return updated;
|
||||
});
|
||||
|
|
@ -299,19 +299,18 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
const result = await response.json();
|
||||
|
||||
// Update the connector's last_indexed_at timestamp
|
||||
setConnectors(prev =>
|
||||
prev.map(c => c.id === connectorId
|
||||
? { ...c, last_indexed_at: new Date().toISOString() }
|
||||
: c
|
||||
setConnectors((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === connectorId ? { ...c, last_indexed_at: new Date().toISOString() } : c
|
||||
)
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("Error indexing connector content:", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error indexing connector content:", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get connector source items - memoized to prevent unnecessary re-renders
|
||||
|
|
|
|||
30
surfsense_web/lib/apis/stripe-api.service.ts
Normal file
30
surfsense_web/lib/apis/stripe-api.service.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
type CreateCheckoutSessionRequest,
|
||||
type CreateCheckoutSessionResponse,
|
||||
createCheckoutSessionResponse,
|
||||
type GetPagePurchasesResponse,
|
||||
getPagePurchasesResponse,
|
||||
type StripeStatusResponse,
|
||||
stripeStatusResponse,
|
||||
} from "@/contracts/types/stripe.types";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class StripeApiService {
|
||||
createCheckoutSession = async (
|
||||
request: CreateCheckoutSessionRequest
|
||||
): Promise<CreateCheckoutSessionResponse> => {
|
||||
return baseApiService.post("/api/v1/stripe/create-checkout-session", createCheckoutSessionResponse, {
|
||||
body: request,
|
||||
});
|
||||
};
|
||||
|
||||
getPurchases = async (): Promise<GetPagePurchasesResponse> => {
|
||||
return baseApiService.get("/api/v1/stripe/purchases", getPagePurchasesResponse);
|
||||
};
|
||||
|
||||
getStatus = async (): Promise<StripeStatusResponse> => {
|
||||
return baseApiService.get("/api/v1/stripe/status", stripeStatusResponse);
|
||||
};
|
||||
}
|
||||
|
||||
export const stripeApiService = new StripeApiService();
|
||||
|
|
@ -45,3 +45,4 @@ export const isSelfHosted = () => DEPLOYMENT_MODE === "self-hosted";
|
|||
|
||||
// Helper to check if running in cloud mode
|
||||
export const isCloud = () => DEPLOYMENT_MODE === "cloud";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue