diff --git a/app/src/api/onboarding.ts b/app/src/api/onboarding.ts new file mode 100644 index 000000000..058aac83a --- /dev/null +++ b/app/src/api/onboarding.ts @@ -0,0 +1,35 @@ +import { api } from './client'; + +export type OnboardingStatus = { + has_expense: boolean; + has_category: boolean; + has_bill: boolean; + has_budget_goal: boolean; + profile_complete: boolean; +}; + +export type StepResult = { + step: string; + completed?: boolean; + already_completed?: boolean; +}; + +export type Suggestion = { + step: string; + action: string; +}; + +export async function getStatus(): Promise { + return api('/onboarding/status'); +} + +export async function completeStep(step: string): Promise { + return api('/onboarding/complete-step', { + method: 'POST', + body: { step }, + }); +} + +export async function getSuggestions(): Promise { + return api('/onboarding/suggestions'); +} diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..22af5ea34 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .onboarding import bp as onboarding_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(onboarding_bp, url_prefix="/onboarding") diff --git a/packages/backend/app/routes/onboarding.py b/packages/backend/app/routes/onboarding.py new file mode 100644 index 000000000..ab6685e67 --- /dev/null +++ b/packages/backend/app/routes/onboarding.py @@ -0,0 +1,105 @@ +import json +import logging +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import AuditLog, Expense, Category, Bill + +bp = Blueprint("onboarding", __name__) +logger = logging.getLogger("finmind.onboarding") + +ONBOARDING_STEPS = [ + "has_expense", + "has_category", + "has_bill", + "has_budget_goal", + "profile_complete", +] + + +def _get_completed_steps(uid): + rows = ( + db.session.query(AuditLog) + .filter(AuditLog.user_id == uid, AuditLog.action.like("onboarding_step:%")) + .all() + ) + completed = set() + for r in rows: + try: + step = r.action.split(":", 1)[1] + completed.add(step) + except IndexError: + continue + return completed + + +def _check_status(uid): + manual = _get_completed_steps(uid) + status = {} + status["has_expense"] = ( + "has_expense" in manual + or db.session.query(Expense).filter(Expense.user_id == uid).first() is not None + ) + status["has_category"] = ( + "has_category" in manual + or db.session.query(Category).filter(Category.user_id == uid).first() is not None + ) + status["has_bill"] = ( + "has_bill" in manual + or db.session.query(Bill).filter(Bill.user_id == uid).first() is not None + ) + status["has_budget_goal"] = "has_budget_goal" in manual + status["profile_complete"] = "profile_complete" in manual + return status + + +@bp.get("/status") +@jwt_required() +def get_status(): + uid = int(get_jwt_identity()) + status = _check_status(uid) + logger.info("Onboarding status user=%s", uid) + return jsonify(status) + + +@bp.post("/complete-step") +@jwt_required() +def complete_step(): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + step = (data.get("step") or "").strip() + if step not in ONBOARDING_STEPS: + return jsonify(error=f"invalid step, must be one of {ONBOARDING_STEPS}"), 400 + + existing = ( + db.session.query(AuditLog) + .filter(AuditLog.user_id == uid, AuditLog.action == f"onboarding_step:{step}") + .first() + ) + if existing: + return jsonify({"step": step, "already_completed": True}) + + log = AuditLog(user_id=uid, action=f"onboarding_step:{step}") + db.session.add(log) + db.session.commit() + logger.info("Onboarding step completed user=%s step=%s", uid, step) + return jsonify({"step": step, "completed": True}), 201 + + +@bp.get("/suggestions") +@jwt_required() +def get_suggestions(): + uid = int(get_jwt_identity()) + status = _check_status(uid) + suggestions = [] + if not status["has_expense"]: + suggestions.append({"step": "has_expense", "action": "Add your first expense"}) + if not status["has_category"]: + suggestions.append({"step": "has_category", "action": "Create a spending category"}) + if not status["has_bill"]: + suggestions.append({"step": "has_bill", "action": "Set up a recurring bill"}) + if not status["has_budget_goal"]: + suggestions.append({"step": "has_budget_goal", "action": "Set a budget goal"}) + if not status["profile_complete"]: + suggestions.append({"step": "profile_complete", "action": "Complete your profile"}) + return jsonify(suggestions) diff --git a/packages/backend/tests/test_onboarding.py b/packages/backend/tests/test_onboarding.py new file mode 100644 index 000000000..ef3eac795 --- /dev/null +++ b/packages/backend/tests/test_onboarding.py @@ -0,0 +1,65 @@ +def test_onboarding_requires_auth(client): + r = client.get("/onboarding/status") + assert r.status_code in (401, 422) + + r = client.post("/onboarding/complete-step", json={"step": "has_expense"}) + assert r.status_code in (401, 422) + + r = client.get("/onboarding/suggestions") + assert r.status_code in (401, 422) + + +def test_status_empty(client, auth_header): + r = client.get("/onboarding/status", headers=auth_header) + assert r.status_code == 200 + status = r.get_json() + assert status["has_expense"] is False + assert status["has_category"] is False + assert status["has_bill"] is False + assert status["has_budget_goal"] is False + assert status["profile_complete"] is False + + +def test_complete_step(client, auth_header): + r = client.post( + "/onboarding/complete-step", + json={"step": "has_budget_goal"}, + headers=auth_header, + ) + assert r.status_code == 201 + assert r.get_json()["completed"] is True + + r = client.get("/onboarding/status", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["has_budget_goal"] is True + + # Repeating should not error + r = client.post( + "/onboarding/complete-step", + json={"step": "has_budget_goal"}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["already_completed"] is True + + +def test_suggestions(client, auth_header): + r = client.get("/onboarding/suggestions", headers=auth_header) + assert r.status_code == 200 + suggestions = r.get_json() + assert len(suggestions) == 5 + steps = [s["step"] for s in suggestions] + assert "has_expense" in steps + assert "has_budget_goal" in steps + + # Complete a step and check suggestions shrink + client.post( + "/onboarding/complete-step", + json={"step": "profile_complete"}, + headers=auth_header, + ) + r = client.get("/onboarding/suggestions", headers=auth_header) + assert r.status_code == 200 + suggestions = r.get_json() + assert len(suggestions) == 4 + assert "profile_complete" not in [s["step"] for s in suggestions]