Merge pull request #1065 from MODSetter/dev_mod

feat: integrate Stripe
This commit is contained in:
Rohan Verma 2026-03-31 18:41:51 -07:00 committed by GitHub
commit 006dccbe4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 2172 additions and 362 deletions

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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:

View file

@ -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}")

View file

@ -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",

View file

@ -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,
},
},
}

View file

@ -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 (

View file

@ -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

View file

@ -8,7 +8,6 @@ import contextlib
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
from .client import DropboxClient

View file

@ -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(

View file

@ -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)

View file

@ -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."

View file

@ -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)

View 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)

View file

@ -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",

View 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

View file

@ -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),
)

View file

@ -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}")

View file

@ -74,6 +74,7 @@ dependencies = [
"langgraph>=1.1.3",
"langchain-community>=0.4.1",
"deepagents>=0.4.12",
"stripe>=15.0.0",
]
[dependency-groups]

View file

@ -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"

View file

@ -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" },

View file

@ -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>

View file

@ -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 })),

View file

@ -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>
);
}

View file

@ -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&apos;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>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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);
}, []);

View file

@ -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,
};
});

View file

@ -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";

View file

@ -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

View file

@ -243,7 +243,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDetails(prev => !prev);
setShowDetails((prev) => !prev);
}}
>
{showDetails ? (

View file

@ -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

View file

@ -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>
);

View file

@ -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 ? (

View file

@ -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

View file

@ -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"}

View file

@ -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>
);

View file

@ -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;

View file

@ -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"

View file

@ -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

View file

@ -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" />

View file

@ -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";

View file

@ -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";

View file

@ -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"}
>

View file

@ -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}`;

View file

@ -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 />
</>
);
}

View file

@ -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" /> }
);

View file

@ -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>
);

View file

@ -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"

View file

@ -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({

View file

@ -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;
}

View file

@ -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."
/>
);
}

View file

@ -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>
);
}

View file

@ -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";

View 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>
);
}

View file

@ -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 {

View file

@ -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 = {

View file

@ -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&apos;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>
);
}

View file

@ -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 {

View file

@ -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>
);

View file

@ -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" />
) : (

View file

@ -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 (

View file

@ -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]);

View file

@ -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,

View 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>;

View file

@ -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

View 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();

View file

@ -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";