Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""add_onboarding_fields_to_users

Revision ID: 6fb23040d929
Revises: srcpc01
Create Date: 2026-03-25 15:25:08.171602

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "6fb23040d929"
down_revision: Union[str, None] = "srcpc01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_onboarded", sa.Boolean(), nullable=False, server_default="false"),
)
op.add_column(
"users",
sa.Column("completed_onboarding_steps", sa.JSON(), nullable=False, server_default="[]"),
)


def downgrade() -> None:
op.drop_column("users", "completed_onboarding_steps")
op.drop_column("users", "is_onboarded")
22 changes: 15 additions & 7 deletions backend/app/agents/project_chat_assistant/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,11 @@ def _convert_messages_to_llm_format(

return llm_messages

def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantResponse, bool]:
def _parse_response(
self,
response_text: str,
can_explore_code: bool = False,
) -> tuple[ProjectChatAssistantResponse, bool]:
"""
Parse the JSON response from the LLM.

Expand All @@ -669,6 +673,7 @@ def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantRespo

Args:
response_text: Raw response text from the LLM.
can_explore_code: Whether code exploration is available (has repos + explorer enabled).

Returns:
Tuple of (parsed response, json_was_valid).
Expand Down Expand Up @@ -736,8 +741,10 @@ def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantRespo
# Fallback: Detect exploration intent when JSON parsing failed or was incomplete
# This catches cases where the agent says "Let me explore..." without proper JSON
# Skip if web search is requested (via JSON field or block) - "let me search" should NOT trigger code exploration
# Only attempt if code exploration is actually available (has repos + explorer enabled)
if (
not data.get("wants_code_exploration")
can_explore_code
and not data.get("wants_code_exploration")
and not data.get("wants_web_search")
and not has_web_search_block(response_text)
):
Expand Down Expand Up @@ -836,10 +843,10 @@ def _parse_response(self, response_text: str) -> tuple[ProjectChatAssistantRespo
"proposed_feature_module_id": data.get("proposed_feature_module_id"),
"proposed_feature_module_title": data.get("proposed_feature_module_title"),
"proposed_feature_module_description": data.get("proposed_feature_module_description"),
# Code exploration fields
"wants_code_exploration": data.get("wants_code_exploration", False),
"code_exploration_prompt": data.get("code_exploration_prompt"),
"code_exploration_branch": data.get("code_exploration_branch"),
# Code exploration fields — only if exploration is actually available
"wants_code_exploration": data.get("wants_code_exploration", False) and can_explore_code,
"code_exploration_prompt": data.get("code_exploration_prompt") if can_explore_code else None,
"code_exploration_branch": data.get("code_exploration_branch") if can_explore_code else None,
# Web search fields
"wants_web_search": data.get("wants_web_search", False),
"web_search_query": data.get("web_search_query"),
Expand Down Expand Up @@ -937,7 +944,8 @@ async def generate_response(
response_text = " ".join(str(item) for item in response_text)

# Parse the response (handles JSON extraction and Pydantic validation)
parsed, json_parsed = self._parse_response(response_text)
can_explore = context.code_explorer_enabled and context.has_repositories
parsed, json_parsed = self._parse_response(response_text, can_explore_code=can_explore)

if json_parsed:
# Successfully parsed JSON
Expand Down
31 changes: 30 additions & 1 deletion backend/app/mcp/vfs/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from app.mcp.vfs.errors import InvalidPathError, PermissionDeniedError
from app.mcp.vfs.path_resolver import NodeType, ResolvedPath
from app.models.feature import Feature, FeatureCompletionStatus
from app.models.feature import Feature, FeatureCompletionStatus, FeatureProvenance

# Reserved metadata keys that are computed and cannot be set
COMPUTED_KEYS = {
Expand Down Expand Up @@ -248,6 +248,35 @@ def _set_is_complete(
if feature:
FeatureService._broadcast_feature_update(db, feature, "completion_status")

# Onboarding hook: Step 15 / Step 18 — implementation marked complete via MCP
if is_complete:
try:
from app.models.module import Module
from app.models.project import Project
from app.models.user import User as _OUser
from app.services.onboarding_service import OnboardingService

_o_feature = feature or db.query(Feature).filter(Feature.id == impl.feature_id).first()
if _o_feature:
_o_module = db.query(Module).filter(Module.id == _o_feature.module_id).first()
if _o_module:
_o_project = db.query(Project).filter(Project.id == _o_module.project_id).first()
if _o_project and _o_project.is_sample:
_o_user = db.query(_OUser).filter(_OUser.id == user_id).first()
_completed = _o_user.completed_onboarding_steps or [] if _o_user else []

if 15 not in _completed:
if OnboardingService.complete_step(db, user_id, 15):
db.commit()
OnboardingService.broadcast_step_completed(_o_project.org_id, user_id, 15)
elif 17 in _completed:
if _o_feature.provenance == FeatureProvenance.USER:
if OnboardingService.complete_step(db, user_id, 18):
db.commit()
OnboardingService.broadcast_step_completed(_o_project.org_id, user_id, 18)
except Exception:
pass # Non-critical, don't break MCP flow

return {
"path": resolved.path,
"key": "is_complete",
Expand Down
19 changes: 18 additions & 1 deletion backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import TYPE_CHECKING, Optional
from uuid import UUID, uuid4

from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database import Base
Expand Down Expand Up @@ -115,6 +115,23 @@ class User(Base):
doc="Timestamp when the user's trial period started (null = grandfathered)",
)

# Onboarding fields
is_onboarded: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="false",
doc="Whether the user has completed or dismissed onboarding",
)

completed_onboarding_steps: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
server_default="[]",
doc="Array of completed onboarding step numbers (1-18)",
)

# Relationships
current_org: Mapped[Optional["Organization"]] = relationship(
"Organization",
Expand Down
58 changes: 58 additions & 0 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
from app.rate_limit import limiter
from app.schemas.api_key import ApiKeyCreate
from app.schemas.auth import (
OnboardingDismissRequest,
OnboardingStepRequest,
OrgMembershipResponse,
RegistrationResponse,
ResendVerificationRequest,
Expand All @@ -49,6 +51,7 @@
from app.schemas.oauth import OAuthProviderInfo
from app.services.api_key_service import ApiKeyService
from app.services.email_service import EmailService
from app.services.onboarding_service import OnboardingService
from app.services.org_service import OrgService
from app.services.user_service import UserService

Expand Down Expand Up @@ -374,6 +377,8 @@ def get_current_user_info(
display_name=current_user.display_name,
org_id=org_id,
created_at=current_user.created_at,
is_onboarded=current_user.is_onboarded,
completed_onboarding_steps=current_user.completed_onboarding_steps or [],
)


Expand Down Expand Up @@ -507,6 +512,59 @@ def switch_current_org(
display_name=current_user.display_name,
org_id=request.org_id,
created_at=current_user.created_at,
is_onboarded=current_user.is_onboarded,
completed_onboarding_steps=current_user.completed_onboarding_steps or [],
)


@router.patch("/me/onboarding", response_model=UserResponse)
@limiter.limit("30/minute")
def dismiss_onboarding(
request: Request,
body: OnboardingDismissRequest,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> UserResponse:
"""Dismiss onboarding permanently. Sets is_onboarded=True."""
if not body.is_onboarded:
raise HTTPException(status_code=400, detail="is_onboarded must be true")
OnboardingService.dismiss(db, current_user)
db.commit()

org_id = current_user.current_org_id
return UserResponse(
id=current_user.id,
email=current_user.email,
display_name=current_user.display_name,
org_id=org_id,
created_at=current_user.created_at,
is_onboarded=current_user.is_onboarded,
completed_onboarding_steps=current_user.completed_onboarding_steps or [],
)


@router.patch("/me/onboarding/steps", response_model=UserResponse)
@limiter.limit("30/minute")
def mark_onboarding_step_complete(
request: Request,
body: OnboardingStepRequest,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> UserResponse:
"""Mark an onboarding step as complete. Idempotent."""
OnboardingService.complete_step(db, current_user.id, body.step)
db.commit()
db.refresh(current_user)

org_id = current_user.current_org_id
return UserResponse(
id=current_user.id,
email=current_user.email,
display_name=current_user.display_name,
org_id=org_id,
created_at=current_user.created_at,
is_onboarded=current_user.is_onboarded,
completed_onboarding_steps=current_user.completed_onboarding_steps or [],
)


Expand Down
1 change: 1 addition & 0 deletions backend/app/routers/inbox_deep_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async def resolve_deep_link(
chat_title=result.get("chat_title"),
feature_key=result.get("feature_key"),
feature_id=result.get("feature_id"),
feature_short_id=result.get("feature_short_id"),
phase_title=result.get("phase_title"),
)

Expand Down
22 changes: 22 additions & 0 deletions backend/app/routers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@
router = APIRouter(tags=["projects"])


def _broadcast_project_list_changed(org_id: UUID) -> None:
"""Broadcast that the project list changed so sidebar and onboarding panel refresh."""
try:
from app.services.kafka_producer import get_sync_kafka_producer

message = {
"type": "project_list_changed",
"org_id": str(org_id),
}
get_sync_kafka_producer().publish(
topic="mfbt.websocket.broadcasts",
message=message,
key=str(org_id),
)
except Exception:
logger.debug("Failed to broadcast project_list_changed")


@router.post(
"/orgs/{org_id}/projects",
response_model=ProjectResponse,
Expand Down Expand Up @@ -89,6 +107,7 @@ def create_project(
# Note: GitHub repos are now added via POST /projects/{id}/repositories
# Grounding file generation is triggered when the first repository is added

_broadcast_project_list_changed(org_id)
return project


Expand Down Expand Up @@ -232,6 +251,7 @@ def load_sample_project(
partition_key=str(phase.id),
)

_broadcast_project_list_changed(org_id)
return project


Expand Down Expand Up @@ -463,6 +483,7 @@ def archive_project(
)

archived = ProjectService.archive_project(db, project.id)
_broadcast_project_list_changed(project.org_id)
return archived


Expand Down Expand Up @@ -501,6 +522,7 @@ def delete_project(
)

deleted = ProjectService.soft_delete_project(db, project.id)
_broadcast_project_list_changed(project.org_id)
return deleted


Expand Down
28 changes: 28 additions & 0 deletions backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,34 @@ class UserResponse(BaseModel):
...,
description="Timestamp when the user was created",
)
is_onboarded: bool = Field(
False,
description="Whether the user has completed or dismissed onboarding",
)
completed_onboarding_steps: list[int] = Field(
default_factory=list,
description="Array of completed onboarding step numbers (1-18)",
)


class OnboardingDismissRequest(BaseModel):
"""Schema for dismissing onboarding."""

is_onboarded: bool = Field(
True,
description="Must be true to dismiss onboarding",
)


class OnboardingStepRequest(BaseModel):
"""Schema for marking an onboarding step as complete."""

step: int = Field(
...,
ge=1,
le=18,
description="Onboarding step number (1-18)",
)


class TokenResponse(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas/inbox_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class UnifiedConversation(BaseModel):
last_message_author: Optional[str] = None
feature_key: Optional[str] = None # For FEATURE type
feature_id: Optional[str] = None # Feature UUID for FEATURE type (used in ?feature= param)
feature_short_id: Optional[str] = None # Feature short_id for building feature detail URLs
module_title: Optional[str] = None # For FEATURE type
phase_title: Optional[str] = None # For PHASE and FEATURE types
phase_short_id: Optional[str] = None # Phase short_id for FEATURE type URL routing
Expand Down
2 changes: 2 additions & 0 deletions backend/app/schemas/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ class ProjectResponse(BaseModel):
url_identifier: str
# Repositories (multi-repo support)
repositories: list[ProjectRepositoryBrief] = []
# Sample project flag
is_sample: bool = False


# Project Membership Schemas
Expand Down
Loading
Loading