diff --git a/app/api/__init__.py b/app/api/__init__.py index ac916de2..36f9345a 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -15,6 +15,7 @@ from app.api.duplicates import router as duplicates_router from app.api.files import router as files_router from app.api.google_drive import router as google_drive_router from app.api.logs import router as logs_router +from app.api.onboarding import router as onboarding_router from app.api.onedrive import router as onedrive_router from app.api.openai import router as openai_router from app.api.plans import router as plans_router @@ -60,3 +61,4 @@ router.include_router(webhooks_router) router.include_router(database_router) router.include_router(subscriptions_router) router.include_router(plans_router) +router.include_router(onboarding_router) diff --git a/app/api/onboarding.py b/app/api/onboarding.py new file mode 100644 index 00000000..ffdf27ea --- /dev/null +++ b/app/api/onboarding.py @@ -0,0 +1,225 @@ +"""API endpoints for the user onboarding wizard. + +Provides a REST interface for the multi-step onboarding flow, allowing +authenticated users to set their profile, choose a subscription plan, +select a storage destination, and mark onboarding as complete. +""" + +import logging +from datetime import datetime, timezone +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import UserProfile +from app.utils.subscription import TIERS + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/onboarding", tags=["onboarding"]) + +DbSession = Annotated[Session, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Auth helper +# --------------------------------------------------------------------------- + + +def _get_current_user_id(request: Request) -> str: + """Extract the stable user_id from the session using the same priority as _ensure_user_profile. + + Priority: sub → preferred_username → email → id. + + Raises: + HTTPException: 401 if the user is not authenticated. + """ + user = request.session.get("user") + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id") + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + return user_id + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class ProfileBody(BaseModel): + """Body for the profile step of the onboarding wizard.""" + + display_name: str | None = Field(default=None, max_length=255) + contact_email: str | None = Field(default=None, max_length=255) + + +class PlanBody(BaseModel): + """Body for the plan step of the onboarding wizard.""" + + subscription_tier: str + billing_cycle: str = Field(pattern="^(monthly|yearly)$") + + +class StorageBody(BaseModel): + """Body for the storage step of the onboarding wizard.""" + + preferred_destination: str | None = Field(default=None, max_length=50) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _profile_to_dict(profile: UserProfile) -> dict[str, Any]: + """Serialize a UserProfile to a plain dict for API responses.""" + return { + "user_id": profile.user_id, + "display_name": profile.display_name, + "contact_email": profile.contact_email, + "subscription_tier": profile.subscription_tier or "free", + "subscription_billing_cycle": profile.subscription_billing_cycle or "monthly", + "preferred_destination": profile.preferred_destination, + "onboarding_completed": bool(profile.onboarding_completed), + "onboarding_completed_at": profile.onboarding_completed_at.isoformat() + if profile.onboarding_completed_at + else None, + } + + +def _get_or_create_profile(db: Session, user_id: str) -> UserProfile: + """Return the UserProfile for *user_id*, creating one if it does not exist.""" + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + if profile is None: + profile = UserProfile(user_id=user_id) + db.add(profile) + db.flush() + return profile + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/status", summary="Get onboarding status for the current user") +def get_onboarding_status(request: Request, db: DbSession) -> dict[str, Any]: + """Return whether onboarding has been completed and the current step. + + The ``step`` field is a best-effort estimate: 1 for brand-new profiles, + further along when partial data has already been saved. + """ + user_id = _get_current_user_id(request) + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + + if profile is None: + return {"completed": False, "step": 1, "profile": None} + + # Derive a sensible current step from saved data so the wizard can resume. + step = 1 + if profile.display_name or profile.contact_email: + step = 2 + if profile.subscription_tier and profile.subscription_tier != "free": + step = 3 + if profile.preferred_destination: + step = 4 + if profile.onboarding_completed: + step = 5 + + return { + "completed": bool(profile.onboarding_completed), + "step": step, + "profile": _profile_to_dict(profile), + } + + +@router.post("/profile", summary="Save profile step during onboarding") +def save_profile(request: Request, body: ProfileBody, db: DbSession) -> dict[str, Any]: + """Persist the user's display name and contact email from the profile step.""" + user_id = _get_current_user_id(request) + profile = _get_or_create_profile(db, user_id) + + if body.display_name is not None: + profile.display_name = body.display_name + if body.contact_email is not None: + profile.contact_email = body.contact_email + + try: + db.commit() + db.refresh(profile) + except Exception: + db.rollback() + raise + + logger.info("Onboarding: saved profile for user %s", user_id) + return _profile_to_dict(profile) + + +@router.post("/plan", summary="Save plan selection during onboarding") +def save_plan(request: Request, body: PlanBody, db: DbSession) -> dict[str, Any]: + """Persist the chosen subscription tier and billing cycle from the plan step. + + Raises: + HTTPException: 422 if the tier is not a recognised value. + """ + user_id = _get_current_user_id(request) + + if body.subscription_tier not in TIERS: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}", + ) + + profile = _get_or_create_profile(db, user_id) + profile.subscription_tier = body.subscription_tier + profile.subscription_billing_cycle = body.billing_cycle + + try: + db.commit() + db.refresh(profile) + except Exception: + db.rollback() + raise + + logger.info("Onboarding: saved plan %s/%s for user %s", body.subscription_tier, body.billing_cycle, user_id) + return _profile_to_dict(profile) + + +@router.post("/storage", summary="Save storage preference during onboarding") +def save_storage(request: Request, body: StorageBody, db: DbSession) -> dict[str, Any]: + """Persist the user's preferred storage destination from the storage step.""" + user_id = _get_current_user_id(request) + profile = _get_or_create_profile(db, user_id) + profile.preferred_destination = body.preferred_destination + + try: + db.commit() + db.refresh(profile) + except Exception: + db.rollback() + raise + + logger.info("Onboarding: saved storage preference '%s' for user %s", body.preferred_destination, user_id) + return _profile_to_dict(profile) + + +@router.post("/complete", summary="Mark onboarding as completed") +def complete_onboarding(request: Request, db: DbSession) -> dict[str, bool]: + """Set onboarding_completed=True and record the completion timestamp.""" + user_id = _get_current_user_id(request) + profile = _get_or_create_profile(db, user_id) + profile.onboarding_completed = True + profile.onboarding_completed_at = datetime.now(tz=timezone.utc) + + try: + db.commit() + except Exception: + db.rollback() + raise + + logger.info("Onboarding: completed for user %s", user_id) + return {"success": True} diff --git a/app/auth.py b/app/auth.py index ae741038..707f4279 100644 --- a/app/auth.py +++ b/app/auth.py @@ -167,6 +167,19 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)): # Log the successful authentication logger.info(f"[SECURITY] OAUTH_LOGIN_SUCCESS user={user_data.get('email', 'unknown')} admin={is_admin}") + # Redirect first-time users to onboarding + user_id = ( + user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id") + ) + if user_id: + from app.models import UserProfile as _UserProfile + + profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() + if profile and not profile.onboarding_completed: + post_onboarding = request.session.pop("redirect_after_login", "/upload") + request.session["post_onboarding_redirect"] = post_onboarding + return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND) + # Redirect to original destination or default redirect_url = request.session.pop("redirect_after_login", "/upload") return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND) diff --git a/app/models.py b/app/models.py index b5857bb5..1c38ea84 100644 --- a/app/models.py +++ b/app/models.py @@ -209,6 +209,12 @@ class UserProfile(Base): subscription_period_start = Column(DateTime(timezone=True), nullable=True) allow_overage = Column(Boolean, nullable=False, default=False, server_default="0") + # Onboarding tracking (added in migration 017) + onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0") + onboarding_completed_at = Column(DateTime(timezone=True), nullable=True) + contact_email = Column(String(255), nullable=True) + preferred_destination = Column(String(50), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/app/views/__init__.py b/app/views/__init__.py index 75eafbfb..c6e5ae77 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -13,6 +13,7 @@ from app.views.filemanager import router as filemanager_router from app.views.general import router as general_router from app.views.google_drive import router as google_drive_router from app.views.license_routes import router as license_router # Add the license router +from app.views.onboarding import router as onboarding_router from app.views.onedrive import router as onedrive_router from app.views.plans import router as plans_router # Admin Plan Designer from app.views.queue import router as queue_router @@ -39,3 +40,4 @@ router.include_router(search_router) router.include_router(queue_router) router.include_router(subscriptions_router) # Pricing + subscription pages router.include_router(plans_router) # Admin Plan Designer +router.include_router(onboarding_router) # User onboarding wizard diff --git a/app/views/onboarding.py b/app/views/onboarding.py new file mode 100644 index 00000000..8f6a85fa --- /dev/null +++ b/app/views/onboarding.py @@ -0,0 +1,90 @@ +"""View route for the user onboarding wizard.""" + +import logging + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + +from app.config import settings as _settings +from app.models import UserProfile +from app.utils.subscription import get_all_tiers +from app.views.base import APIRouter, get_db, require_login, templates + +logger = logging.getLogger(__name__) +router = APIRouter() + +# --------------------------------------------------------------------------- +# Destination helper +# --------------------------------------------------------------------------- + +_DESTINATION_META: list[dict] = [ + {"id": "dropbox", "name": "Dropbox", "icon": "fab fa-dropbox"}, + {"id": "gdrive", "name": "Google Drive", "icon": "fab fa-google-drive"}, + {"id": "onedrive", "name": "OneDrive", "icon": "fab fa-microsoft"}, + {"id": "s3", "name": "Amazon S3", "icon": "fab fa-aws"}, + {"id": "nextcloud", "name": "Nextcloud", "icon": "fas fa-cloud"}, + {"id": "webdav", "name": "WebDAV", "icon": "fas fa-server"}, + {"id": "sftp", "name": "SFTP", "icon": "fas fa-terminal"}, + {"id": "ftp", "name": "FTP", "icon": "fas fa-server"}, +] + + +def _get_configured_destinations(cfg) -> list[dict]: + """Return which storage providers are fully configured in the current settings. + + Each entry is a dict with ``id``, ``name``, and ``icon`` keys. + + Args: + cfg: The application settings object (``app.config.settings``). + + Returns: + A list of destination dicts for providers that have the required + credentials set. + """ + checks: dict[str, bool] = { + "dropbox": bool(cfg.dropbox_refresh_token and cfg.dropbox_app_key), + "gdrive": bool(cfg.google_drive_credentials_json or cfg.google_drive_refresh_token), + "onedrive": bool(cfg.onedrive_refresh_token and cfg.onedrive_client_id), + "s3": bool(cfg.aws_access_key_id and cfg.s3_bucket_name), + "nextcloud": bool(cfg.nextcloud_upload_url and cfg.nextcloud_username), + "webdav": bool(cfg.webdav_url and cfg.webdav_username), + "sftp": bool(cfg.sftp_host and cfg.sftp_username), + "ftp": bool(cfg.ftp_host and cfg.ftp_username), + } + return [meta for meta in _DESTINATION_META if checks.get(meta["id"], False)] + + +# --------------------------------------------------------------------------- +# Route +# --------------------------------------------------------------------------- + + +@router.get("/onboarding", include_in_schema=False) +@require_login +async def onboarding_page(request: Request, db: Session = Depends(get_db)): + """Render the multi-step onboarding wizard. + + Redirects to ``/upload`` when the user has already completed onboarding. + """ + user = request.session.get("user") or {} + user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id") + + if user_id: + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + if profile and profile.onboarding_completed: + from starlette.responses import RedirectResponse + + return RedirectResponse(url="/upload", status_code=302) + + configured_destinations = _get_configured_destinations(_settings) + tiers = get_all_tiers(db) + + return templates.TemplateResponse( + "onboarding.html", + { + "request": request, + "user": user, + "configured_destinations": configured_destinations, + "tiers": tiers, + }, + ) diff --git a/frontend/templates/onboarding.html b/frontend/templates/onboarding.html new file mode 100644 index 00000000..d3368538 --- /dev/null +++ b/frontend/templates/onboarding.html @@ -0,0 +1,470 @@ +{% extends "base.html" %} +{% block title %}Welcome to DocuElevate{% endblock %} + +{% block content %} +
Let's get you set up
+You're just a few steps away from transforming how you handle documents.
+AI extracts text from any document
+Documents organised automatically
+Instantly backed up to your storage
++ This quick setup takes about 2 minutes. You can change everything later in your settings. +
+ +Tell us a little about yourself
+This is how you'll appear in DocuElevate.
+Used for notifications. Can be different from your login email.
+Start free, upgrade when you're ready
+Where should your processed documents go?
+Select where you'd like your documents stored. You can change this later in settings.
++ Not seeing your provider? + {% if user.is_admin %} + Go to Settings to configure more destinations. + {% else %} + Ask your administrator to configure additional storage providers. + {% endif %} +
+ {% else %} ++ No storage destinations have been configured for this instance yet. + You can skip this step and set one up later. +
+ {% if user.is_admin %} + + Configure Storage + + {% else %} +Contact your administrator to set up a storage destination.
+ {% endif %} +Your account is configured and ready to go.
+