mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-24 21:38:09 +02:00
feat: added incentive credits system
This commit is contained in:
parent
d45b33e776
commit
39d65d6166
27 changed files with 587 additions and 84 deletions
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -87,10 +87,14 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
|||
context_parts.append("<document>")
|
||||
context_parts.append("<document_metadata>")
|
||||
context_parts.append(f" <document_id>{doc.id}</document_id>")
|
||||
context_parts.append(f" <document_type>{doc.document_type.value}</document_type>")
|
||||
context_parts.append(
|
||||
f" <document_type>{doc.document_type.value}</document_type>"
|
||||
)
|
||||
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
||||
context_parts.append(f" <url><![CDATA[{url}]]></url>")
|
||||
context_parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
|
||||
context_parts.append(
|
||||
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
|
||||
)
|
||||
context_parts.append("</document_metadata>")
|
||||
context_parts.append("")
|
||||
context_parts.append("<document_content>")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue