mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat: refactor user configuration table
This commit is contained in:
parent
03daaba7a1
commit
e5cc1308ed
31 changed files with 932 additions and 419 deletions
|
|
@ -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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
47
api/schemas/onboarding_state.py
Normal file
47
api/schemas/onboarding_state.py
Normal 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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
),
|
||||
)
|
||||
|
|
|
|||
37
api/services/user_onboarding.py
Normal file
37
api/services/user_onboarding.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
)
|
||||
|
|
|
|||
131
api/tests/test_onboarding_state.py
Normal file
131
api/tests/test_onboarding_state.py
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -148,12 +148,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
|
|||
title: "Reports",
|
||||
url: "/reports",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Credits & Billing",
|
||||
url: "/billing",
|
||||
icon: CircleDollarSign,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue