feat: refactor user configuration table

This commit is contained in:
Abhishek Kumar 2026-06-12 22:00:51 +05:30
parent 03daaba7a1
commit e5cc1308ed
31 changed files with 932 additions and 419 deletions

View file

@ -0,0 +1,52 @@
"""add key to user_configurations
Turns user_configurations into a per-user keyed JSON store mirroring
organization_configurations. Existing rows (the legacy v1 AI model
configuration blob) are backfilled with key MODEL_CONFIGURATION.
Revision ID: 91cc6ba3e1c7
Revises: 384be6596b36
Create Date: 2026-06-12 21:04:25.561529
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "91cc6ba3e1c7"
down_revision: Union[str, None] = "384be6596b36"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Backfill existing rows (all legacy model-config blobs) via the server
# default, then drop the default — application code always supplies key.
op.add_column(
"user_configurations",
sa.Column(
"key",
sa.String(),
nullable=False,
server_default="MODEL_CONFIGURATION",
),
)
op.create_unique_constraint(
"_user_configuration_key_uc", "user_configurations", ["user_id", "key"]
)
op.alter_column("user_configurations", "key", server_default=None)
def downgrade() -> None:
op.drop_constraint(
"_user_configuration_key_uc", "user_configurations", type_="unique"
)
# Non-model-config rows (e.g. ONBOARDING) have no meaning in the old
# single-blob schema; the old code would read them as the user's model
# config, so they must not survive the downgrade.
op.execute("DELETE FROM user_configurations WHERE key != 'MODEL_CONFIGURATION'")
op.drop_column("user_configurations", "key")

View file

@ -82,12 +82,24 @@ class UserModel(Base):
class UserConfigurationModel(Base):
"""Per-user keyed JSON store, mirroring organization_configurations.
Keys are defined in UserConfigurationKey. The legacy v1 AI model
configuration lives under MODEL_CONFIGURATION; last_validated_at is only
meaningful for that key.
"""
__tablename__ = "user_configurations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
key = Column(String, nullable=False)
configuration = Column(JSON, nullable=False, default=dict)
last_validated_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "key", name="_user_configuration_key_uc"),
)
# New Organization model
class OrganizationModel(Base):

View file

@ -18,7 +18,7 @@ from api.db.models import (
WorkflowModel,
WorkflowRunModel,
)
from api.enums import OrganizationConfigurationKey
from api.enums import OrganizationConfigurationKey, UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
@ -343,7 +343,9 @@ class OrganizationUsageClient(BaseDBClient):
if user_id:
config_result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key
== UserConfigurationKey.MODEL_CONFIGURATION.value,
)
)
config_obj = config_result.scalar_one_or_none()

View file

@ -8,6 +8,7 @@ from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import UserConfigurationModel, UserModel
from api.enums import UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
@ -65,16 +66,51 @@ class UserClient(BaseDBClient):
)
return result.scalars().first()
async def _get_user_configuration_row(
self, session, user_id: int, key: str
) -> UserConfigurationModel | None:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key == key,
)
)
return result.scalars().first()
async def get_user_configuration_value(self, user_id: int, key: str) -> dict | None:
"""Get the JSON value stored for a user under `key`, or None."""
async with self.async_session() as session:
row = await self._get_user_configuration_row(session, user_id, key)
return row.configuration if row else None
async def upsert_user_configuration_value(
self, user_id: int, key: str, value: dict
) -> dict:
"""Create or update the JSON value stored for a user under `key`."""
async with self.async_session() as session:
row = await self._get_user_configuration_row(session, user_id, key)
if row:
row.configuration = value
else:
row = UserConfigurationModel(
user_id=user_id, key=key, configuration=value
)
session.add(row)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(row)
return row.configuration
async def get_user_configurations(
self, user_id: int
) -> EffectiveAIModelConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
configuration_obj = await self._get_user_configuration_row(
session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value
)
configuration_obj = result.scalars().first()
if not configuration_obj:
return EffectiveAIModelConfiguration()
@ -97,38 +133,18 @@ class UserClient(BaseDBClient):
async def update_user_configuration(
self, user_id: int, configuration: EffectiveAIModelConfiguration
) -> EffectiveAIModelConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
)
configuration_obj = result.scalars().first()
if not configuration_obj:
configuration_obj = UserConfigurationModel(
user_id=user_id, configuration=configuration.model_dump()
)
session.add(configuration_obj)
else:
configuration_obj.configuration = configuration.model_dump()
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(configuration_obj)
return EffectiveAIModelConfiguration.model_validate(
configuration_obj.configuration
value = await self.upsert_user_configuration_value(
user_id,
UserConfigurationKey.MODEL_CONFIGURATION.value,
configuration.model_dump(),
)
return EffectiveAIModelConfiguration.model_validate(value)
async def update_user_configuration_last_validated_at(self, user_id: int) -> None:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
configuration_obj = await self._get_user_configuration_row(
session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value
)
configuration_obj = result.scalars().first()
if not configuration_obj:
raise ValueError(f"User configuration with ID {user_id} not found")
configuration_obj.last_validated_at = datetime.now()

View file

@ -96,6 +96,15 @@ class OrganizationConfigurationKey(Enum):
MODEL_CONFIGURATION_PREFERENCES = "MODEL_CONFIGURATION_PREFERENCES" # Deprecated; read fallback for old org preferences
class UserConfigurationKey(Enum):
"""Keys for the per-user keyed JSON store (user_configurations)."""
MODEL_CONFIGURATION = (
"MODEL_CONFIGURATION" # Legacy per-user v1 AI model configuration
)
ONBOARDING = "ONBOARDING" # Post-signup onboarding state (gate, tooltips, actions)
class WorkflowStatus(Enum):
"""Workflow status values"""

View file

@ -9,6 +9,7 @@ from api.db import db_client
from api.db.models import (
UserModel,
)
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
get_resolved_ai_model_configuration,
@ -26,6 +27,10 @@ from api.services.organization_preferences import (
get_organization_preferences,
upsert_organization_preferences,
)
from api.services.user_onboarding import (
get_onboarding_state,
update_onboarding_state,
)
router = APIRouter(prefix="/user")
@ -92,9 +97,6 @@ class UserConfigurationRequestResponseSchema(BaseModel):
test_phone_number: str | None = None
timezone: str | None = None
organization_pricing: dict[str, Union[float, str, bool]] | None = None
# Post-signup onboarding gate. Set once on submit/skip.
onboarding_completed_at: datetime | None = None
onboarding_skipped: bool | None = None
@router.get("/configurations/user")
@ -206,6 +208,21 @@ async def update_user_configurations(
return masked_config
@router.get("/onboarding-state")
async def get_user_onboarding_state(
user: UserModel = Depends(get_user),
) -> OnboardingState:
return await get_onboarding_state(user.id)
@router.put("/onboarding-state")
async def update_user_onboarding_state(
request: OnboardingStateUpdate,
user: UserModel = Depends(get_user),
) -> OnboardingState:
return await update_onboarding_state(user.id, request)
@router.get("/configurations/user/validate")
async def validate_user_configurations(
validity_ttl_seconds: int = Query(default=60, ge=0, le=86400),

View file

@ -34,10 +34,6 @@ class EffectiveAIModelConfiguration(BaseModel):
test_phone_number: str | None = None
timezone: str | None = None
last_validated_at: datetime | None = None
# Post-signup onboarding gate: set once the user submits or skips the
# onboarding form, so it shows only once per user.
onboarding_completed_at: datetime | None = None
onboarding_skipped: bool = False
@model_validator(mode="before")
@classmethod

View file

@ -0,0 +1,47 @@
from datetime import datetime
from pydantic import BaseModel, Field
class OnboardingState(BaseModel):
"""Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.
Server-authoritative replacement for the browser-localStorage onboarding
store, so the post-signup gate and one-time tooltips hold across devices.
"""
# Post-signup onboarding form gate: set once on submit/skip.
completed_at: datetime | None = None
skipped: bool = False
# One-time UI affordances (tooltip keys, milestone action keys). Kept as
# free-form strings — the UI owns the vocabulary.
seen_tooltips: list[str] = Field(default_factory=list)
completed_actions: list[str] = Field(default_factory=list)
class OnboardingStateUpdate(BaseModel):
"""Partial update merged into the stored state.
Scalars overwrite when supplied; list entries are unioned into the stored
lists, so concurrent updates (e.g. two tabs marking different tooltips)
don't drop each other's items.
"""
completed_at: datetime | None = None
skipped: bool | None = None
seen_tooltips: list[str] | None = None
completed_actions: list[str] | None = None
def apply_to(self, state: OnboardingState) -> OnboardingState:
merged = state.model_copy(deep=True)
if self.completed_at is not None:
merged.completed_at = self.completed_at
if self.skipped is not None:
merged.skipped = self.skipped
for tooltip in self.seen_tooltips or []:
if tooltip not in merged.seen_tooltips:
merged.seen_tooltips.append(tooltip)
for action in self.completed_actions or []:
if action not in merged.completed_actions:
merged.completed_actions.append(action)
return merged

View file

@ -141,10 +141,6 @@ def mask_user_config(config: EffectiveAIModelConfiguration) -> Dict[str, Any]:
"is_realtime": config.is_realtime,
"test_phone_number": config.test_phone_number,
"timezone": config.timezone,
# Onboarding gate flags (not secrets) — surfaced so the UI can decide
# whether to show the post-signup onboarding form on boot.
"onboarding_completed_at": config.onboarding_completed_at,
"onboarding_skipped": config.onboarding_skipped,
}

View file

@ -113,13 +113,6 @@ def merge_user_configurations(
if "timezone" in incoming_partial:
merged["timezone"] = incoming_partial["timezone"]
# Onboarding gate flags: overwrite only when supplied.
if "onboarding_completed_at" in incoming_partial:
merged["onboarding_completed_at"] = incoming_partial["onboarding_completed_at"]
if "onboarding_skipped" in incoming_partial:
merged["onboarding_skipped"] = incoming_partial["onboarding_skipped"]
return EffectiveAIModelConfiguration.model_validate(merged)

View file

@ -257,12 +257,12 @@ SPEACHES_PROVIDER_MODEL_CONFIG = provider_model_config(
)
AZURE_SPEECH_PROVIDER_MODEL_CONFIG = provider_model_config(
"Azure Speech Services",
description="Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.",
description="Azure Cognitive Services Speech TTS and STT via the Azure Speech SDK.",
provider_docs_url="https://learn.microsoft.com/en-us/azure/ai-services/speech-service/",
)
AZURE_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config(
"Azure OpenAI Realtime",
description="Azure OpenAI Realtime API - low-latency speech-to-speech conversations.",
description="Azure OpenAI Realtime API low-latency speech-to-speech conversations.",
provider_docs_url="https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/realtime-audio-quickstart",
)
@ -360,7 +360,7 @@ class GoogleVertexLLMConfiguration(BaseLLMConfiguration):
api_key: str | list[str] | None = Field(
default=None,
description=(
"Not used for Vertex AI - authentication is via the service account "
"Not used for Vertex AI authentication is via the service account "
"in `credentials` (or ADC). Leave blank."
),
)
@ -425,7 +425,7 @@ class AWSBedrockLLMConfiguration(BaseLLMConfiguration):
provider: Literal[ServiceProviders.AWS_BEDROCK] = ServiceProviders.AWS_BEDROCK
model: str = Field(
default="us.amazon.nova-pro-v1:0",
description="Bedrock model ID - include the region inference-profile prefix (e.g. 'us.').",
description="Bedrock model ID include the region inference-profile prefix (e.g. 'us.').",
json_schema_extra={"examples": AWS_BEDROCK_MODELS, "allow_custom_input": True},
)
aws_access_key: str = Field(
@ -442,7 +442,7 @@ class AWSBedrockLLMConfiguration(BaseLLMConfiguration):
)
api_key: str | list[str] | None = Field(
default=None,
description="Not used for Bedrock - authentication is via the AWS credentials above. Leave blank.",
description="Not used for Bedrock authentication is via the AWS credentials above. Leave blank.",
)
@ -682,7 +682,7 @@ class GoogleVertexRealtimeLLMConfiguration(BaseLLMConfiguration):
api_key: str | list[str] | None = Field(
default=None,
description=(
"Not used for Vertex AI - authentication is via the service account "
"Not used for Vertex AI authentication is via the service account "
"in `credentials` (or ADC). Leave blank."
),
)

View file

@ -0,0 +1,37 @@
from loguru import logger
from pydantic import ValidationError
from api.db import db_client
from api.enums import UserConfigurationKey
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
async def get_onboarding_state(user_id: int) -> OnboardingState:
value = await db_client.get_user_configuration_value(
user_id, UserConfigurationKey.ONBOARDING.value
)
return _parse_state(value, user_id)
async def update_onboarding_state(
user_id: int, update: OnboardingStateUpdate
) -> OnboardingState:
state = update.apply_to(await get_onboarding_state(user_id))
await db_client.upsert_user_configuration_value(
user_id,
UserConfigurationKey.ONBOARDING.value,
state.model_dump(mode="json", exclude_none=True),
)
return state
def _parse_state(value, user_id: int) -> OnboardingState:
if not value or not isinstance(value, dict):
return OnboardingState()
try:
return OnboardingState.model_validate(value)
except ValidationError as exc:
logger.warning(
f"Invalid onboarding state for user {user_id}: {exc}. Returning defaults."
)
return OnboardingState()

View file

@ -176,7 +176,7 @@ class _ToolDocumentRefsMixin(BaseModel):
@node_spec(
name="startCall",
display_name="Start Call",
description="Entry point of the workflow - plays a greeting and opens the conversation.",
description="Entry point of the workflow plays a greeting and opens the conversation.",
llm_hint=(
"The entry point of every workflow (exactly one required). Plays an "
"optional greeting, can fetch context from an external API before the "
@ -344,7 +344,7 @@ class StartCallNodeData(
@node_spec(
name="agentNode",
display_name="Agent Node",
description="Conversational step - the LLM runs one focused exchange.",
description="Conversational step the LLM runs one focused exchange.",
llm_hint=(
"Mid-call step executed by the LLM. Most workflows are a chain of agent "
"nodes connected by edges that describe transition conditions. Each agent "
@ -613,9 +613,9 @@ class GlobalNodeData(BaseNodeData, _PromptedNodeDataMixin):
"description": (
"Path segment that uniquely identifies "
"this trigger. Used in both URLs:\n"
" • Production: `/api/v1/public/agent/<trigger_path>` - executes "
" • Production: `/api/v1/public/agent/<trigger_path>` executes "
"the published agent.\n"
" • Test: `/api/v1/public/agent/test/<trigger_path>` - executes "
" • Test: `/api/v1/public/agent/test/<trigger_path>` executes "
"the latest draft.\n"
"Can be customized to a descriptive value up to 36 characters "
"using letters, numbers, hyphens, or underscores."
@ -708,7 +708,7 @@ class TriggerNodeData(BaseNodeData):
"display_name": "Payload Template",
"description": (
"JSON body of the request. Values are Jinja-rendered against the "
"run context - `{{workflow_run_id}}`, `{{gathered_context.foo}}`, "
"run context `{{workflow_run_id}}`, `{{gathered_context.foo}}`, "
"`{{annotations.qa_xxx}}`, etc."
),
"ui_type": PropertyType.json,

View file

@ -229,7 +229,7 @@ class WorkflowGraph:
kind=ItemKind.workflow,
id=None,
field=None,
message="Workflow has no start node - exactly one is required",
message="Workflow has no start node exactly one is required",
)
)
elif len(start_nodes) > 1:
@ -239,7 +239,7 @@ class WorkflowGraph:
id=None,
field=None,
message=(
f"Workflow has {len(start_nodes)} start nodes - "
f"Workflow has {len(start_nodes)} start nodes "
f"exactly one is required"
),
)

View file

@ -0,0 +1,131 @@
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.user import router
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
from api.services.auth.depends import get_user
def _make_test_app():
app = FastAPI()
app.include_router(router)
mock_user = MagicMock()
mock_user.id = 1
mock_user.is_superuser = False
mock_user.selected_organization_id = None
app.dependency_overrides[get_user] = lambda: mock_user
return app
class TestOnboardingStateUpdateMerge:
def test_lists_union_without_duplicates(self):
state = OnboardingState(
seen_tooltips=["web_call"], completed_actions=["web_call_started"]
)
update = OnboardingStateUpdate(
seen_tooltips=["web_call", "customize_workflow"],
completed_actions=["welcome_form_completed"],
)
merged = update.apply_to(state)
assert merged.seen_tooltips == ["web_call", "customize_workflow"]
assert merged.completed_actions == [
"web_call_started",
"welcome_form_completed",
]
def test_omitted_fields_preserve_existing_state(self):
completed_at = datetime(2026, 6, 12, tzinfo=UTC)
state = OnboardingState(
completed_at=completed_at, skipped=True, seen_tooltips=["web_call"]
)
merged = OnboardingStateUpdate().apply_to(state)
assert merged.completed_at == completed_at
assert merged.skipped is True
assert merged.seen_tooltips == ["web_call"]
def test_scalars_overwrite_when_supplied(self):
state = OnboardingState()
completed_at = datetime(2026, 6, 12, tzinfo=UTC)
merged = OnboardingStateUpdate(
completed_at=completed_at, skipped=True
).apply_to(state)
assert merged.completed_at == completed_at
assert merged.skipped is True
class TestOnboardingStateRoutes:
def test_get_returns_defaults_when_no_row(self):
app = _make_test_app()
client = TestClient(app)
with patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value=None),
):
response = client.get("/user/onboarding-state")
assert response.status_code == 200
body = response.json()
assert body["completed_at"] is None
assert body["skipped"] is False
assert body["seen_tooltips"] == []
assert body["completed_actions"] == []
def test_get_returns_defaults_on_invalid_stored_value(self):
app = _make_test_app()
client = TestClient(app)
with patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value={"skipped": "not-a-bool"}),
):
response = client.get("/user/onboarding-state")
assert response.status_code == 200
assert response.json()["skipped"] is False
def test_put_merges_into_stored_state_and_persists(self):
app = _make_test_app()
client = TestClient(app)
existing = {"seen_tooltips": ["web_call"]}
upsert = AsyncMock(side_effect=lambda user_id, key, value: value)
with (
patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value=existing),
),
patch(
"api.services.user_onboarding.db_client.upsert_user_configuration_value",
new=upsert,
),
):
response = client.put(
"/user/onboarding-state",
json={
"completed_at": "2026-06-12T00:00:00Z",
"seen_tooltips": ["customize_workflow"],
},
)
assert response.status_code == 200
body = response.json()
assert body["seen_tooltips"] == ["web_call", "customize_workflow"]
assert body["completed_at"] is not None
upsert.assert_awaited_once()
user_id, key, stored = upsert.await_args.args
assert user_id == 1
assert key == "ONBOARDING"
assert stored["seen_tooltips"] == ["web_call", "customize_workflow"]

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.mKgFDhNhca
# timestamp: 2026-06-09T10:10:10+00:00
# filename: dograh-openapi-XXXXXX.json.w5T4z8AeiH
# timestamp: 2026-06-12T16:16:24+00:00
from __future__ import annotations

View file

@ -55,6 +55,7 @@ export default async function Handler(props: unknown) {
}
const normalizedSegment = segment.toLowerCase().replace(/-/g, "");
const isAuthForm = segment !== "" && !FULL_PAGE_ROUTES.has(normalizedSegment);
const showBackButton = !new Set(["signin", "login"]).has(normalizedSegment);
const handler = (
<StackTheme theme={stackAuthDarkTheme}>
@ -65,7 +66,7 @@ export default async function Handler(props: unknown) {
if (isAuthForm) {
return (
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<BackButton />
{showBackButton && <BackButton />}
{handler}
</AuthShell>
);

View file

@ -1,8 +1,7 @@
// Dark token overrides for the embedded Stack Auth form so it blends into the
// auth card surface (zinc-900 background, zinc-100 foreground, the warm CTA
// accent on the primary button, zinc-800 borders/inputs). Kept in sync with the
// .dark tokens in globals.css. Values are CSS color strings; Stack applies them
// to its own CSS variables.
// accent on the primary button, zinc-800 borders/inputs). Stack's theme parser
// does not accept OKLCH strings, so keep these values in hex.
import type { StackTheme } from "@stackframe/stack";
import type { ComponentProps } from "react";
@ -11,25 +10,25 @@ type ThemeConfig = NonNullable<ComponentProps<typeof StackTheme>["theme"]>;
export const stackAuthDarkTheme: ThemeConfig = {
dark: {
background: "oklch(0.205 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
cardForeground: "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
popoverForeground: "oklch(0.985 0 0)",
primary: "oklch(0.78 0.16 67)",
primaryForeground: "oklch(0.16 0.02 60)",
secondary: "oklch(0.269 0 0)",
secondaryForeground: "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
mutedForeground: "oklch(0.708 0 0)",
accent: "oklch(0.269 0 0)",
accentForeground: "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
destructiveForeground: "oklch(0.985 0 0)",
border: "oklch(0.269 0 0)",
input: "oklch(0.269 0 0)",
ring: "oklch(0.78 0.16 67)",
background: "#27272a",
foreground: "#fafafa",
card: "#27272a",
cardForeground: "#fafafa",
popover: "#27272a",
popoverForeground: "#fafafa",
primary: "#fbbf24",
primaryForeground: "#422006",
secondary: "#3f3f46",
secondaryForeground: "#fafafa",
muted: "#3f3f46",
mutedForeground: "#a1a1aa",
accent: "#3f3f46",
accentForeground: "#fafafa",
destructive: "#ef4444",
destructiveForeground: "#fafafa",
border: "#3f3f46",
input: "#3f3f46",
ring: "#fbbf24",
},
radius: "0.625rem",
};

View file

@ -2,7 +2,6 @@ import { isNextRouterError } from "next/dist/client/components/is-next-router-er
import { redirect } from "next/navigation";
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
import SignInClient from "@/components/SignInClient";
import { getServerAccessToken,getServerAuthProvider,getServerUser } from "@/lib/auth/server";
import logger from '@/lib/logger';
import { getRedirectUrl } from "@/lib/utils";
@ -92,16 +91,6 @@ export default async function Home() {
}
}
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<SignInClient />
</div>
);
logger.debug('[HomePage] Redirecting unauthenticated Stack user to /handler/sign-in');
redirect('/handler/sign-in');
}

View file

@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet, listFoldersApiV1FolderGet } from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { Card, CardContent } from '@/components/ui/card';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { AgentFolderView } from '@/components/workflow/folders/AgentFolderView';
import { CreateFolderButton } from '@/components/workflow/folders/CreateFolderButton';
@ -78,9 +79,11 @@ async function WorkflowList() {
{activeWorkflows.length > 0 || folders.length > 0 ? (
<AgentFolderView workflows={activeWorkflows} folders={folders} />
) : (
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
</div>
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
No active workflows found. Create your first workflow to get started.
</CardContent>
</Card>
)}
</div>
@ -132,7 +135,11 @@ function WorkflowsLoading() {
<div className="h-8 w-48 bg-muted rounded mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-muted rounded-lg h-40"></div>
<Card key={i}>
<CardContent className="p-0">
<div className="h-40 bg-muted/70" />
</CardContent>
</Card>
))}
</div>
</div>
@ -143,7 +150,11 @@ function WorkflowsLoading() {
<div className="h-8 w-48 bg-muted rounded"></div>
<div className="h-10 w-32 bg-muted rounded"></div>
</div>
<div className="bg-muted rounded-lg h-96"></div>
<Card>
<CardContent className="p-0">
<div className="h-96 bg-muted/70" />
</CardContent>
</Card>
</div>
</div>
);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -147,13 +147,13 @@ export type AwsBedrockLlmConfiguration = {
/**
* Api Key
*
* Not used for Bedrock authentication is via the AWS credentials above. Leave blank.
* Not used for Bedrock - authentication is via the AWS credentials above. Leave blank.
*/
api_key?: string | Array<string> | null;
/**
* Model
*
* Bedrock model ID include the region inference-profile prefix (e.g. 'us.').
* Bedrock model ID - include the region inference-profile prefix (e.g. 'us.').
*/
model?: string;
/**
@ -344,7 +344,7 @@ export type AzureOpenAiEmbeddingsConfiguration = {
/**
* Azure OpenAI Realtime
*
* Azure OpenAI Realtime API low-latency speech-to-speech conversations.
* Azure OpenAI Realtime API - low-latency speech-to-speech conversations.
*/
export type AzureRealtimeLlmConfiguration = {
/**
@ -384,7 +384,7 @@ export type AzureRealtimeLlmConfiguration = {
/**
* Azure Speech Services
*
* Azure Cognitive Services Speech TTS and STT via the Azure Speech SDK.
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
*/
export type AzureSpeechSttConfiguration = {
/**
@ -418,7 +418,7 @@ export type AzureSpeechSttConfiguration = {
/**
* Azure Speech Services
*
* Azure Cognitive Services Speech TTS and STT via the Azure Speech SDK.
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
*/
export type AzureSpeechTtsConfiguration = {
/**
@ -2627,7 +2627,7 @@ export type GoogleVertexLlmConfiguration = {
/**
* Api Key
*
* Not used for Vertex AI authentication is via the service account in `credentials` (or ADC). Leave blank.
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
*/
api_key?: string | Array<string> | null;
/**
@ -2667,7 +2667,7 @@ export type GoogleVertexRealtimeLlmConfiguration = {
/**
* Api Key
*
* Not used for Vertex AI authentication is via the service account in `credentials` (or ADC). Leave blank.
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
*/
api_key?: string | Array<string> | null;
/**
@ -3537,6 +3537,61 @@ export type NodeTypesResponse = {
node_types: Array<NodeSpec>;
};
/**
* OnboardingState
*
* Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.
*
* Server-authoritative replacement for the browser-localStorage onboarding
* store, so the post-signup gate and one-time tooltips hold across devices.
*/
export type OnboardingState = {
/**
* Completed At
*/
completed_at?: string | null;
/**
* Skipped
*/
skipped?: boolean;
/**
* Seen Tooltips
*/
seen_tooltips?: Array<string>;
/**
* Completed Actions
*/
completed_actions?: Array<string>;
};
/**
* OnboardingStateUpdate
*
* Partial update merged into the stored state.
*
* Scalars overwrite when supplied; list entries are unioned into the stored
* lists, so concurrent updates (e.g. two tabs marking different tooltips)
* don't drop each other's items.
*/
export type OnboardingStateUpdate = {
/**
* Completed At
*/
completed_at?: string | null;
/**
* Skipped
*/
skipped?: boolean | null;
/**
* Seen Tooltips
*/
seen_tooltips?: Array<string> | null;
/**
* Completed Actions
*/
completed_actions?: Array<string> | null;
};
/**
* OpenAI
*/
@ -8563,6 +8618,84 @@ export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses = {
export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse = UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses[keyof UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses];
export type GetUserOnboardingStateApiV1UserOnboardingStateGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/user/onboarding-state';
};
export type GetUserOnboardingStateApiV1UserOnboardingStateGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetUserOnboardingStateApiV1UserOnboardingStateGetError = GetUserOnboardingStateApiV1UserOnboardingStateGetErrors[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetErrors];
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponses = {
/**
* Successful Response
*/
200: OnboardingState;
};
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponse = GetUserOnboardingStateApiV1UserOnboardingStateGetResponses[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetResponses];
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutData = {
body: OnboardingStateUpdate;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/user/onboarding-state';
};
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutError = UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors];
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses = {
/**
* Successful Response
*/
200: OnboardingState;
};
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponse = UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses];
export type ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData = {
body?: never;
headers?: {

View file

@ -11,6 +11,7 @@ import {
type ServiceSegment,
} from "@/components/ServiceConfigurationForm";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -359,79 +360,81 @@ export function AIModelConfigurationV2Editor({
</TabsContent>
<TabsContent value="dograh" className="mt-0">
<div className="rounded-lg border p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Voice</Label>
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="pt-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Voice</Label>
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Speed</Label>
<Select
value={String(dograh.speed)}
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select speed" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.speeds.map((speed) => (
<SelectItem key={speed} value={String(speed)}>
{speed}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Speed</Label>
<Select
value={String(dograh.speed)}
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select speed" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.speeds.map((speed) => (
<SelectItem key={speed} value={String(speed)}>
{speed}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dograh-api-key"
className="pl-9"
value={dograh.api_key}
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
placeholder="Enter API key"
/>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dograh-api-key"
className="pl-9"
value={dograh.api_key}
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
placeholder="Enter API key"
/>
</div>
</div>
</div>
</div>
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
<Save className="mr-2 h-4 w-4" />
{isSavingDograh ? "Saving..." : submitLabel}
</Button>
</div>
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
<Save className="mr-2 h-4 w-4" />
{isSavingDograh ? "Saving..." : submitLabel}
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="byok" className="mt-0">

View file

@ -148,12 +148,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
title: "Reports",
url: "/reports",
icon: FileText,
},
{
title: "Credits & Billing",
url: "/billing",
icon: CircleDollarSign,
},
}
],
},
];

View file

@ -34,8 +34,9 @@ import { type OnboardingAnswers, skipOnboarding, submitOnboarding } from "./subm
interface OnboardingModalProps {
open: boolean;
// Called after a tracked outcome (submit or skip) to dismiss the gate.
onComplete: () => void;
// Called after a tracked outcome (submit or skip) to dismiss the gate and
// stamp the matching server-side flag (completed_at vs skipped).
onComplete: (skipped: boolean) => void;
}
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
@ -88,7 +89,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
setSubmitting(true);
const data = answers();
const efSnapshot = withEnterprise ? { ...ef } : null;
onComplete();
onComplete(skipped);
void (async () => {
const token = await getAccessToken().catch(() => undefined);
try {

View file

@ -1,8 +1,8 @@
// Submission seam for the post-signup onboarding form.
// Fires a PostHog capture (submit or skip) AND, when a token is supplied, POSTs
// the answers to the separate user_onboarding service (best-effort). The "show
// once per user" flag itself is stamped on the Dograh user-config by the caller
// (LeadFormsContext.completeOnboarding), not here — that needs the saveUserConfig hook.
// once per user" flag itself is stamped on the server-backed onboarding state
// by the caller (LeadFormsContext.completeOnboarding → OnboardingContext), not here.
import posthog from "posthog-js";

View file

@ -19,6 +19,7 @@ import {
} from '@/client/sdk.gen';
import type { FolderResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
@ -126,132 +127,134 @@ export function WorkflowTable({
};
return (
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Agent Name</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold text-center">Total Runs</TableHead>
<TableHead className="font-semibold text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflows.map((workflow) => (
<TableRow
key={workflow.id}
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
>
<TableCell className="text-muted-foreground">
{workflow.id}
</TableCell>
<TableCell className="font-medium">
{workflow.name}
</TableCell>
<TableCell>
{new Date(workflow.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</TableCell>
<TableCell className="text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
{workflow.total_runs || 0}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(workflow.id)}
className="flex items-center gap-2"
>
<Pencil size={16} />
Edit
</Button>
{folders && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={movingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{movingWorkflowId === workflow.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<FolderInput size={16} />
)}
Move
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={currentFolderId === null}
onClick={() => handleMove(workflow.id, null)}
>
<Inbox size={14} className="mr-2" />
Uncategorized
{currentFolderId === null && (
<Check size={14} className="ml-auto" />
)}
</DropdownMenuItem>
{folders.map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onClick={() => handleMove(workflow.id, folder.id)}
<Card className="overflow-hidden">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Agent Name</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold text-center">Total Runs</TableHead>
<TableHead className="font-semibold text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflows.map((workflow) => (
<TableRow
key={workflow.id}
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
>
<TableCell className="text-muted-foreground">
{workflow.id}
</TableCell>
<TableCell className="font-medium">
{workflow.name}
</TableCell>
<TableCell>
{new Date(workflow.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</TableCell>
<TableCell className="text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
{workflow.total_runs || 0}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(workflow.id)}
className="flex items-center gap-2"
>
<Pencil size={16} />
Edit
</Button>
{folders && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={movingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
<FolderIcon size={14} className="mr-2" />
<span className="truncate">{folder.name}</span>
{folder.id === currentFolderId && (
<Check size={14} className="ml-auto shrink-0" />
{movingWorkflowId === workflow.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<FolderInput size={16} />
)}
Move
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={currentFolderId === null}
onClick={() => handleMove(workflow.id, null)}
>
<Inbox size={14} className="mr-2" />
Uncategorized
{currentFolderId === null && (
<Check size={14} className="ml-auto" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant={showArchived ? "default" : "outline"}
size="sm"
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
disabled={loadingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{loadingWorkflowId === workflow.id ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{showArchived ? 'Restoring...' : 'Archiving...'}
</>
) : (
<>
{showArchived ? (
<>
<RotateCcw size={16} />
Restore
</>
) : (
<>
<Archive size={16} />
Archive
</>
)}
</>
{folders.map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onClick={() => handleMove(workflow.id, folder.id)}
>
<FolderIcon size={14} className="mr-2" />
<span className="truncate">{folder.name}</span>
{folder.id === currentFolderId && (
<Check size={14} className="ml-auto shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Button
variant={showArchived ? "default" : "outline"}
size="sm"
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
disabled={loadingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{loadingWorkflowId === workflow.id ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{showArchived ? 'Restoring...' : 'Archiving...'}
</>
) : (
<>
{showArchived ? (
<>
<RotateCcw size={16} />
Restore
</>
) : (
<>
<Archive size={16} />
Archive
</>
)}
</>
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View file

@ -10,15 +10,7 @@ import type { LeadSource } from "@/components/lead-forms/leadFieldOptions";
import { OnboardingModal } from "@/components/lead-forms/OnboardingModal";
import { PostHogEvent } from "@/constants/posthog-events";
import { useOnboarding } from "@/context/OnboardingContext";
import { useUserConfig } from "@/context/UserConfigContext";
// The onboarding flag fields live on the Dograh user-config JSON blob. The
// generated client type may not include them until `npm run generate-client`
// is re-run against the updated backend, so read them through this shape.
type OnboardingFlags = {
onboarding_completed_at?: string | null;
onboarding_skipped?: boolean | null;
};
import { useAuth } from "@/lib/auth";
interface LeadFormsContextValue {
openHireExpert: (source: LeadSource) => void;
@ -40,32 +32,32 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
// ---- Post-signup onboarding gate ----
// Show the onboarding form ONCE per user, and ONLY to genuinely new users:
// (a) the completion flag is unset (server-side, cross-device), AND
// (a) the completion/skip flag is unset (server-backed onboarding state,
// cross-device), AND
// (b) the user has zero workflows (grandfathers out all existing users —
// they already have workflows, so they never see this modal).
const { userConfig, loading: userConfigLoading, user, saveUserConfig } = useUserConfig();
// Same-browser "show once" backstop, shared with the rest of onboarding
// (tooltips/actions) via OnboardingProvider. Complements the server-side flag
// so an instant reload before the async save round-trips can't re-show the gate.
const { hasCompletedAction, markActionCompleted } = useOnboarding();
const { user, loading: authLoading } = useAuth();
const {
loading: onboardingLoading,
onboardingCompletedAt,
onboardingSkipped,
markOnboardingCompleted,
} = useOnboarding();
const [onboardingOpen, setOnboardingOpen] = useState(false);
// Guard so the one-time workflow-count check runs at most once per mount.
const onboardingCheckedRef = useRef(false);
// Live view of the gate for the post-await re-check below.
const onboardingDoneRef = useRef(false);
onboardingDoneRef.current = Boolean(onboardingCompletedAt) || onboardingSkipped;
useEffect(() => {
if (userConfigLoading || !user || onboardingCheckedRef.current) return;
const flags = userConfig as OnboardingFlags | null;
const completed =
hasCompletedAction("welcome_form_completed") ||
Boolean(flags?.onboarding_completed_at) ||
Boolean(flags?.onboarding_skipped);
if (completed) {
onboardingCheckedRef.current = true; // already done — never show
if (authLoading || onboardingLoading || !user || onboardingCheckedRef.current) {
return;
}
onboardingCheckedRef.current = true;
if (onboardingDoneRef.current) return; // already done — never show
// Only brand-new users (no workflows yet) see the form. The count is
// org-scoped (the user's selected organization), so a new user joining an
// org that already has workflows is correctly grandfathered out. This costs
@ -74,12 +66,9 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
(async () => {
try {
const res = await getWorkflowCountApiV1WorkflowCountGet();
// Re-read the flag after the await: a config save elsewhere may have
// stamped completion while the count was in flight.
const latest = userConfig as OnboardingFlags | null;
const stillPending =
!latest?.onboarding_completed_at && !latest?.onboarding_skipped;
if (res.data?.total === 0 && stillPending) {
// Re-check the flag after the await: a completion elsewhere (another
// tab) may have stamped it while the count was in flight.
if (res.data?.total === 0 && !onboardingDoneRef.current) {
setOnboardingOpen(true);
posthog.capture(PostHogEvent.ONBOARDING_SHOWN);
}
@ -88,24 +77,15 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
// existing users are never disrupted.
}
})();
}, [userConfigLoading, user, userConfig, hasCompletedAction]);
}, [authLoading, onboardingLoading, user]);
const completeOnboarding = useCallback(() => {
// Dismiss immediately. Mark the same-browser backstop synchronously via
// OnboardingProvider (same store as the one-time tooltips/actions) so an
// instant reload can't re-show the gate, then best-effort persist the server
// flag (cross-device source of truth). saveUserConfig merges with the existing
// config, so only the new field is needed.
const completeOnboarding = useCallback((skipped: boolean) => {
// Dismiss immediately, then persist the flag through OnboardingContext
// (optimistic local state closes the gate even if the server write lags;
// the write itself is best-effort and cross-device).
setOnboardingOpen(false);
markActionCompleted("welcome_form_completed");
void saveUserConfig({
onboarding_completed_at: new Date().toISOString(),
} as Parameters<typeof saveUserConfig>[0]).catch(() => {
// The local backstop already prevents a same-browser re-prompt; a failed
// server stamp only risks a re-prompt on another device.
console.error("[onboarding] failed to persist completion flag to user-config");
});
}, [saveUserConfig, markActionCompleted]);
markOnboardingCompleted({ skipped });
}, [markOnboardingCompleted]);
const openHireExpert = useCallback((source: LeadSource) => {
hasOpenedHireRef.current = true;

View file

@ -1,95 +1,168 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import {
getUserOnboardingStateApiV1UserOnboardingStateGet,
updateUserOnboardingStateApiV1UserOnboardingStatePut,
} from '@/client/sdk.gen';
import type { OnboardingStateUpdate } from '@/client/types.gen';
import { useAuth } from '@/lib/auth';
export type TooltipKey = 'web_call' | 'customize_workflow';
export type OnboardingActionKey = 'web_call_started' | 'welcome_form_completed';
export type OnboardingActionKey = 'web_call_started';
// Server-backed onboarding state (GET/PUT /user/onboarding-state), stored
// per-user under the ONBOARDING user-configuration key — deliberately
// independent of the AI model configuration. Replaces the old
// localStorage-only store so one-time UI (post-signup gate, tooltips,
// milestone actions) holds across devices and browsers.
interface OnboardingState {
seenTooltips: TooltipKey[];
completedActions: OnboardingActionKey[];
completed_at: string | null;
skipped: boolean;
seen_tooltips: string[];
completed_actions: string[];
}
interface OnboardingContextType {
// True until the server state has been fetched. While loading, the
// has* checks report "already seen/done" so one-time UI never flashes
// for users who have in fact seen it.
loading: boolean;
// Post-signup onboarding form gate (set once on submit/skip).
onboardingCompletedAt: string | null;
onboardingSkipped: boolean;
markOnboardingCompleted: (opts?: { skipped?: boolean }) => void;
hasSeenTooltip: (key: TooltipKey) => boolean;
markTooltipSeen: (key: TooltipKey) => void;
hasCompletedAction: (key: OnboardingActionKey) => boolean;
markActionCompleted: (key: OnboardingActionKey) => void;
resetOnboarding: () => void;
}
const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
const defaultState: OnboardingState = {
seenTooltips: [],
completedActions: [],
completed_at: null,
skipped: false,
seen_tooltips: [],
completed_actions: [],
};
const union = (a: string[], b: string[] | null | undefined) =>
[...a, ...(b ?? []).filter((item) => !a.includes(item))];
// Merge a server response into local state monotonically: flags only ever
// advance, so a response that raced a newer optimistic mark can't revert it.
const absorb = (prev: OnboardingState, server: Partial<OnboardingState>): OnboardingState => ({
completed_at: prev.completed_at ?? server.completed_at ?? null,
skipped: prev.skipped || Boolean(server.skipped),
seen_tooltips: union(prev.seen_tooltips, server.seen_tooltips),
completed_actions: union(prev.completed_actions, server.completed_actions),
});
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
export const OnboardingProvider = ({ children }: { children: React.ReactNode }) => {
const [onboardingState, setOnboardingState] = useState<OnboardingState>(() => {
// Initialize state from localStorage on first render
if (typeof window !== 'undefined') {
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
if (savedState) {
try {
const parsed = JSON.parse(savedState);
return { ...defaultState, ...parsed };
} catch (error) {
console.error('Failed to parse onboarding state:', error);
}
}
}
return defaultState;
});
const [state, setState] = useState<OnboardingState>(defaultState);
const [loaded, setLoaded] = useState(false);
const auth = useAuth();
const authRef = useRef(auth);
authRef.current = auth;
const hasFetched = useRef(false);
// Save state to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
if (auth.loading || hasFetched.current) return;
if (!auth.isAuthenticated) {
// Unauthenticated pages (login/signup) have no onboarding state;
// unblock consumers with defaults.
setLoaded(true);
return;
}
}, [onboardingState]);
hasFetched.current = true;
const hasSeenTooltip = (key: TooltipKey): boolean => {
return onboardingState.seenTooltips.includes(key);
};
(async () => {
const res = await getUserOnboardingStateApiV1UserOnboardingStateGet().catch(() => null);
if (res?.data) {
const data = res.data as Partial<OnboardingState>;
setState((prev) => absorb(prev, data));
setLoaded(true);
} else {
// Fetch failed: stay in loading so one-time UI stays suppressed
// (fail closed — never re-show onboarding to an onboarded user).
console.error('[onboarding] failed to fetch onboarding state', res?.error);
}
})();
}, [auth.loading, auth.isAuthenticated]);
const markTooltipSeen = (key: TooltipKey) => {
setOnboardingState(prev => ({
// Best-effort server write. Only the delta is sent; the server unions list
// fields into the stored state, so concurrent tabs don't drop each other's
// updates. The response is the merged state — use it to reconcile.
const persist = useCallback((update: OnboardingStateUpdate) => {
if (!authRef.current.isAuthenticated) return;
void updateUserOnboardingStateApiV1UserOnboardingStatePut({ body: update })
.then((res) => {
if (res.error) {
console.error('[onboarding] failed to persist onboarding state', res.error);
} else if (res.data) {
const data = res.data as Partial<OnboardingState>;
setState((prev) => absorb(prev, data));
}
})
.catch(() => {
console.error('[onboarding] failed to persist onboarding state');
});
}, []);
const markOnboardingCompleted = useCallback((opts?: { skipped?: boolean }) => {
const skipped = opts?.skipped ?? false;
const completedAt = new Date().toISOString();
// Optimistic: the gate must close immediately and never re-open.
setState((prev) => ({
...prev,
seenTooltips: prev.seenTooltips.includes(key)
? prev.seenTooltips
: [...prev.seenTooltips, key]
skipped: prev.skipped || skipped,
completed_at: prev.completed_at ?? (skipped ? null : completedAt),
}));
};
persist(skipped ? { skipped: true } : { completed_at: completedAt });
}, [persist]);
const hasCompletedAction = (key: OnboardingActionKey): boolean => {
return onboardingState.completedActions.includes(key);
};
const hasSeenTooltip = useCallback(
(key: TooltipKey) => !loaded || state.seen_tooltips.includes(key),
[loaded, state.seen_tooltips],
);
const markActionCompleted = (key: OnboardingActionKey) => {
setOnboardingState(prev => ({
...prev,
completedActions: prev.completedActions.includes(key)
? prev.completedActions
: [...prev.completedActions, key]
}));
};
const markTooltipSeen = useCallback((key: TooltipKey) => {
setState((prev) =>
prev.seen_tooltips.includes(key)
? prev
: { ...prev, seen_tooltips: [...prev.seen_tooltips, key] }
);
persist({ seen_tooltips: [key] });
}, [persist]);
const resetOnboarding = () => {
setOnboardingState(defaultState);
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
};
const hasCompletedAction = useCallback(
(key: OnboardingActionKey) => !loaded || state.completed_actions.includes(key),
[loaded, state.completed_actions],
);
const markActionCompleted = useCallback((key: OnboardingActionKey) => {
setState((prev) =>
prev.completed_actions.includes(key)
? prev
: { ...prev, completed_actions: [...prev.completed_actions, key] }
);
persist({ completed_actions: [key] });
}, [persist]);
return (
<OnboardingContext.Provider
value={{
loading: !loaded,
onboardingCompletedAt: state.completed_at,
onboardingSkipped: state.skipped,
markOnboardingCompleted,
hasSeenTooltip,
markTooltipSeen,
hasCompletedAction,
markActionCompleted,
resetOnboarding
}}
>
{children}