diff --git a/surfsense_backend/alembic/versions/80_add_user_incentive_tasks_table.py b/surfsense_backend/alembic/versions/80_add_user_incentive_tasks_table.py new file mode 100644 index 000000000..7fcadb763 --- /dev/null +++ b/surfsense_backend/alembic/versions/80_add_user_incentive_tasks_table.py @@ -0,0 +1,97 @@ +"""Add user incentive tasks table for earning free pages + +Revision ID: 80 +Revises: 79 + +Changes: +1. Create incentive_task_type enum with GITHUB_STAR value +2. Create user_incentive_tasks table to track completed tasks +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "80" +down_revision: str | None = "79" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create incentive tasks infrastructure.""" + + # Check if enum already exists (handles partial migration recovery) + conn = op.get_bind() + result = conn.execute( + sa.text("SELECT 1 FROM pg_type WHERE typname = 'incentivetasktype'") + ) + enum_exists = result.fetchone() is not None + + # Create the enum type only if it doesn't exist + if not enum_exists: + incentive_task_type_enum = postgresql.ENUM( + "GITHUB_STAR", + name="incentivetasktype", + create_type=False, + ) + incentive_task_type_enum.create(op.get_bind(), checkfirst=True) + + # Check if table already exists (handles partial migration recovery) + result = conn.execute( + sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'user_incentive_tasks'" + ) + ) + table_exists = result.fetchone() is not None + + if not table_exists: + # Create the user_incentive_tasks table + op.create_table( + "user_incentive_tasks", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "user_id", + sa.UUID(as_uuid=True), + sa.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column( + "task_type", + postgresql.ENUM( + "GITHUB_STAR", name="incentivetasktype", create_type=False + ), + nullable=False, + index=True, + ), + sa.Column("pages_awarded", sa.Integer(), nullable=False), + sa.Column( + "completed_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.func.now(), + index=True, + ), + sa.UniqueConstraint("user_id", "task_type", name="uq_user_incentive_task"), + ) + + +def downgrade() -> None: + """Remove incentive tasks infrastructure.""" + + # Drop the table + op.drop_table("user_incentive_tasks") + + # Drop the enum type + postgresql.ENUM(name="incentivetasktype").drop(op.get_bind(), checkfirst=True) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 8b6f3c718..8a9507e1b 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -144,6 +144,43 @@ class LogStatus(str, Enum): FAILED = "FAILED" +class IncentiveTaskType(str, Enum): + """ + Enum for incentive task types that users can complete to earn free pages. + Each task can only be completed once per user. + + When adding new tasks: + 1. Add a new enum value here + 2. Add the task configuration to INCENTIVE_TASKS_CONFIG below + 3. Create an Alembic migration to add the enum value to PostgreSQL + """ + + GITHUB_STAR = "GITHUB_STAR" + # Future tasks can be added here: + # GITHUB_ISSUE = "GITHUB_ISSUE" + # SOCIAL_SHARE = "SOCIAL_SHARE" + # REFER_FRIEND = "REFER_FRIEND" + + +# Centralized configuration for incentive tasks +# This makes it easy to add new tasks without changing code in multiple places +INCENTIVE_TASKS_CONFIG = { + IncentiveTaskType.GITHUB_STAR: { + "title": "Star our GitHub repository", + "description": "Show your support by starring SurfSense on GitHub", + "pages_reward": 100, + "action_url": "https://github.com/MODSetter/SurfSense", + }, + # Future tasks can be configured here: + # IncentiveTaskType.GITHUB_ISSUE: { + # "title": "Create an issue", + # "description": "Help improve SurfSense by reporting bugs or suggesting features", + # "pages_reward": 50, + # "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose", + # }, +} + + class Permission(str, Enum): """ Granular permissions for search space resources. @@ -915,6 +952,39 @@ class Notification(BaseModel, TimestampMixin): search_space = relationship("SearchSpace", back_populates="notifications") +class UserIncentiveTask(BaseModel, TimestampMixin): + """ + Tracks completed incentive tasks for users. + Each user can only complete each task type once. + When a task is completed, the user's pages_limit is increased. + """ + + __tablename__ = "user_incentive_tasks" + __table_args__ = ( + UniqueConstraint( + "user_id", + "task_type", + name="uq_user_incentive_task", + ), + ) + + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True) + pages_awarded = Column(Integer, nullable=False) + completed_at = Column( + TIMESTAMP(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) + + user = relationship("User", back_populates="incentive_tasks") + + class SearchSpaceRole(BaseModel, TimestampMixin): """ Custom roles that can be defined per search space. @@ -1093,6 +1163,13 @@ if config.AUTH_TYPE == "GOOGLE": cascade="all, delete-orphan", ) + # Incentive tasks completed by this user + incentive_tasks = relationship( + "UserIncentiveTask", + back_populates="user", + cascade="all, delete-orphan", + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, @@ -1144,6 +1221,13 @@ else: cascade="all, delete-orphan", ) + # Incentive tasks completed by this user + incentive_tasks = relationship( + "UserIncentiveTask", + back_populates="user", + cascade="all, delete-orphan", + ) + # Page usage tracking for ETL services pages_limit = Column( Integer, diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 76bb5101a..753105c46 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -20,6 +20,7 @@ from .google_drive_add_connector_route import ( from .google_gmail_add_connector_route import ( router as google_gmail_add_connector_router, ) +from .incentive_tasks_routes import router as incentive_tasks_router from .jira_add_connector_route import router as jira_add_connector_router from .linear_add_connector_route import router as linear_add_connector_router from .logs_routes import router as logs_router @@ -67,3 +68,4 @@ router.include_router(circleback_webhook_router) # Circleback meeting webhooks router.include_router(surfsense_docs_router) # Surfsense documentation for citations router.include_router(notifications_router) # Notifications with Electric SQL sync router.include_router(composio_router) # Composio OAuth and toolkit management +router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages diff --git a/surfsense_backend/app/routes/incentive_tasks_routes.py b/surfsense_backend/app/routes/incentive_tasks_routes.py new file mode 100644 index 000000000..93e54c153 --- /dev/null +++ b/surfsense_backend/app/routes/incentive_tasks_routes.py @@ -0,0 +1,131 @@ +""" +Incentive Tasks API routes. +Allows users to complete tasks (like starring GitHub repo) to earn free pages. +Each task can only be completed once per user. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import ( + INCENTIVE_TASKS_CONFIG, + IncentiveTaskType, + User, + UserIncentiveTask, + get_async_session, +) +from app.schemas.incentive_tasks import ( + CompleteTaskResponse, + IncentiveTaskInfo, + IncentiveTasksResponse, + TaskAlreadyCompletedResponse, +) +from app.users import current_active_user + +router = APIRouter(prefix="/incentive-tasks", tags=["incentive-tasks"]) + + +@router.get("", response_model=IncentiveTasksResponse) +async def get_incentive_tasks( + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> IncentiveTasksResponse: + """ + Get all available incentive tasks with the user's completion status. + """ + # Get all completed tasks for this user + result = await session.execute( + select(UserIncentiveTask).where(UserIncentiveTask.user_id == user.id) + ) + completed_tasks = {task.task_type: task for task in result.scalars().all()} + + # Build task list with completion status + tasks = [] + total_pages_earned = 0 + + for task_type, config in INCENTIVE_TASKS_CONFIG.items(): + completed_task = completed_tasks.get(task_type) + is_completed = completed_task is not None + + if is_completed: + total_pages_earned += completed_task.pages_awarded + + tasks.append( + IncentiveTaskInfo( + task_type=task_type, + title=config["title"], + description=config["description"], + pages_reward=config["pages_reward"], + action_url=config["action_url"], + completed=is_completed, + completed_at=completed_task.completed_at if completed_task else None, + ) + ) + + return IncentiveTasksResponse( + tasks=tasks, + total_pages_earned=total_pages_earned, + ) + + +@router.post( + "/{task_type}/complete", + response_model=CompleteTaskResponse | TaskAlreadyCompletedResponse, +) +async def complete_task( + task_type: IncentiveTaskType, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> CompleteTaskResponse | TaskAlreadyCompletedResponse: + """ + Mark an incentive task as completed and award pages to the user. + + Each task can only be completed once. If the task was already completed, + returns the existing completion information without awarding additional pages. + """ + # Validate task type exists in config + task_config = INCENTIVE_TASKS_CONFIG.get(task_type) + if not task_config: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown task type: {task_type}", + ) + + # Check if task was already completed + existing_task = await session.execute( + select(UserIncentiveTask).where( + UserIncentiveTask.user_id == user.id, + UserIncentiveTask.task_type == task_type, + ) + ) + existing = existing_task.scalar_one_or_none() + + if existing: + return TaskAlreadyCompletedResponse( + success=False, + message="Task already completed", + completed_at=existing.completed_at, + ) + + # Create the task completion record + pages_reward = task_config["pages_reward"] + new_task = UserIncentiveTask( + user_id=user.id, + task_type=task_type, + pages_awarded=pages_reward, + ) + session.add(new_task) + + # Update user's pages_limit + user.pages_limit += pages_reward + + await session.commit() + await session.refresh(user) + + return CompleteTaskResponse( + success=True, + message=f"Task completed! You earned {pages_reward} pages.", + pages_awarded=pages_reward, + new_pages_limit=user.pages_limit, + ) diff --git a/surfsense_backend/app/schemas/incentive_tasks.py b/surfsense_backend/app/schemas/incentive_tasks.py new file mode 100644 index 000000000..52c2a5182 --- /dev/null +++ b/surfsense_backend/app/schemas/incentive_tasks.py @@ -0,0 +1,61 @@ +""" +Schemas for incentive tasks API. +""" + +from datetime import datetime + +from pydantic import BaseModel + +from app.db import INCENTIVE_TASKS_CONFIG, IncentiveTaskType + + +class IncentiveTaskInfo(BaseModel): + """Information about an available incentive task.""" + + task_type: IncentiveTaskType + title: str + description: str + pages_reward: int + action_url: str + completed: bool + completed_at: datetime | None = None + + +class IncentiveTasksResponse(BaseModel): + """Response containing all available incentive tasks with completion status.""" + + tasks: list[IncentiveTaskInfo] + total_pages_earned: int + + +class CompleteTaskRequest(BaseModel): + """Request to mark a task as completed.""" + + task_type: IncentiveTaskType + + +class CompleteTaskResponse(BaseModel): + """Response after completing a task.""" + + success: bool + message: str + pages_awarded: int + new_pages_limit: int + + +class TaskAlreadyCompletedResponse(BaseModel): + """Response when task was already completed.""" + + success: bool + message: str + completed_at: datetime + + +def get_task_info(task_type: IncentiveTaskType) -> dict | None: + """Get task configuration by type.""" + return INCENTIVE_TASKS_CONFIG.get(task_type) + + +def get_all_task_types() -> list[IncentiveTaskType]: + """Get all configured task types.""" + return list(INCENTIVE_TASKS_CONFIG.keys()) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 8dfff4895..9a4f050a1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -87,10 +87,14 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str: context_parts.append("") context_parts.append("") context_parts.append(f" {doc.id}") - context_parts.append(f" {doc.document_type.value}") + context_parts.append( + f" {doc.document_type.value}" + ) context_parts.append(f" <![CDATA[{doc.title}]]>") context_parts.append(f" ") - context_parts.append(f" ") + context_parts.append( + f" " + ) context_parts.append("") context_parts.append("") context_parts.append("") diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index bd9c2c1b4..5b2edae71 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -8,11 +8,11 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; +import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { AUTH_TYPE } from "@/lib/env-config"; import { ValidationError } from "@/lib/error"; import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; -import { Spinner } from "@/components/ui/spinner"; export function LocalLoginForm() { const t = useTranslations("auth"); diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index f8170ff63..60c3ba1be 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; +import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { AUTH_TYPE } from "@/lib/env-config"; import { AppError, ValidationError } from "@/lib/error"; @@ -18,7 +19,6 @@ import { trackRegistrationSuccess, } from "@/lib/posthog/events"; import { AmbientBackground } from "../login/AmbientBackground"; -import { Spinner } from "@/components/ui/spinner"; export default function RegisterPage() { const t = useTranslations("auth"); diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 6d28f9166..d9908f46c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -8,8 +8,8 @@ import React from "react"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { DocumentViewer } from "@/components/document-viewer"; import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; import { Checkbox } from "@/components/ui/checkbox"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, diff --git a/surfsense_web/app/dashboard/[search_space_id]/more-pages/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/more-pages/page.tsx index 000163814..44a9896ca 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/more-pages/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/more-pages/page.tsx @@ -1,48 +1,50 @@ "use client"; -import { ExternalLink, Gift, Mail, Star, MessageSquarePlus, Share2, Check } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react"; import { motion } from "motion/react"; import Link from "next/link"; -import { useState, useCallback } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types"; +import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service"; import { cn } from "@/lib/utils"; -const GITHUB_REPO_URL = "https://github.com/MODSetter/SurfSense"; +export default function MorePagesPage() { + const queryClient = useQueryClient(); -const INITIAL_TASKS = [ - { - id: "star", - title: "Star the repository", - reward: 100, - href: GITHUB_REPO_URL, - icon: Star, - }, - { - id: "issue", - title: "Create an issue", - reward: 50, - href: `${GITHUB_REPO_URL}/issues/new/choose`, - icon: MessageSquarePlus, - }, - { - id: "share", - title: "Share on social media", - reward: 50, - href: `https://twitter.com/intent/tweet?text=Check out SurfSense - an AI-powered personal knowledge base!&url=${encodeURIComponent(GITHUB_REPO_URL)}`, - icon: Share2, - }, -] as const; + // Fetch tasks from API + const { data, isLoading } = useQuery({ + queryKey: ["incentive-tasks"], + queryFn: () => incentiveTasksApiService.getTasks(), + }); -export default function FreePagesPage() { - const [completedIds, setCompletedIds] = useState>(new Set()); + // Mutation to complete a task + const completeMutation = useMutation({ + mutationFn: incentiveTasksApiService.completeTask, + onSuccess: (response) => { + if (response.success) { + toast.success(response.message); + // Invalidate queries to refresh data + queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] }); + queryClient.invalidateQueries({ queryKey: ["user"] }); + } + }, + onError: () => { + toast.error("Failed to complete task. Please try again."); + }, + }); - const handleTaskClick = useCallback((taskId: string) => { - setCompletedIds((prev) => new Set(prev).add(taskId)); - }, []); + const handleTaskClick = (task: IncentiveTaskInfo) => { + if (!task.completed) { + completeMutation.mutate(task.task_type); + } + }; - const allCompleted = completedIds.size === INITIAL_TASKS.length; + const allCompleted = data?.tasks.every((t) => t.completed) ?? false; return (
@@ -55,67 +57,93 @@ export default function FreePagesPage() { {/* Header */}
-

Get Pages

-

- Complete tasks to get free additional pages -

+

Get More Pages

+

Complete tasks to earn additional pages

{/* Tasks */} -
- {INITIAL_TASKS.map((task) => { - const isCompleted = completedIds.has(task.id); - const Icon = task.icon; - return ( + {isLoading ? ( + + + +
+ + +
+ +
+
+ ) : ( +
+ {data?.tasks.map((task) => (
- {isCompleted ? : } + {task.completed ? : }
-

+

{task.title}

-

+{task.reward} pages

+

+{task.pages_reward} pages

- ); - })} -
+ ))} +
+ )} {/* Contact */}

- {allCompleted ? "All done! Need more?" : "Need more pages?"} + {allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"}