diff --git a/api/controllers/console/notification.py b/api/controllers/console/notification.py
index 3e58f598bf7..080080bb361 100644
--- a/api/controllers/console/notification.py
+++ b/api/controllers/console/notification.py
@@ -1,56 +1,16 @@
-from collections.abc import Mapping
-from typing import TypedDict
-
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
-from controllers.console.wraps import (
- account_initialization_required,
- model_validate,
- only_edition_cloud,
- setup_required,
- with_current_user,
-)
+from controllers.console.flask_admission import console_account_admission
+from controllers.console.wraps import model_validate
+from enums import DeploymentEdition
+from extensions.ext_application_services import application_services
from fields.base import ResponseModel
-from libs.login import login_required
-from models import Account
-from services.billing_service import BillingService
-
-# Notification content is stored under three lang tags.
-_FALLBACK_LANG = "en-US"
-
-
-class NotificationLangContent(TypedDict, total=False):
- lang: str
- title: str
- subtitle: str
- body: str
- titlePicUrl: str
-
-
-class NotificationItemDict(TypedDict):
- notification_id: str | None
- frequency: str | None
- lang: str
- title: str
- subtitle: str
- body: str
- title_pic_url: str
-
-
-class NotificationResponseDict(TypedDict):
- should_show: bool
- notifications: list[NotificationItemDict]
-
-
-def _pick_lang_content(contents: Mapping[str, NotificationLangContent], lang: str) -> NotificationLangContent:
- """Return the single LangContent for *lang*, falling back to English."""
- return (
- contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), NotificationLangContent())
- )
+from libs.helper import dump_response
+from machinery.context import RequestContext
class DismissNotificationPayload(BaseModel):
@@ -92,39 +52,10 @@ class NotificationApi(Resource):
},
)
@console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__])
- @setup_required
- @login_required
- @with_current_user
- @account_initialization_required
- @only_edition_cloud
- def get(self, current_user: Account):
- result = BillingService.get_account_notification(str(current_user.id))
-
- # Proto JSON uses camelCase field names (Kratos default marshaling).
- response: NotificationResponseDict
- if not result.get("shouldShow"):
- response = {"should_show": False, "notifications": []}
- return response, 200
-
- lang = current_user.interface_language or _FALLBACK_LANG
-
- notifications: list[NotificationItemDict] = []
- for notification in result.get("notifications") or []:
- contents: Mapping[str, NotificationLangContent] = notification.get("contents") or {}
- lang_content = _pick_lang_content(contents, lang)
- item: NotificationItemDict = {
- "notification_id": notification.get("notificationId"),
- "frequency": notification.get("frequency"),
- "lang": lang_content.get("lang", lang),
- "title": lang_content.get("title", ""),
- "subtitle": lang_content.get("subtitle", ""),
- "body": lang_content.get("body", ""),
- "title_pic_url": lang_content.get("titlePicUrl", ""),
- }
- notifications.append(item)
-
- response = {"should_show": bool(notifications), "notifications": notifications}
- return response, 200
+ @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
+ def get(self, request_context: RequestContext):
+ result = application_services().notifications.get_active(request_context)
+ return dump_response(NotificationResponse, result), 200
@console_ns.route("/notification/dismiss")
@@ -134,17 +65,10 @@ class NotificationDismissApi(Resource):
description="Mark a notification as dismissed for the current user.",
responses={200: "Success", 401: "Unauthorized"},
)
- @setup_required
- @login_required
- @with_current_user
- @account_initialization_required
- @only_edition_cloud
+ @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
@console_ns.expect(console_ns.models[DismissNotificationPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@model_validate(DismissNotificationPayload)
- def post(self, payload: DismissNotificationPayload, current_user: Account):
- BillingService.dismiss_notification(
- notification_id=payload.notification_id,
- account_id=str(current_user.id),
- )
- return {"result": "success"}, 200
+ def post(self, payload: DismissNotificationPayload, request_context: RequestContext):
+ application_services().notifications.dismiss(request_context, payload.notification_id)
+ return dump_response(SimpleResultResponse, {"result": "success"}), 200
diff --git a/api/controllers/console/onboarding.py b/api/controllers/console/onboarding.py
index f26e2d539e4..cbd77752e7b 100644
--- a/api/controllers/console/onboarding.py
+++ b/api/controllers/console/onboarding.py
@@ -7,36 +7,20 @@ action-based so callers do not replace server-side arrays with stale snapshots.
"""
from datetime import datetime
-from typing import Literal, cast
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, model_validator
from controllers.common.schema import register_response_schema_models, register_schema_models
-from extensions.ext_database import db
+from controllers.console.flask_admission import console_account_admission
+from controllers.console.wraps import model_validate
+from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response
-from libs.login import login_required
-from models import Account
-from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
+from machinery.context import RequestContext
+from services.entities.onboarding_entities import StepByStepTourAction, StepByStepTourPatch, StepByStepTourTaskId
from . import console_ns
-from .wraps import (
- account_initialization_required,
- model_validate,
- setup_required,
- with_current_tenant_id,
- with_current_user,
-)
-
-StepByStepTourAction = Literal[
- "skip",
- "complete_task",
- "uncomplete_task",
- "enable_current_workspace",
- "disable_current_workspace",
-]
-StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
class StepByStepTourStatePatchPayload(BaseModel):
@@ -74,39 +58,22 @@ class StepByStepTourStateApi(Resource):
@console_ns.doc("get_step_by_step_tour_state")
@console_ns.doc(description="Get account-level Step-by-step Tour state")
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_current_tenant_id
- def get(self, current_tenant_id: str, current_user: Account):
+ @console_account_admission()
+ def get(self, request_context: RequestContext):
return dump_response(
StepByStepTourStateResponse,
- StepByStepTourService.get_state(
- account=current_user,
- current_tenant_id=current_tenant_id,
- session=db.session,
- ),
+ application_services().step_by_step_tour.get_state(request_context),
)
@console_ns.doc("patch_step_by_step_tour_state")
@console_ns.doc(description="Update account-level Step-by-step Tour state")
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
- @setup_required
- @login_required
- @account_initialization_required
- @with_current_user
- @with_current_tenant_id
+ @console_account_admission()
@model_validate(StepByStepTourStatePatchPayload)
- def patch(self, req_data: StepByStepTourStatePatchPayload, current_tenant_id: str, current_user: Account):
- patch = cast(StepByStepTourPatch, req_data.model_dump(exclude_unset=True, exclude_none=True))
+ def patch(self, req_data: StepByStepTourStatePatchPayload, request_context: RequestContext):
+ patch = StepByStepTourPatch(action=req_data.action, task_id=req_data.task_id)
return dump_response(
StepByStepTourStateResponse,
- StepByStepTourService.patch_state(
- account=current_user,
- current_tenant_id=current_tenant_id,
- patch=patch,
- session=db.session,
- ),
+ application_services().step_by_step_tour.patch_state(request_context, patch),
)
diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py
index 991a487c107..a9451c52778 100644
--- a/api/dev/generate_swagger_markdown_docs.py
+++ b/api/dev/generate_swagger_markdown_docs.py
@@ -76,6 +76,10 @@ def _schema_markdown_type(schema: object) -> str:
item_type = _schema_markdown_type(schema.get("items"))
return f"[ {item_type or 'object'} ]"
if isinstance(schema_type, str):
+ enum_values = schema.get("enum")
+ if isinstance(enum_values, list) and enum_values:
+ rendered_values = ", ".join(json.dumps(value, ensure_ascii=False) for value in enum_values)
+ return f"{schema_type},
**Available values:** {rendered_values}"
return schema_type
return ""
diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py
index 7a482248cce..747c658a561 100644
--- a/api/extensions/ext_application_services.py
+++ b/api/extensions/ext_application_services.py
@@ -31,6 +31,7 @@ from repositories.factory import DifyAPIRepositoryFactory
from repositories.installation_state_repository import InstallationStateRepository
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
+from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourStateRepository
from repositories.tag_repository import TagRepository
from repositories.trial_app_query_repository import TrialAppQueryRepository
from repositories.trial_app_usage_repository import TrialAppUsageRepository
@@ -104,6 +105,8 @@ from services.feature_service import FeatureService
from services.feature_service_gateway import FeatureServiceGateway
from services.file_service import FileService
from services.init_validation_service import InitValidationService
+from services.notification_gateway import BillingNotificationGateway
+from services.notification_service import NotificationService
from services.notion_data_source_gateway import NotionDataSourceGateway
from services.oauth_server_service import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthServerService
from services.partner_tenant_binding_service import PartnerTenantBindingService
@@ -122,6 +125,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi
from services.schema_definition_service import SchemaDefinitionService
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
from services.setup_service import SetupService
+from services.step_by_step_tour_service import StepByStepTourService
from services.tag_application_service import TagApplicationService
from services.trial_app_usage import TrialAppUsageRecorder
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
@@ -188,6 +192,8 @@ class ApplicationServices:
feature_queries: FeatureQueryService
oauth_server: OAuthServerService
init_validation: InitValidationService
+ notifications: NotificationService
+ step_by_step_tour: StepByStepTourService
partner_tenant_bindings: PartnerTenantBindingService
recommended_app_queries: RecommendedAppQueryService
trial_app_usage: TrialAppUsageRecorder
@@ -434,6 +440,16 @@ def build_application_services(
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
expected_password=initialization_password,
),
+ notifications=NotificationService(
+ accounts=accounts,
+ notifications=BillingNotificationGateway(),
+ ),
+ step_by_step_tour=StepByStepTourService(
+ accounts=accounts,
+ states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client),
+ enabled=dify_config.ENABLE_STEP_BY_STEP_TOUR,
+ rollout_started_at=dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT,
+ ),
partner_tenant_bindings=PartnerTenantBindingService(
sync_bindings=BillingService.sync_partner_tenants_bindings,
),
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index b8008497e43..c143fd19243 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -13501,7 +13501,7 @@ default (the config form sends the full desired feature state on save).
| mode | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow",
**Default:** all | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
| name | string | Filter by app name | No |
| page | integer,
**Default:** 1 | Page number (1-99999) | No |
-| publication_status | string | Filter by published or draft Agent configuration status | No |
+| publication_status | string,
**Available values:** "drafts", "published" | Filter by published or draft Agent configuration status | No |
| sort_by | string,
**Available values:** "earliest_created", "last_modified", "recently_created",
**Default:** last_modified | Sort apps by last modified, recently created, or earliest created
*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No |
| tag_ids | [ string ] | Filter by tag IDs | No |
@@ -15744,7 +15744,7 @@ AppMCPServer Status Enum
| copyright | string | | No |
| custom_disclaimer | string | | No |
| customize_domain | string | | No |
-| customize_token_strategy | string | | No |
+| customize_token_strategy | string,
**Available values:** "allow", "must", "not_allow" | | No |
| default_language | string | | No |
| description | string | | No |
| icon | string | | No |
@@ -16202,7 +16202,7 @@ TEAM: Team collaboration paid plan
| files | [ object ] | | No |
| inputs | object | | Yes |
| query | string | | No |
-| response_mode | string | | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | | No |
| retriever_from | string,
**Default:** explore_app | | No |
#### CompletionMessagePayload
@@ -16223,7 +16223,7 @@ TEAM: Team collaboration paid plan
| files | [ object ] | | No |
| inputs | object | | Yes |
| query | string | | No |
-| response_mode | string | | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | | No |
| retriever_from | string,
**Default:** explore_app | | No |
#### ComplianceDownloadQuery
@@ -18263,9 +18263,9 @@ Flask blueprint initialization.
| ---- | ---- | ----------- | -------- |
| end_date | string | End date (YYYY-MM-DD) | No |
| format | string,
**Available values:** "csv", "json",
**Default:** csv | Export format
*Enum:* `"csv"`, `"json"` | No |
-| from_source | string | Filter by feedback source | No |
+| from_source | string,
**Available values:** "admin", "user" | Filter by feedback source | No |
| has_comment | boolean | Only include feedback with comments | No |
-| rating | string | Filter by rating | No |
+| rating | string,
**Available values:** "dislike", "like" | Filter by rating | No |
| start_date | string | Start date (YYYY-MM-DD) | No |
#### FeedbackStat
@@ -18663,7 +18663,7 @@ Icon information model.
| ---- | ---- | ----------- | -------- |
| icon | string | | No |
| icon_background | string | | No |
-| icon_type | string | | No |
+| icon_type | string,
**Available values:** "emoji", "image" | | No |
| icon_url | string | | No |
#### IconType
@@ -19245,7 +19245,7 @@ Enum class for large language model mode.
| ---- | ---- | ----------- | -------- |
| content | string | Optional text feedback providing additional detail. | No |
| message_id | string | Message ID | Yes |
-| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
+| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
#### MessageFile
@@ -19306,7 +19306,7 @@ Metadata Filtering Condition.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
-| logical_operator | string | How to combine multiple conditions. | No |
+| logical_operator | string,
**Available values:** "and", "or" | How to combine multiple conditions. | No |
#### MetadataOperationData
@@ -19439,7 +19439,7 @@ Enum class for model property key.
| is_exhausted | boolean | | Yes |
| is_unlimited | boolean | | Yes |
| next_credit_reset_date | integer | | Yes |
-| pool_type | string | | Yes |
+| pool_type | string,
**Available values:** "paid", "trial" | | Yes |
| quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes |
| quota_used | integer | | Yes |
| remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes |
@@ -21434,7 +21434,7 @@ Model class for provider quota configuration.
| ---- | ---- | ----------- | -------- |
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
-| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
+| reranking_mode | string,
**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
@@ -21488,7 +21488,7 @@ Model class for provider quota configuration.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| parent_mode | string | Parent-child segmentation mode. | No |
+| parent_mode | string,
**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
@@ -22477,7 +22477,7 @@ Query parameters for listing snippet published workflows.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| action | string,
**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action
*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes |
-| task_id | string | Task ID for task actions | No |
+| task_id | string,
**Available values:** "home", "integration", "knowledge", "studio" | Task ID for task actions | No |
#### StepByStepTourStateResponse
@@ -22943,7 +22943,7 @@ Tool label
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| visibility | string | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
+| visibility | string,
**Available values:** "all_team_members", "only_me" | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
#### ToolOAuthCustomClientPayload
@@ -23075,7 +23075,7 @@ removes TOOLS_SELECTOR from PluginParameterType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| type | string | | No |
+| type | string,
**Available values:** "api", "builtin", "mcp", "model", "workflow" | | No |
#### ToolProviderListResponse
@@ -23693,7 +23693,7 @@ in form definition, or a variable while the workflow is running.
| ---- | ---- | ----------- | -------- |
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
-| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
+| weight_type | string,
**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
#### WeightVectorSetting
@@ -24199,7 +24199,7 @@ can reuse its existing handler.
| description | string | | No |
| event | string | | No |
| icon | string | | No |
-| mode | string | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
+| mode | string,
**Available values:** "advanced-chat", "workflow" | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
| nodes | [ [WorkflowPlanNodeResponse](#workflowplannoderesponse) ] | | Yes |
| start_inputs | [ [WorkflowPlanStartInputResponse](#workflowplanstartinputresponse) ] | | No |
| title | string | | No |
@@ -24214,7 +24214,7 @@ can reuse its existing handler.
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
| icon | string | | No |
| message | string | | No |
-| mode | string | | No |
+| mode | string,
**Available values:** "advanced-chat", "workflow" | | No |
#### WorkflowGenerateResultEventResponse
@@ -24227,7 +24227,7 @@ can reuse its existing handler.
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
| icon | string | | No |
| message | string | | No |
-| mode | string | | No |
+| mode | string,
**Available values:** "advanced-chat", "workflow" | | No |
#### WorkflowGenerateStreamEventResponse
@@ -24527,9 +24527,9 @@ Lifecycle state for an asynchronous archive download request.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| status | string | Workflow run status filter | No |
+| status | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
| time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No |
-| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
+| triggered_from | string,
**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
#### WorkflowRunCountResponse
@@ -24601,8 +24601,8 @@ Lifecycle state for an asynchronous archive download request.
| ---- | ---- | ----------- | -------- |
| last_id | string | Last run ID for pagination | No |
| limit | integer,
**Default:** 20 | Number of items per page (1-100) | No |
-| status | string | Workflow run status filter | No |
-| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
+| status | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
+| triggered_from | string,
**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
#### WorkflowRunNodeExecutionListResponse
@@ -24900,7 +24900,7 @@ Workflow tool configuration
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| language | string | Localized policy label language | No |
+| language | string,
**Available values:** "en", "ja", "zh" | Localized policy label language | No |
#### _AccessPolicyList
@@ -24959,7 +24959,7 @@ Workflow tool configuration
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| language | string | Localized policy label language | No |
+| language | string,
**Available values:** "en", "ja", "zh" | Localized policy label language | No |
| limit | integer | | No |
| page | integer | | No |
| reverse | boolean | | No |
diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md
index a09bd57e255..80b5ab94db8 100644
--- a/api/openapi/markdown/service-openapi.md
+++ b/api/openapi/markdown/service-openapi.md
@@ -2587,7 +2587,7 @@ Public pause reason emitted by a blocking Chatflow execution.
| files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
| query | string | User input or question content. | Yes |
-| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
#### ChatRequestPayloadWithUser
@@ -2599,7 +2599,7 @@ Public pause reason emitted by a blocking Chatflow execution.
| files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
| query | string | User input or question content. | Yes |
-| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
@@ -2672,7 +2672,7 @@ Public pause reason emitted by a blocking Chatflow execution.
| files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
| query | string | User input or prompt content. | No |
-| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
#### CompletionRequestPayloadWithUser
@@ -2681,7 +2681,7 @@ Public pause reason emitted by a blocking Chatflow execution.
| files | [ object
object
object
object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
| query | string | User input or prompt content. | No |
-| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
#### Condition
@@ -2797,7 +2797,7 @@ Enum class for custom configuration status.
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
| external_knowledge_id | string | ID of the external knowledge base. | No |
-| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
+| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
| name | string | Name of the knowledge base. | Yes |
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
| provider | string,
**Available values:** "external", "vendor",
**Default:** vendor | Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.
*Enum:* `"external"`, `"vendor"` | No |
@@ -3039,7 +3039,7 @@ Enum class for custom configuration status.
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
| external_knowledge_id | string | ID of the external knowledge base. | No |
| external_retrieval_model | object | Retrieval settings for external knowledge bases. | No |
-| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
+| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
| name | string | Name of the knowledge base. | No |
| partial_member_list | [ object ] | List of team members with access when `permission` is `partial_members`. | No |
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
@@ -3167,7 +3167,7 @@ Request payload for bulk downloading documents as a zip archive.
| keyword | string | Search keyword to filter by document name. | No |
| limit | integer,
**Default:** 20 | Number of items per page. Server caps at `100`. | No |
| page | integer,
**Default:** 1 | Page number to retrieve. | No |
-| status | string | Filter by display status. | No |
+| status | string,
**Available values:** "archived", "available", "disabled", "error", "indexing", "paused", "queuing" | Filter by display status. | No |
#### DocumentListResponse
@@ -3265,7 +3265,7 @@ Request payload for bulk downloading documents as a zip archive.
| doc_language | string,
**Default:** English | Language of the document for processing optimization. | No |
| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
-| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
+| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
| name | string | Document name. | Yes |
| original_document_id | string | Original document ID for replacement. | No |
| process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No |
@@ -3614,14 +3614,14 @@ Model class for i18n object.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | Optional text feedback providing additional detail. | No |
-| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
+| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
#### MessageFeedbackPayloadWithUser
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | Optional text feedback providing additional detail. | No |
-| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
+| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
#### MessageFile
@@ -3701,7 +3701,7 @@ Metadata Filtering Condition.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
-| logical_operator | string | How to combine multiple conditions. | No |
+| logical_operator | string,
**Available values:** "and", "or" | How to combine multiple conditions. | No |
#### MetadataOperationData
@@ -3935,7 +3935,7 @@ Model class for provider with models response.
| ---- | ---- | ----------- | -------- |
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
-| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
+| reranking_mode | string,
**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
@@ -3969,7 +3969,7 @@ Model class for provider with models response.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| parent_mode | string | Parent-child segmentation mode. | No |
+| parent_mode | string,
**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
@@ -4300,7 +4300,7 @@ in form definition, or a variable while the workflow is running.
| ---- | ---- | ----------- | -------- |
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
-| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
+| weight_type | string,
**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
#### WeightVectorSetting
@@ -4383,7 +4383,7 @@ Blocking workflow response for a finished or paused execution.
| keyword | string | Keyword to search in logs. | No |
| limit | integer,
**Default:** 20 | Number of items per page. | No |
| page | integer,
**Default:** 1 | Page number for pagination. | No |
-| status | string | Filter by execution status. | No |
+| status | string,
**Available values:** "failed", "stopped", "succeeded" | Filter by execution status. | No |
#### WorkflowPauseReasonResponse
@@ -4452,7 +4452,7 @@ Public pause reason emitted by a blocking Workflow execution.
| ---- | ---- | ----------- | -------- |
| files | [ object
object
object
object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
-| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
#### WorkflowRunPayloadWithUser
@@ -4460,7 +4460,7 @@ Public pause reason emitted by a blocking Workflow execution.
| ---- | ---- | ----------- | -------- |
| files | [ object
object
object
object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
-| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
#### WorkflowRunResponse
diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md
index e534fe39350..3cc99e099ce 100644
--- a/api/openapi/markdown/web-openapi.md
+++ b/api/openapi/markdown/web-openapi.md
@@ -1019,7 +1019,7 @@ Button styles for user actions.
| inputs | object | Input variables for the chat | Yes |
| parent_message_id | string | Parent message ID | No |
| query | string | User query/message | Yes |
-| response_mode | string | Response mode: blocking or streaming | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
| retriever_from | string,
**Default:** web_app | Source of retriever | No |
#### CompletionMessagePayload
@@ -1029,7 +1029,7 @@ Button styles for user actions.
| files | [ object ] | Files to be processed | No |
| inputs | object | Input variables for the completion | Yes |
| query | string | Query text for completion | No |
-| response_mode | string | Response mode: blocking or streaming | No |
+| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
| retriever_from | string,
**Default:** web_app | Source of retriever | No |
#### ConversationInfiniteScrollPagination
@@ -1322,7 +1322,7 @@ Parsed multipart form fields for HITL uploads.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | Optional text feedback providing additional detail. | No |
-| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
+| rating | string,
**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
#### MessageFile
diff --git a/api/repositories/step_by_step_tour_repository.py b/api/repositories/step_by_step_tour_repository.py
new file mode 100644
index 00000000000..7dc7a6d6bf2
--- /dev/null
+++ b/api/repositories/step_by_step_tour_repository.py
@@ -0,0 +1,189 @@
+"""SQLAlchemy repository for account Step-by-step Tour state."""
+
+import logging
+from collections.abc import Callable
+from typing import Protocol, override, runtime_checkable
+
+from sqlalchemy import select, update
+from sqlalchemy.exc import IntegrityError, OperationalError
+from sqlalchemy.orm import Session, sessionmaker
+
+from models.onboarding import AccountStepByStepTourState
+from services.entities.onboarding_entities import StepByStepTourState
+from services.step_by_step_tour_service import StepByStepTourStateRepository
+
+logger = logging.getLogger(__name__)
+
+_MYSQL_RETRYABLE_LOCK_ERRNOS = frozenset({1205, 1213})
+_MAX_LOCK_ATTEMPTS = 3
+
+
+@runtime_checkable
+class _ErrorWithErrno(Protocol):
+ @property
+ def errno(self) -> object: ...
+
+
+class SQLAlchemyStepByStepTourStateRepository(StepByStepTourStateRepository):
+ def __init__(self, session_factory: sessionmaker[Session]) -> None:
+ self._session_factory = session_factory
+
+ @override
+ def get(self, account_id: str) -> StepByStepTourState | None:
+ with self._session_factory() as session:
+ model = self._get_model(account_id, session=session)
+ return self._to_state(model) if model is not None else None
+
+ @override
+ def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
+ """Create state with its first workspace, or atomically claim a legacy empty state."""
+ return self._run_with_lock_retry(
+ lambda: self._initialize_once(account_id, first_workspace_id),
+ )
+
+ def _initialize_once(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
+ with self._session_factory() as session:
+ model = self._get_model(account_id, session=session)
+ if model is None:
+ model = AccountStepByStepTourState(
+ account_id=account_id,
+ first_workspace_id=first_workspace_id,
+ )
+ session.add(model)
+ try:
+ session.commit()
+ except IntegrityError:
+ # A concurrent request inserted the account-owned row first.
+ session.rollback()
+ model = self._get_model(account_id, session=session)
+ if model is None:
+ raise
+ else:
+ session.refresh(model)
+ return self._to_state(model)
+
+ if model.first_workspace_id is None:
+ stmt = (
+ update(AccountStepByStepTourState)
+ .where(
+ AccountStepByStepTourState.account_id == account_id,
+ AccountStepByStepTourState.first_workspace_id.is_(None),
+ )
+ .values(first_workspace_id=first_workspace_id)
+ .execution_options(synchronize_session=False)
+ )
+ session.execute(stmt)
+ session.commit()
+ # A competing conditional update may have won while this request waited.
+ session.refresh(model)
+
+ return self._to_state(model)
+
+ @override
+ def mutate(
+ self,
+ account_id: str,
+ mutation: Callable[[StepByStepTourState], StepByStepTourState],
+ ) -> StepByStepTourState:
+ """Lock, create if needed, mutate, and persist account state in one transaction."""
+ return self._run_with_lock_retry(
+ lambda: self._mutate_once(account_id, mutation),
+ )
+
+ def _mutate_once(
+ self,
+ account_id: str,
+ mutation: Callable[[StepByStepTourState], StepByStepTourState],
+ ) -> StepByStepTourState:
+ with self._session_factory() as session:
+ # Probe without a locking read so a missing MySQL unique key does not
+ # acquire a gap/next-key lock before the insert.
+ model = self._get_model(account_id, session=session)
+ if model is None:
+ model = AccountStepByStepTourState(account_id=account_id)
+ session.add(model)
+ try:
+ session.flush()
+ except IntegrityError:
+ # A concurrent mutation created the row. Start a new transaction,
+ # lock its committed state, and replay the pure mutation on it.
+ session.rollback()
+ model = self._get_model(account_id, session=session, lock_for_update=True)
+ if model is None:
+ raise
+ else:
+ model = self._get_model(account_id, session=session, lock_for_update=True)
+ if model is None:
+ raise RuntimeError("Step-by-step Tour state disappeared while acquiring its lock")
+
+ state = mutation(self._to_state(model))
+ if state.account_id != account_id:
+ raise ValueError("Step-by-step Tour mutation cannot change account ownership")
+ # first_workspace_id is write-once and owned exclusively by initialize().
+ model.skipped = state.skipped
+ model.completed_task_ids = list(state.completed_task_ids)
+ model.manually_enabled_workspace_ids = list(state.manually_enabled_workspace_ids)
+ model.manually_disabled_workspace_ids = list(state.manually_disabled_workspace_ids)
+ session.commit()
+ session.refresh(model)
+ return self._to_state(model)
+
+ @staticmethod
+ def _run_with_lock_retry[T](operation: Callable[[], T]) -> T:
+ for attempt in range(1, _MAX_LOCK_ATTEMPTS):
+ try:
+ return operation()
+ except OperationalError as exc:
+ if not _is_retryable_mysql_lock_error(exc):
+ raise
+ logger.warning(
+ "Retrying Step-by-step Tour transaction after MySQL lock failure (attempt %s/%s)",
+ attempt,
+ _MAX_LOCK_ATTEMPTS,
+ )
+ return operation()
+
+ @staticmethod
+ def _get_model(
+ account_id: str,
+ *,
+ session: Session,
+ lock_for_update: bool = False,
+ ) -> AccountStepByStepTourState | None:
+ stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
+ if lock_for_update:
+ stmt = stmt.with_for_update().execution_options(populate_existing=True)
+ return session.execute(stmt).scalar_one_or_none()
+
+ @staticmethod
+ def _to_state(model: AccountStepByStepTourState) -> StepByStepTourState:
+ return StepByStepTourState(
+ account_id=model.account_id,
+ first_workspace_id=model.first_workspace_id,
+ skipped=model.skipped,
+ completed_task_ids=tuple(model.completed_task_ids),
+ manually_enabled_workspace_ids=tuple(model.manually_enabled_workspace_ids),
+ manually_disabled_workspace_ids=tuple(model.manually_disabled_workspace_ids),
+ updated_at=model.updated_at,
+ )
+
+
+def _is_retryable_mysql_lock_error(exc: OperationalError) -> bool:
+ orig = exc.orig
+ if isinstance(orig, _ErrorWithErrno) and _is_retryable_mysql_lock_error_code(orig.errno):
+ return True
+ if not isinstance(orig, BaseException) or not orig.args:
+ return False
+ return _is_retryable_mysql_lock_error_code(orig.args[0])
+
+
+def _is_retryable_mysql_lock_error_code(candidate: object) -> bool:
+ if isinstance(candidate, bool):
+ return False
+ if isinstance(candidate, int):
+ code = candidate
+ elif isinstance(candidate, str) and candidate.isdecimal():
+ code = int(candidate)
+ else:
+ return False
+ return code in _MYSQL_RETRYABLE_LOCK_ERRNOS
diff --git a/api/services/entities/notification_entities.py b/api/services/entities/notification_entities.py
new file mode 100644
index 00000000000..6686c5edb99
--- /dev/null
+++ b/api/services/entities/notification_entities.py
@@ -0,0 +1,38 @@
+"""Framework-independent notification contracts."""
+
+from collections.abc import Mapping
+from typing import NamedTuple
+
+
+class NotificationContent(NamedTuple):
+ lang: str
+ title: str
+ subtitle: str
+ body: str
+ title_pic_url: str
+
+
+class AccountNotification(NamedTuple):
+ notification_id: str | None
+ frequency: str | None
+ contents: Mapping[str, NotificationContent]
+
+
+class AccountNotificationBatch(NamedTuple):
+ should_show: bool
+ notifications: tuple[AccountNotification, ...]
+
+
+class NotificationItem(NamedTuple):
+ notification_id: str | None
+ frequency: str | None
+ lang: str
+ title: str
+ subtitle: str
+ body: str
+ title_pic_url: str
+
+
+class NotificationResult(NamedTuple):
+ should_show: bool
+ notifications: tuple[NotificationItem, ...]
diff --git a/api/services/entities/onboarding_entities.py b/api/services/entities/onboarding_entities.py
new file mode 100644
index 00000000000..2550db489e4
--- /dev/null
+++ b/api/services/entities/onboarding_entities.py
@@ -0,0 +1,42 @@
+"""Framework-independent Step-by-step Tour contracts."""
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Literal, TypeAlias
+
+# Assignment-form aliases preserve Literal enum values in Pydantic-generated OpenAPI schemas.
+StepByStepTourAction: TypeAlias = Literal[ # noqa: UP040
+ "skip",
+ "complete_task",
+ "uncomplete_task",
+ "enable_current_workspace",
+ "disable_current_workspace",
+]
+StepByStepTourTaskId: TypeAlias = Literal["home", "studio", "knowledge", "integration"] # noqa: UP040
+
+
+@dataclass(frozen=True, slots=True)
+class StepByStepTourPatch:
+ action: StepByStepTourAction
+ task_id: StepByStepTourTaskId | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class StepByStepTourState:
+ account_id: str
+ first_workspace_id: str | None = None
+ skipped: bool = False
+ completed_task_ids: tuple[str, ...] = ()
+ manually_enabled_workspace_ids: tuple[str, ...] = ()
+ manually_disabled_workspace_ids: tuple[str, ...] = ()
+ updated_at: datetime | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class StepByStepTourResult:
+ first_workspace_id: str | None = None
+ skipped: bool = False
+ completed_task_ids: tuple[str, ...] = ()
+ manually_enabled_workspace_ids: tuple[str, ...] = ()
+ manually_disabled_workspace_ids: tuple[str, ...] = ()
+ updated_at: datetime | None = None
diff --git a/api/services/notification_gateway.py b/api/services/notification_gateway.py
new file mode 100644
index 00000000000..cb7cc5e74d0
--- /dev/null
+++ b/api/services/notification_gateway.py
@@ -0,0 +1,48 @@
+"""Billing-backed notification gateway."""
+
+from collections.abc import Mapping
+from typing import Any, override
+
+from services.billing_service import BillingService
+from services.entities.notification_entities import (
+ AccountNotification,
+ AccountNotificationBatch,
+ NotificationContent,
+)
+from services.notification_service import NotificationGateway
+
+
+class BillingNotificationGateway(NotificationGateway):
+ @override
+ def get_active(self, account_id: str) -> AccountNotificationBatch:
+ payload = BillingService.get_account_notification(account_id)
+ notifications = tuple(self._map_notification(item) for item in payload.get("notifications") or ())
+ return AccountNotificationBatch(
+ should_show=bool(payload.get("shouldShow")),
+ notifications=notifications,
+ )
+
+ @override
+ def dismiss(self, notification_id: str, account_id: str) -> None:
+ BillingService.dismiss_notification(notification_id=notification_id, account_id=account_id)
+
+ @classmethod
+ def _map_notification(cls, payload: Mapping[str, Any]) -> AccountNotification:
+ raw_contents = payload.get("contents") or {}
+ contents = {language: cls._map_content(content) for language, content in raw_contents.items() if content}
+ return AccountNotification(
+ notification_id=payload.get("notificationId"),
+ frequency=payload.get("frequency"),
+ contents=contents,
+ )
+
+ @staticmethod
+ def _map_content(payload: Mapping[str, Any]) -> NotificationContent:
+ return NotificationContent(
+ # The application service owns the requested-language fallback.
+ lang=payload.get("lang") or "",
+ title=payload.get("title") or "",
+ subtitle=payload.get("subtitle") or "",
+ body=payload.get("body") or "",
+ title_pic_url=payload.get("titlePicUrl") or "",
+ )
diff --git a/api/services/notification_service.py b/api/services/notification_service.py
new file mode 100644
index 00000000000..13236ef16ed
--- /dev/null
+++ b/api/services/notification_service.py
@@ -0,0 +1,60 @@
+"""Application service for Console account notifications."""
+
+from typing import Protocol
+
+from machinery.context import RequestContext
+from services.account_ports import AccountRepository
+from services.entities.notification_entities import (
+ AccountNotification,
+ AccountNotificationBatch,
+ NotificationContent,
+ NotificationItem,
+ NotificationResult,
+)
+
+_FALLBACK_LANGUAGE = "en-US"
+
+
+class NotificationGateway(Protocol):
+ def get_active(self, account_id: str) -> AccountNotificationBatch: ...
+
+ def dismiss(self, notification_id: str, account_id: str) -> None: ...
+
+
+class NotificationService:
+ def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None:
+ self._accounts = accounts
+ self._notifications = notifications
+
+ def get_active(self, context: RequestContext) -> NotificationResult:
+ batch = self._notifications.get_active(context.account_id)
+ if not batch.should_show:
+ return NotificationResult(should_show=False, notifications=())
+
+ account = self._accounts.get(context.account_id)
+ if account is None:
+ raise RuntimeError("Console account admission resolved an unknown account")
+ language = account.interface_language or _FALLBACK_LANGUAGE
+
+ notifications = tuple(self._localize(notification, language) for notification in batch.notifications)
+ return NotificationResult(should_show=bool(notifications), notifications=notifications)
+
+ def dismiss(self, context: RequestContext, notification_id: str) -> None:
+ self._notifications.dismiss(notification_id, context.account_id)
+
+ @staticmethod
+ def _localize(notification: AccountNotification, language: str) -> NotificationItem:
+ content = (
+ notification.contents.get(language)
+ or notification.contents.get(_FALLBACK_LANGUAGE)
+ or next(iter(notification.contents.values()), NotificationContent(language, "", "", "", ""))
+ )
+ return NotificationItem(
+ notification_id=notification.notification_id,
+ frequency=notification.frequency,
+ lang=content.lang or language,
+ title=content.title,
+ subtitle=content.subtitle,
+ body=content.body,
+ title_pic_url=content.title_pic_url,
+ )
diff --git a/api/services/step_by_step_tour_service.py b/api/services/step_by_step_tour_service.py
index b01d59c1acc..9597d3d5e77 100644
--- a/api/services/step_by_step_tour_service.py
+++ b/api/services/step_by_step_tour_service.py
@@ -1,221 +1,161 @@
-"""Account-level Step-by-step Tour persistence."""
+"""Application service for account-level Step-by-step Tour use cases."""
+from collections.abc import Callable
+from dataclasses import replace
from datetime import datetime
-from typing import NotRequired, TypedDict
+from typing import Protocol, get_args
-from sqlalchemy import select
-from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import Session, scoped_session
-
-from configs import dify_config
from libs.datetime_utils import ensure_naive_utc
-from models.account import Account
-from models.onboarding import AccountStepByStepTourState
+from machinery.context import RequestContext
+from services.account_ports import AccountRepository
+from services.entities.onboarding_entities import (
+ StepByStepTourPatch,
+ StepByStepTourResult,
+ StepByStepTourState,
+ StepByStepTourTaskId,
+)
-STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
+_TASK_IDS: frozenset[str] = frozenset(get_args(StepByStepTourTaskId))
-class StepByStepTourStateResponse(TypedDict):
- first_workspace_id: str | None
- skipped: bool
- completed_task_ids: list[str]
- manually_enabled_workspace_ids: list[str]
- manually_disabled_workspace_ids: list[str]
- updated_at: datetime | None
+class StepByStepTourStateRepository(Protocol):
+ def get(self, account_id: str) -> StepByStepTourState | None: ...
+ def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: ...
-class StepByStepTourPatch(TypedDict):
- action: str
- task_id: NotRequired[str | None]
+ def mutate(
+ self,
+ account_id: str,
+ mutation: Callable[[StepByStepTourState], StepByStepTourState],
+ ) -> StepByStepTourState: ...
class StepByStepTourService:
- """Coordinate persisted tour state with account eligibility rules."""
-
- @classmethod
- def get_state(
- cls,
+ def __init__(
+ self,
*,
- account: Account,
- current_tenant_id: str,
- session: Session | scoped_session,
- ) -> StepByStepTourStateResponse:
- eligible = cls.is_eligible(account)
- state = cls._get_state(account.id, session=session)
+ accounts: AccountRepository,
+ states: StepByStepTourStateRepository,
+ enabled: bool,
+ rollout_started_at: datetime | None,
+ ) -> None:
+ self._accounts = accounts
+ self._states = states
+ self._enabled = enabled
+ self._rollout_started_at = rollout_started_at
- if eligible:
- state = cls._ensure_state(account.id, session=session, state=state)
- if state.first_workspace_id is None:
- state.first_workspace_id = current_tenant_id
- session.commit()
- session.refresh(state)
+ def get_state(self, context: RequestContext) -> StepByStepTourResult:
+ workspace_id = self._require_workspace(context)
+ account = self._accounts.get(context.account_id)
+ if account is None:
+ raise RuntimeError("Console account admission resolved an unknown account")
- return cls._build_response(state=state)
+ if not self._is_eligible(account.initialized_at or account.created_at):
+ return self._to_result(self._states.get(context.account_id))
- @classmethod
- def patch_state(
- cls,
- *,
- account: Account,
- current_tenant_id: str,
- patch: StepByStepTourPatch,
- session: Session | scoped_session,
- ) -> StepByStepTourStateResponse:
- state = cls._ensure_state(account.id, session=session, state=None)
- cls._apply_action(
- state=state,
- action=patch["action"],
- task_id=patch.get("task_id"),
- current_tenant_id=current_tenant_id,
+ return self._to_result(self._states.initialize(context.account_id, workspace_id))
+
+ def patch_state(self, context: RequestContext, patch: StepByStepTourPatch) -> StepByStepTourResult:
+ workspace_id = self._require_workspace(context)
+ state = self._states.mutate(
+ context.account_id,
+ lambda current: self._apply_action(current, patch=patch, workspace_id=workspace_id),
)
+ return self._to_result(state)
- session.commit()
- session.refresh(state)
- return cls._build_response(state=state)
-
- @classmethod
- def is_eligible(cls, account: Account) -> bool:
- if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
+ def _is_eligible(self, account_started_at: datetime) -> bool:
+ if not self._enabled or self._rollout_started_at is None:
return False
-
- rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
- if rollout_started_at is None:
- return False
-
- account_started_at = account.initialized_at or account.created_at
- if account_started_at is None:
- return False
-
- return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
-
- @classmethod
- def _get_state(
- cls,
- account_id: str,
- *,
- session: Session | scoped_session,
- ) -> AccountStepByStepTourState | None:
- stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
- return session.execute(stmt).scalar_one_or_none()
-
- @classmethod
- def _ensure_state(
- cls,
- account_id: str,
- *,
- session: Session | scoped_session,
- state: AccountStepByStepTourState | None,
- ) -> AccountStepByStepTourState:
- if state is None:
- state = cls._get_state(account_id, session=session)
- if state is not None:
- return state
-
- state = AccountStepByStepTourState(account_id=account_id)
- session.add(state)
- try:
- session.flush()
- except IntegrityError:
- # Another tab/device can create the account row between our read and insert.
- session.rollback()
- state = cls._get_state(account_id, session=session)
- if state is None:
- raise
- return state
+ return ensure_naive_utc(account_started_at) >= ensure_naive_utc(self._rollout_started_at)
@classmethod
def _apply_action(
cls,
+ state: StepByStepTourState,
*,
- state: AccountStepByStepTourState,
- action: str,
- task_id: str | None,
- current_tenant_id: str,
- ) -> None:
- match action:
+ patch: StepByStepTourPatch,
+ workspace_id: str,
+ ) -> StepByStepTourState:
+ match patch.action:
case "skip":
- state.skipped = True
- state.manually_enabled_workspace_ids = cls._remove_id(
- state.manually_enabled_workspace_ids,
- current_tenant_id,
+ return replace(
+ state,
+ skipped=True,
+ manually_enabled_workspace_ids=cls._remove_id(
+ state.manually_enabled_workspace_ids,
+ workspace_id,
+ ),
)
case "complete_task":
- if task_id is None:
- raise ValueError("task_id is required")
- cls._validate_task_id(task_id)
- state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
+ task_id = cls._require_task_id(patch.task_id)
+ return replace(state, completed_task_ids=cls._add_id(state.completed_task_ids, task_id))
case "uncomplete_task":
- if task_id is None:
- raise ValueError("task_id is required")
- cls._validate_task_id(task_id)
- state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
+ task_id = cls._require_task_id(patch.task_id)
+ return replace(state, completed_task_ids=cls._remove_id(state.completed_task_ids, task_id))
case "enable_current_workspace":
- state.skipped = False
- state.manually_enabled_workspace_ids = cls._add_id(
- state.manually_enabled_workspace_ids,
- current_tenant_id,
- )
- state.manually_disabled_workspace_ids = cls._remove_id(
- state.manually_disabled_workspace_ids,
- current_tenant_id,
+ return replace(
+ state,
+ skipped=False,
+ manually_enabled_workspace_ids=cls._add_id(
+ state.manually_enabled_workspace_ids,
+ workspace_id,
+ ),
+ manually_disabled_workspace_ids=cls._remove_id(
+ state.manually_disabled_workspace_ids,
+ workspace_id,
+ ),
)
case "disable_current_workspace":
- state.manually_enabled_workspace_ids = cls._remove_id(
- state.manually_enabled_workspace_ids,
- current_tenant_id,
- )
- state.manually_disabled_workspace_ids = cls._add_id(
- state.manually_disabled_workspace_ids,
- current_tenant_id,
+ return replace(
+ state,
+ manually_enabled_workspace_ids=cls._remove_id(
+ state.manually_enabled_workspace_ids,
+ workspace_id,
+ ),
+ manually_disabled_workspace_ids=cls._add_id(
+ state.manually_disabled_workspace_ids,
+ workspace_id,
+ ),
)
case _:
- raise ValueError(f"Unsupported action: {action}")
-
- @classmethod
- def _build_response(
- cls,
- *,
- state: AccountStepByStepTourState | None,
- ) -> StepByStepTourStateResponse:
- if state is None:
- return {
- "first_workspace_id": None,
- "skipped": False,
- "completed_task_ids": [],
- "manually_enabled_workspace_ids": [],
- "manually_disabled_workspace_ids": [],
- "updated_at": None,
- }
-
- return {
- "first_workspace_id": state.first_workspace_id,
- "skipped": state.skipped,
- "completed_task_ids": cls._normalize_ids(state.completed_task_ids),
- "manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
- "manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
- "updated_at": state.updated_at,
- }
+ raise ValueError(f"Unsupported action: {patch.action}")
@staticmethod
- def _validate_task_id(task_id: str) -> None:
- if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
+ def _require_workspace(context: RequestContext) -> str:
+ if context.active_workspace_id is None:
+ raise RuntimeError("Console account admission did not resolve an active workspace")
+ return context.active_workspace_id
+
+ @staticmethod
+ def _require_task_id(task_id: str | None) -> str:
+ if task_id is None:
+ raise ValueError("task_id is required")
+ if task_id not in _TASK_IDS:
raise ValueError(f"Unsupported task_id: {task_id}")
+ return task_id
@classmethod
- def _add_id(cls, values: list[str], value: str) -> list[str]:
+ def _add_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
normalized = cls._normalize_ids(values)
- if value in normalized:
- return normalized
- return [*normalized, value]
+ return normalized if value in normalized else (*normalized, value)
@classmethod
- def _remove_id(cls, values: list[str], value: str) -> list[str]:
- return [item for item in cls._normalize_ids(values) if item != value]
+ def _remove_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
+ return tuple(item for item in cls._normalize_ids(values) if item != value)
@staticmethod
- def _normalize_ids(values: list[str]) -> list[str]:
- normalized: list[str] = []
- for value in values:
- if value not in normalized:
- normalized.append(value)
- return normalized
+ def _normalize_ids(values: tuple[str, ...]) -> tuple[str, ...]:
+ return tuple(dict.fromkeys(values))
+
+ @staticmethod
+ def _to_result(state: StepByStepTourState | None) -> StepByStepTourResult:
+ if state is None:
+ return StepByStepTourResult()
+ return StepByStepTourResult(
+ first_workspace_id=state.first_workspace_id,
+ skipped=state.skipped,
+ completed_task_ids=tuple(dict.fromkeys(state.completed_task_ids)),
+ manually_enabled_workspace_ids=tuple(dict.fromkeys(state.manually_enabled_workspace_ids)),
+ manually_disabled_workspace_ids=tuple(dict.fromkeys(state.manually_disabled_workspace_ids)),
+ updated_at=state.updated_at,
+ )
diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py
index cff6695e414..9231e274d5c 100644
--- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py
+++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py
@@ -238,6 +238,42 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat
assert "| value | string
integer
number
boolean | | No |" in patched
+def test_patch_union_schema_markdown_preserves_nullable_enum_values(tmp_path: Path):
+ module = _load_generate_swagger_markdown_docs_module()
+ spec_path = tmp_path / "console-openapi.json"
+ spec_path.write_text(
+ json.dumps(
+ {
+ "components": {
+ "schemas": {
+ "StepByStepTourStatePatchPayload": {
+ "properties": {
+ "task_id": {
+ "anyOf": [
+ {"enum": ["home", "studio"], "type": "string"},
+ {"type": "null"},
+ ],
+ },
+ },
+ },
+ },
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ markdown = """#### StepByStepTourStatePatchPayload
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| task_id | string | Task ID | No |
+"""
+
+ patched = module._patch_union_schema_markdown(markdown, spec_path)
+
+ assert '| task_id | string,
**Available values:** "home", "studio" | Task ID | No |' in patched
+
+
def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "console-openapi.json"
diff --git a/api/tests/unit_tests/controllers/console/test_notification.py b/api/tests/unit_tests/controllers/console/test_notification.py
new file mode 100644
index 00000000000..48843d1af8a
--- /dev/null
+++ b/api/tests/unit_tests/controllers/console/test_notification.py
@@ -0,0 +1,77 @@
+from inspect import unwrap
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+from controllers.console.notification import (
+ DismissNotificationPayload,
+ NotificationApi,
+ NotificationDismissApi,
+)
+from machinery.context import RequestContext
+from services.entities.notification_entities import NotificationItem, NotificationResult
+
+
+def _request_context() -> RequestContext:
+ return RequestContext(
+ request_id="request-1",
+ trace_id="trace-1",
+ account_id="account-1",
+ active_workspace_id="workspace-1",
+ )
+
+
+def test_get_notification_delegates_and_serializes_result() -> None:
+ service = Mock()
+ service.get_active.return_value = NotificationResult(
+ should_show=True,
+ notifications=(
+ NotificationItem(
+ notification_id="notification-1",
+ frequency="once",
+ lang="en-US",
+ title="Title",
+ subtitle="Subtitle",
+ body="Body",
+ title_pic_url="https://example.com/title.png",
+ ),
+ ),
+ )
+ services = SimpleNamespace(notifications=service)
+ api = NotificationApi()
+ method = unwrap(api.get)
+ context = _request_context()
+
+ with patch("controllers.console.notification.application_services", return_value=services):
+ result, status = method(api, context)
+
+ assert status == 200
+ assert result == {
+ "should_show": True,
+ "notifications": [
+ {
+ "notification_id": "notification-1",
+ "frequency": "once",
+ "lang": "en-US",
+ "title": "Title",
+ "subtitle": "Subtitle",
+ "body": "Body",
+ "title_pic_url": "https://example.com/title.png",
+ }
+ ],
+ }
+ service.get_active.assert_called_once_with(context)
+
+
+def test_dismiss_notification_delegates_with_stable_account_context() -> None:
+ service = Mock()
+ services = SimpleNamespace(notifications=service)
+ api = NotificationDismissApi()
+ method = unwrap(api.post)
+ context = _request_context()
+
+ with patch("controllers.console.notification.application_services", return_value=services):
+ result, status = method(api, DismissNotificationPayload(notification_id="notification-1"), context)
+
+ assert status == 200
+ assert result == {"result": "success"}
+ service.dismiss.assert_called_once_with(context, "notification-1")
diff --git a/api/tests/unit_tests/controllers/console/test_onboarding.py b/api/tests/unit_tests/controllers/console/test_onboarding.py
index 8d613f7c202..90a9521a2fa 100644
--- a/api/tests/unit_tests/controllers/console/test_onboarding.py
+++ b/api/tests/unit_tests/controllers/console/test_onboarding.py
@@ -2,47 +2,48 @@ from __future__ import annotations
from datetime import UTC, datetime
from inspect import unwrap
-from unittest.mock import Mock
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
import pytest
-from flask import Flask
from pydantic import ValidationError
from controllers.console.onboarding import (
StepByStepTourStateApi,
StepByStepTourStatePatchPayload,
+ StepByStepTourStateResponse,
)
-from extensions.ext_database import db
-from models.account import Account, AccountStatus
-from services.step_by_step_tour_service import StepByStepTourService
+from machinery.context import RequestContext
+from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult
-def _account() -> Account:
- account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
- account.id = "account-1"
- return account
+def _request_context() -> RequestContext:
+ return RequestContext(
+ request_id="request-1",
+ trace_id="trace-1",
+ account_id="account-1",
+ active_workspace_id="workspace-1",
+ )
-def _state_response() -> dict[str, object]:
- return {
- "first_workspace_id": "workspace-1",
- "skipped": False,
- "completed_task_ids": ["home"],
- "manually_enabled_workspace_ids": [],
- "manually_disabled_workspace_ids": [],
- "updated_at": datetime(2026, 6, 28, tzinfo=UTC),
- }
+def _state_result() -> StepByStepTourResult:
+ return StepByStepTourResult(
+ first_workspace_id="workspace-1",
+ completed_task_ids=("home",),
+ updated_at=datetime(2026, 6, 28, tzinfo=UTC),
+ )
-def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
- get_state = Mock(return_value=_state_response())
- monkeypatch.setattr(StepByStepTourService, "get_state", get_state)
-
+def test_get_step_by_step_tour_state_delegates_with_request_context() -> None:
+ service = Mock()
+ service.get_state.return_value = _state_result()
+ services = SimpleNamespace(step_by_step_tour=service)
api = StepByStepTourStateApi()
method = unwrap(api.get)
+ context = _request_context()
- with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"):
- result = method(api, "workspace-1", _account())
+ with patch("controllers.console.onboarding.application_services", return_value=services):
+ result = method(api, context)
assert result == {
"first_workspace_id": "workspace-1",
@@ -52,35 +53,26 @@ def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch
"manually_disabled_workspace_ids": [],
"updated_at": "2026-06-28T00:00:00Z",
}
- get_state.assert_called_once()
- assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
- assert get_state.call_args.kwargs["session"] is db.session
+ service.get_state.assert_called_once_with(context)
-def test_patch_step_by_step_tour_state_passes_action_payload(
- app: Flask,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- patch_state = Mock(return_value=_state_response())
- monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state)
-
+def test_patch_step_by_step_tour_state_maps_transport_payload_to_command() -> None:
+ service = Mock()
+ service.patch_state.return_value = _state_result()
+ services = SimpleNamespace(step_by_step_tour=service)
api = StepByStepTourStateApi()
method = unwrap(api.patch)
- payload = {"action": "complete_task", "task_id": "studio"}
+ context = _request_context()
+ payload = StepByStepTourStatePatchPayload.model_validate({"action": "complete_task", "task_id": "studio"})
- req_data = StepByStepTourStatePatchPayload.model_validate(payload)
- with app.test_request_context(
- "/console/api/onboarding/step-by-step-tour/state",
- method="PATCH",
- json=payload,
- ):
- result = method(api, req_data, "workspace-1", _account())
+ with patch("controllers.console.onboarding.application_services", return_value=services):
+ result = method(api, payload, context)
assert result["completed_task_ids"] == ["home"]
- patch_state.assert_called_once()
- assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
- assert patch_state.call_args.kwargs["patch"] == payload
- assert patch_state.call_args.kwargs["session"] is db.session
+ service.patch_state.assert_called_once_with(
+ context,
+ StepByStepTourPatch(action="complete_task", task_id="studio"),
+ )
def test_patch_payload_rejects_non_action_fields() -> None:
@@ -96,3 +88,21 @@ def test_patch_payload_rejects_task_id_without_task_action() -> None:
def test_patch_payload_requires_action() -> None:
with pytest.raises(ValidationError):
StepByStepTourStatePatchPayload.model_validate({"task_id": "home"})
+
+
+def test_step_by_step_tour_schemas_preserve_enum_values() -> None:
+ patch_schema = StepByStepTourStatePatchPayload.model_json_schema()
+ action_schema = patch_schema["properties"]["action"]
+ task_id_schema = patch_schema["properties"]["task_id"]
+ task_id_values = next(candidate["enum"] for candidate in task_id_schema["anyOf"] if "enum" in candidate)
+ response_schema = StepByStepTourStateResponse.model_json_schema()
+
+ assert set(action_schema["enum"]) == {
+ "skip",
+ "complete_task",
+ "uncomplete_task",
+ "enable_current_workspace",
+ "disable_current_workspace",
+ }
+ assert set(task_id_values) == {"home", "studio", "knowledge", "integration"}
+ assert set(response_schema["properties"]["completed_task_ids"]["items"]["enum"]) == set(task_id_values)
diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py
index ee3d2125204..191f13cf7e3 100644
--- a/api/tests/unit_tests/extensions/test_ext_application_services.py
+++ b/api/tests/unit_tests/extensions/test_ext_application_services.py
@@ -382,6 +382,8 @@ def test_build_application_services_wires_account_profile_repository(
assert email_registration._registration._session_factory is sqlite_session_factory
assert services.accounts.education._accounts is accounts
assert services.accounts.deletion._accounts is accounts
+ assert services.notifications._accounts is accounts
+ assert services.step_by_step_tour._accounts is accounts
assert services.accounts.deletion._memberships is services.workspace_queries._workspaces
integrations = services.accounts.integrations._integrations
assert isinstance(integrations, SQLAlchemyAccountIntegrationRepository)
diff --git a/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py b/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py
new file mode 100644
index 00000000000..a6439f58bc5
--- /dev/null
+++ b/api/tests/unit_tests/repositories/test_step_by_step_tour_repository.py
@@ -0,0 +1,171 @@
+from contextlib import nullcontext
+from dataclasses import replace
+from datetime import datetime
+from typing import cast
+from unittest.mock import MagicMock, Mock
+
+import pytest
+from sqlalchemy.exc import IntegrityError, OperationalError
+from sqlalchemy.orm import Session, sessionmaker
+
+from models.onboarding import AccountStepByStepTourState
+from repositories.step_by_step_tour_repository import (
+ SQLAlchemyStepByStepTourStateRepository,
+ _is_retryable_mysql_lock_error,
+)
+
+
+class _ErrnoOnlyError(Exception):
+ def __init__(self, errno: int | str) -> None:
+ super().__init__()
+ self.errno = errno
+
+
+def test_mutate_creates_and_updates_state_in_repository_owned_transaction(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
+
+ saved = repository.mutate(
+ "account-1",
+ lambda state: replace(state, completed_task_ids=("home",)),
+ )
+ reloaded = repository.get("account-1")
+
+ assert saved.first_workspace_id is None
+ assert saved.completed_task_ids == ("home",)
+ assert saved.updated_at is not None
+ assert reloaded == saved
+
+
+def test_initialize_creates_state_with_first_workspace_atomically(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
+
+ result = repository.initialize("account-1", "workspace-1")
+
+ assert result.first_workspace_id == "workspace-1"
+ assert repository.get("account-1") == result
+
+
+def test_initialize_claims_empty_state_once_without_overwriting_winner(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
+ with sqlite_session_factory() as session:
+ session.add(AccountStepByStepTourState(account_id="account-1"))
+ session.commit()
+
+ first = repository.initialize("account-1", "workspace-1")
+ second = repository.initialize("account-1", "workspace-2")
+
+ assert first.first_workspace_id == "workspace-1"
+ assert second.first_workspace_id == "workspace-1"
+
+
+def test_mutate_cannot_clear_or_overwrite_first_workspace(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
+ repository.initialize("account-1", "workspace-1")
+
+ result = repository.mutate(
+ "account-1",
+ lambda state: replace(state, first_workspace_id="workspace-2", skipped=True),
+ )
+
+ assert result.first_workspace_id == "workspace-1"
+ assert result.skipped is True
+
+
+def test_sequential_mutations_replay_against_latest_state(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ repository = SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory)
+
+ repository.mutate("account-1", lambda state: replace(state, completed_task_ids=("home",)))
+ result = repository.mutate(
+ "account-1",
+ lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
+ )
+
+ assert result.completed_task_ids == ("home", "studio")
+
+
+def test_mutate_replays_after_concurrent_create_conflict() -> None:
+ concurrent_state = AccountStepByStepTourState(account_id="account-1")
+ concurrent_state.completed_task_ids = ["home"]
+ concurrent_state.updated_at = datetime(2026, 8, 13)
+ session = MagicMock(spec=Session)
+ session.execute.return_value.scalar_one_or_none.side_effect = [None, concurrent_state]
+ session.flush.side_effect = IntegrityError("insert", {}, Exception("duplicate"))
+ factory = cast(sessionmaker[Session], Mock(return_value=nullcontext(session)))
+ repository = SQLAlchemyStepByStepTourStateRepository(factory)
+
+ result = repository.mutate(
+ "account-1",
+ lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
+ )
+
+ assert result.completed_task_ids == ("home", "studio")
+ session.rollback.assert_called_once_with()
+ initial_probe = session.execute.call_args_list[0].args[0]
+ replay_statement = session.execute.call_args_list[1].args[0]
+ assert initial_probe._for_update_arg is None
+ assert replay_statement._for_update_arg is not None
+
+
+def test_mutate_retries_mysql_deadlock_with_fresh_session() -> None:
+ concurrent_state = AccountStepByStepTourState(account_id="account-1")
+ concurrent_state.completed_task_ids = ["home"]
+ concurrent_state.updated_at = datetime(2026, 8, 13)
+
+ deadlocked_session = MagicMock(spec=Session)
+ deadlocked_session.execute.return_value.scalar_one_or_none.return_value = None
+ deadlocked_session.flush.side_effect = OperationalError(
+ "INSERT",
+ {},
+ Exception(1213, "Deadlock found when trying to get lock"),
+ )
+
+ retry_session = MagicMock(spec=Session)
+ retry_session.execute.return_value.scalar_one_or_none.side_effect = [concurrent_state, concurrent_state]
+ factory = Mock(side_effect=[nullcontext(deadlocked_session), nullcontext(retry_session)])
+ repository = SQLAlchemyStepByStepTourStateRepository(cast(sessionmaker[Session], factory))
+
+ result = repository.mutate(
+ "account-1",
+ lambda state: replace(state, completed_task_ids=(*state.completed_task_ids, "studio")),
+ )
+
+ assert result.completed_task_ids == ("home", "studio")
+ assert factory.call_count == 2
+ retry_lock_statement = retry_session.execute.call_args_list[1].args[0]
+ assert retry_lock_statement._for_update_arg is not None
+
+
+@pytest.mark.parametrize(
+ ("orig", "expected"),
+ [
+ pytest.param(_ErrnoOnlyError(1205), True, id="errno-attribute"),
+ pytest.param(Exception(1213, "deadlock"), True, id="integer-args-code"),
+ pytest.param(Exception("1213", "deadlock"), True, id="string-args-code"),
+ pytest.param(Exception(9999, "other error"), False, id="non-retryable-code"),
+ pytest.param(Exception(True), False, id="boolean-is-not-an-error-code"),
+ pytest.param(Exception(), False, id="missing-error-code"),
+ ],
+)
+def test_mysql_lock_error_detection_preserves_errno_and_args_coverage(
+ orig: BaseException,
+ expected: bool,
+) -> None:
+ exc = OperationalError("statement", {}, orig)
+
+ assert _is_retryable_mysql_lock_error(exc) is expected
+
+
+def test_get_returns_none_for_unknown_account(
+ sqlite_session_factory: sessionmaker[Session],
+) -> None:
+ assert SQLAlchemyStepByStepTourStateRepository(sqlite_session_factory).get("missing") is None
diff --git a/api/tests/unit_tests/services/test_notification_gateway.py b/api/tests/unit_tests/services/test_notification_gateway.py
new file mode 100644
index 00000000000..9df67e7a325
--- /dev/null
+++ b/api/tests/unit_tests/services/test_notification_gateway.py
@@ -0,0 +1,63 @@
+from unittest.mock import patch
+
+from services.entities.notification_entities import NotificationContent
+from services.notification_gateway import BillingNotificationGateway
+
+
+def test_get_active_maps_billing_proto_json_contract() -> None:
+ payload = {
+ "shouldShow": True,
+ "notifications": [
+ {
+ "notificationId": "notification-1",
+ "frequency": "once",
+ "contents": {
+ "en-US": {
+ "lang": "en-US",
+ "title": "Title",
+ "subtitle": "Subtitle",
+ "body": "Body",
+ "titlePicUrl": "title.png",
+ }
+ },
+ }
+ ],
+ }
+
+ with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload):
+ result = BillingNotificationGateway().get_active("account-1")
+
+ assert result.should_show is True
+ assert result.notifications[0].notification_id == "notification-1"
+ assert result.notifications[0].contents["en-US"].title_pic_url == "title.png"
+
+
+def test_get_active_omits_empty_localized_content_so_service_can_fall_back() -> None:
+ empty_localized_content: dict[str, str] = {}
+ payload = {
+ "shouldShow": True,
+ "notifications": [
+ {
+ "notificationId": "notification-1",
+ "frequency": "once",
+ "contents": {
+ "zh-Hans": empty_localized_content,
+ "en-US": {"lang": "en-US", "title": "Title"},
+ },
+ }
+ ],
+ }
+
+ with patch("services.notification_gateway.BillingService.get_account_notification", return_value=payload):
+ result = BillingNotificationGateway().get_active("account-1")
+
+ assert result.notifications[0].contents == {
+ "en-US": NotificationContent("en-US", "Title", "", "", ""),
+ }
+
+
+def test_dismiss_delegates_to_billing_service() -> None:
+ with patch("services.notification_gateway.BillingService.dismiss_notification") as dismiss:
+ BillingNotificationGateway().dismiss("notification-1", "account-1")
+
+ dismiss.assert_called_once_with(notification_id="notification-1", account_id="account-1")
diff --git a/api/tests/unit_tests/services/test_notification_service.py b/api/tests/unit_tests/services/test_notification_service.py
new file mode 100644
index 00000000000..3be7f08a6f7
--- /dev/null
+++ b/api/tests/unit_tests/services/test_notification_service.py
@@ -0,0 +1,138 @@
+from datetime import datetime
+from unittest.mock import Mock
+
+import pytest
+
+from machinery.context import RequestContext
+from services.account_ports import AccountRepository
+from services.entities.account_entities import AccountSnapshot
+from services.entities.notification_entities import (
+ AccountNotification,
+ AccountNotificationBatch,
+ NotificationContent,
+ NotificationItem,
+ NotificationResult,
+)
+from services.notification_service import NotificationService
+
+
+def _context() -> RequestContext:
+ return RequestContext(
+ request_id="request-1",
+ trace_id="trace-1",
+ account_id="account-1",
+ active_workspace_id="workspace-1",
+ )
+
+
+class NotificationGatewayStub:
+ def __init__(self, batch: AccountNotificationBatch) -> None:
+ self.batch = batch
+ self.get_account_ids: list[str] = []
+ self.dismissals: list[tuple[str, str]] = []
+
+ def get_active(self, account_id: str) -> AccountNotificationBatch:
+ self.get_account_ids.append(account_id)
+ return self.batch
+
+ def dismiss(self, notification_id: str, account_id: str) -> None:
+ self.dismissals.append((notification_id, account_id))
+
+
+def _account(language: str | None = "zh-Hans") -> AccountSnapshot:
+ return AccountSnapshot(
+ id="account-1",
+ name="Account",
+ email="account@example.com",
+ avatar=None,
+ is_password_set=False,
+ interface_language=language,
+ interface_theme="light",
+ timezone="UTC",
+ last_login_at=None,
+ last_login_ip=None,
+ status="active",
+ initialized_at=None,
+ created_at=datetime(2026, 1, 1),
+ )
+
+
+def _accounts(account: AccountSnapshot | None) -> Mock:
+ accounts = Mock(spec=AccountRepository)
+ accounts.get.return_value = account
+ return accounts
+
+
+def _notification(contents: dict[str, NotificationContent]) -> AccountNotification:
+ return AccountNotification(
+ notification_id="notification-1",
+ frequency="once",
+ contents=contents,
+ )
+
+
+def test_get_active_localizes_notification_for_account_language() -> None:
+ chinese = NotificationContent("zh-Hans", "标题", "副标题", "正文", "zh.png")
+ english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
+ gateway = NotificationGatewayStub(
+ AccountNotificationBatch(True, (_notification({"zh-Hans": chinese, "en-US": english}),))
+ )
+ service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
+
+ result = service.get_active(_context())
+
+ assert result == NotificationResult(
+ should_show=True,
+ notifications=(NotificationItem("notification-1", "once", "zh-Hans", "标题", "副标题", "正文", "zh.png"),),
+ )
+ assert gateway.get_account_ids == ["account-1"]
+
+
+def test_get_active_falls_back_to_english() -> None:
+ english = NotificationContent("en-US", "Title", "Subtitle", "Body", "en.png")
+ gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({"en-US": english}),)))
+ service = NotificationService(accounts=_accounts(_account("fr-FR")), notifications=gateway)
+
+ result = service.get_active(_context())
+
+ assert result.notifications[0].lang == "en-US"
+ assert result.notifications[0].title == "Title"
+
+
+def test_get_active_skips_account_query_when_gateway_says_not_to_show() -> None:
+ accounts = _accounts(None)
+ service = NotificationService(
+ accounts=accounts,
+ notifications=NotificationGatewayStub(AccountNotificationBatch(False, ())),
+ )
+
+ result = service.get_active(_context())
+
+ assert result == NotificationResult(False, ())
+ accounts.get.assert_not_called()
+
+
+def test_get_active_uses_empty_content_when_notification_has_no_translations() -> None:
+ gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
+ service = NotificationService(accounts=_accounts(_account(None)), notifications=gateway)
+
+ result = service.get_active(_context())
+
+ assert result.notifications == (NotificationItem("notification-1", "once", "en-US", "", "", "", ""),)
+
+
+def test_get_active_rejects_unknown_admitted_account() -> None:
+ gateway = NotificationGatewayStub(AccountNotificationBatch(True, (_notification({}),)))
+ service = NotificationService(accounts=_accounts(None), notifications=gateway)
+
+ with pytest.raises(RuntimeError, match="unknown account"):
+ service.get_active(_context())
+
+
+def test_dismiss_delegates_identifiers_to_gateway() -> None:
+ gateway = NotificationGatewayStub(AccountNotificationBatch(False, ()))
+ service = NotificationService(accounts=_accounts(_account()), notifications=gateway)
+
+ service.dismiss(_context(), "notification-1")
+
+ assert gateway.dismissals == [("notification-1", "account-1")]
diff --git a/api/tests/unit_tests/services/test_step_by_step_tour_service.py b/api/tests/unit_tests/services/test_step_by_step_tour_service.py
index 7a99fa61444..40017bb7798 100644
--- a/api/tests/unit_tests/services/test_step_by_step_tour_service.py
+++ b/api/tests/unit_tests/services/test_step_by_step_tour_service.py
@@ -1,230 +1,213 @@
from __future__ import annotations
-from datetime import UTC, datetime
+from collections.abc import Callable
+from dataclasses import replace
+from datetime import datetime
+from unittest.mock import Mock
import pytest
-from sqlalchemy import event, select
-from sqlalchemy.orm import Session, sessionmaker
-from enums import DeploymentEdition
-from models.account import Account, AccountStatus
-from models.onboarding import AccountStepByStepTourState
+from machinery.context import RequestContext
+from services.account_ports import AccountRepository
+from services.entities.account_entities import AccountSnapshot
+from services.entities.onboarding_entities import StepByStepTourPatch, StepByStepTourResult, StepByStepTourState
from services.step_by_step_tour_service import StepByStepTourService
-from tests.unit_tests.config_override import apply_config_overrides
-def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account:
- account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
- account.id = "account-1"
- account.initialized_at = initialized_at
- account.created_at = created_at or datetime(2026, 6, 28)
- return account
-
-
-def _state() -> AccountStepByStepTourState:
- state = AccountStepByStepTourState(account_id="account-1")
- state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
- return state
-
-
-def _persist_state(session: Session, state: AccountStepByStepTourState) -> None:
- session.add(state)
- session.commit()
-
-
-def _load_state(session: Session) -> AccountStepByStepTourState | None:
- return session.scalar(
- select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == "account-1")
+def _context(*, workspace_id: str | None = "workspace-1") -> RequestContext:
+ return RequestContext(
+ request_id="request-1",
+ trace_id="trace-1",
+ account_id="account-1",
+ active_workspace_id=workspace_id,
)
-def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None:
- apply_config_overrides(
- monkeypatch,
- ENABLE_STEP_BY_STEP_TOUR=enabled,
- STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT=rollout_started_at,
+class StateRepositoryStub:
+ def __init__(self, state: StepByStepTourState | None = None) -> None:
+ self.state = state
+ self.get_account_ids: list[str] = []
+ self.initialize_calls: list[tuple[str, str]] = []
+ self.mutation_account_ids: list[str] = []
+
+ def get(self, account_id: str) -> StepByStepTourState | None:
+ self.get_account_ids.append(account_id)
+ return self.state
+
+ def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
+ self.initialize_calls.append((account_id, first_workspace_id))
+ if self.state is None:
+ self.state = StepByStepTourState(account_id=account_id, first_workspace_id=first_workspace_id)
+ elif self.state.first_workspace_id is None:
+ self.state = replace(self.state, first_workspace_id=first_workspace_id)
+ return self.state
+
+ def mutate(
+ self,
+ account_id: str,
+ mutation: Callable[[StepByStepTourState], StepByStepTourState],
+ ) -> StepByStepTourState:
+ self.mutation_account_ids.append(account_id)
+ if self.state is None:
+ self.state = StepByStepTourState(account_id=account_id)
+ self.state = mutation(self.state)
+ return self.state
+
+
+def _account(*, started_at: datetime = datetime(2026, 6, 28)) -> AccountSnapshot:
+ return AccountSnapshot(
+ id="account-1",
+ name="Account",
+ email="account@example.com",
+ avatar=None,
+ is_password_set=False,
+ interface_language="en-US",
+ interface_theme="light",
+ timezone="UTC",
+ last_login_at=None,
+ last_login_ip=None,
+ status="active",
+ initialized_at=started_at,
+ created_at=started_at,
)
-def test_get_state_creates_state_and_records_first_workspace_for_eligible_account(
- monkeypatch: pytest.MonkeyPatch,
- sqlite_session: Session,
- sqlite_session_factory: sessionmaker[Session],
-) -> None:
- _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
+def _accounts(account: AccountSnapshot | None) -> Mock:
+ accounts = Mock(spec=AccountRepository)
+ accounts.get.return_value = account
+ return accounts
- result = StepByStepTourService.get_state(
- account=_account(initialized_at=datetime(2026, 6, 28)),
- current_tenant_id="workspace-1",
- session=sqlite_session,
+
+def _service(
+ *,
+ states: StateRepositoryStub,
+ account: AccountSnapshot | None = None,
+ enabled: bool = True,
+ rollout_started_at: datetime | None = datetime(2026, 6, 1),
+) -> StepByStepTourService:
+ return StepByStepTourService(
+ accounts=_accounts(account or _account()),
+ states=states,
+ enabled=enabled,
+ rollout_started_at=rollout_started_at,
)
- assert result["first_workspace_id"] == "workspace-1"
- assert result["completed_task_ids"] == []
- with sqlite_session_factory() as observer:
- persisted = _load_state(observer)
- assert persisted is not None
- assert persisted.account_id == "account-1"
- assert persisted.first_workspace_id == "workspace-1"
+
+def test_get_state_creates_state_and_records_first_workspace_for_eligible_account() -> None:
+ states = StateRepositoryStub()
+
+ result = _service(states=states).get_state(_context())
+
+ assert result.first_workspace_id == "workspace-1"
+ assert states.get_account_ids == []
+ assert states.initialize_calls == [("account-1", "workspace-1")]
+ assert states.mutation_account_ids == []
-def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None:
- _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
- apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
+def test_get_state_returns_existing_state_without_rewriting_first_workspace() -> None:
+ state = StepByStepTourState(account_id="account-1", first_workspace_id="workspace-original")
+ states = StateRepositoryStub(state)
- result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28)))
+ result = _service(states=states).get_state(_context(workspace_id="workspace-current"))
- assert result is True
+ assert result.first_workspace_id == "workspace-original"
+ assert states.initialize_calls == [("account-1", "workspace-current")]
+ assert states.mutation_account_ids == []
-def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state(
- monkeypatch: pytest.MonkeyPatch,
- sqlite_session: Session,
- sqlite_session_factory: sessionmaker[Session],
-) -> None:
- _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
+def test_get_state_does_not_create_state_for_ineligible_account() -> None:
+ states = StateRepositoryStub()
+ service = _service(states=states, account=_account(started_at=datetime(2026, 5, 31)))
- result = StepByStepTourService.get_state(
- account=_account(initialized_at=datetime(2026, 5, 31)),
- current_tenant_id="workspace-1",
- session=sqlite_session,
+ result = service.get_state(_context())
+
+ assert result == StepByStepTourResult()
+ assert states.get_account_ids == ["account-1"]
+ assert states.mutation_account_ids == []
+
+
+def test_get_state_does_not_create_state_when_tour_is_disabled() -> None:
+ states = StateRepositoryStub()
+
+ result = _service(states=states, enabled=False).get_state(_context())
+
+ assert result == StepByStepTourResult()
+ assert states.get_account_ids == ["account-1"]
+
+
+def test_patch_state_persists_even_when_tour_is_disabled() -> None:
+ states = StateRepositoryStub()
+ service = _service(states=states, enabled=False)
+
+ result = service.patch_state(_context(workspace_id="workspace-2"), StepByStepTourPatch("enable_current_workspace"))
+
+ assert result.manually_enabled_workspace_ids == ("workspace-2",)
+ assert states.mutation_account_ids == ["account-1"]
+
+
+def test_patch_state_skip_removes_current_workspace_enable() -> None:
+ states = StateRepositoryStub(
+ StepByStepTourState(
+ account_id="account-1",
+ manually_enabled_workspace_ids=("workspace-1", "workspace-2"),
+ )
)
- assert result == {
- "first_workspace_id": None,
- "skipped": False,
- "completed_task_ids": [],
- "manually_enabled_workspace_ids": [],
- "manually_disabled_workspace_ids": [],
- "updated_at": None,
- }
- with sqlite_session_factory() as observer:
- assert _load_state(observer) is None
+ result = _service(states=states).patch_state(_context(), StepByStepTourPatch("skip"))
+
+ assert result.skipped is True
+ assert result.manually_enabled_workspace_ids == ("workspace-2",)
-def test_patch_state_persists_even_when_account_is_not_eligible(
- monkeypatch: pytest.MonkeyPatch,
- sqlite_session: Session,
- sqlite_session_factory: sessionmaker[Session],
-) -> None:
- _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
-
- result = StepByStepTourService.patch_state(
- account=_account(initialized_at=datetime(2026, 6, 28)),
- current_tenant_id="workspace-2",
- patch={"action": "enable_current_workspace"},
- session=sqlite_session,
+def test_patch_state_disable_moves_current_workspace_to_disabled() -> None:
+ states = StateRepositoryStub(
+ StepByStepTourState(
+ account_id="account-1",
+ manually_enabled_workspace_ids=("workspace-1", "workspace-2"),
+ )
)
- assert result["skipped"] is False
- assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
- assert result["manually_disabled_workspace_ids"] == []
- with sqlite_session_factory() as observer:
- persisted = _load_state(observer)
- assert persisted is not None
- assert persisted.manually_enabled_workspace_ids == ["workspace-2"]
-
-
-def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable(
- monkeypatch: pytest.MonkeyPatch,
- sqlite_session: Session,
-) -> None:
- _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
- state = _state()
- state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
- _persist_state(sqlite_session, state)
-
- result = StepByStepTourService.patch_state(
- account=_account(initialized_at=datetime(2026, 6, 28)),
- current_tenant_id="workspace-1",
- patch={"action": "skip"},
- session=sqlite_session,
+ result = _service(states=states).patch_state(
+ _context(),
+ StepByStepTourPatch("disable_current_workspace"),
)
- assert result["skipped"] is True
- assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
- assert result["manually_disabled_workspace_ids"] == []
- assert _load_state(sqlite_session) is state
+ assert result.manually_enabled_workspace_ids == ("workspace-2",)
+ assert result.manually_disabled_workspace_ids == ("workspace-1",)
-def test_patch_state_disable_action_moves_current_workspace_to_disabled(
- monkeypatch: pytest.MonkeyPatch,
- sqlite_session: Session,
-) -> None:
- _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
- state = _state()
- state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
- _persist_state(sqlite_session, state)
+def test_patch_state_complete_and_uncomplete_task() -> None:
+ states = StateRepositoryStub(StepByStepTourState(account_id="account-1", completed_task_ids=("home",)))
+ service = _service(states=states)
- result = StepByStepTourService.patch_state(
- account=_account(initialized_at=datetime(2026, 6, 28)),
- current_tenant_id="workspace-1",
- patch={"action": "disable_current_workspace"},
- session=sqlite_session,
+ service.patch_state(_context(), StepByStepTourPatch("complete_task", "studio"))
+ result = service.patch_state(_context(), StepByStepTourPatch("uncomplete_task", "home"))
+
+ assert result.completed_task_ids == ("studio",)
+
+
+def test_rejects_unsupported_task_id() -> None:
+ with pytest.raises(ValueError, match="Unsupported task_id"):
+ StepByStepTourService._require_task_id("unknown")
+
+
+def test_rejects_missing_workspace_before_using_state_repository() -> None:
+ states = StateRepositoryStub()
+
+ with pytest.raises(RuntimeError, match="did not resolve an active workspace"):
+ _service(states=states).patch_state(_context(workspace_id=None), StepByStepTourPatch("skip"))
+
+ assert states.mutation_account_ids == []
+
+
+def test_get_state_rejects_unknown_admitted_account() -> None:
+ states = StateRepositoryStub()
+ service = StepByStepTourService(
+ accounts=_accounts(None),
+ states=states,
+ enabled=True,
+ rollout_started_at=datetime(2026, 6, 1),
)
- assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
- assert result["manually_disabled_workspace_ids"] == ["workspace-1"]
- assert _load_state(sqlite_session) is state
-
-
-def test_patch_state_complete_and_uncomplete_task(
- monkeypatch: pytest.MonkeyPatch,
- sqlite_session: Session,
-) -> None:
- _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
- state = _state()
- state.completed_task_ids = ["home"]
- _persist_state(sqlite_session, state)
-
- StepByStepTourService.patch_state(
- account=_account(initialized_at=datetime(2026, 6, 28)),
- current_tenant_id="workspace-1",
- patch={"action": "complete_task", "task_id": "studio"},
- session=sqlite_session,
- )
- result = StepByStepTourService.patch_state(
- account=_account(initialized_at=datetime(2026, 6, 28)),
- current_tenant_id="workspace-1",
- patch={"action": "uncomplete_task", "task_id": "home"},
- session=sqlite_session,
- )
-
- assert result["completed_task_ids"] == ["studio"]
-
-
-def test_patch_state_recovers_when_concurrent_request_created_state(
- monkeypatch: pytest.MonkeyPatch,
- sqlite_session: Session,
- sqlite_session_factory: sessionmaker[Session],
-) -> None:
- _set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
- existing_state = _state()
- existing_state.manually_enabled_workspace_ids = ["workspace-1"]
- lifecycle_events: list[str] = []
-
- @event.listens_for(sqlite_session, "before_flush", once=True)
- def add_conflicting_pending_state(session: Session, _flush_context, _instances) -> None:
- lifecycle_events.append("before_flush")
- session.add(AccountStepByStepTourState(account_id="account-1"))
-
- @event.listens_for(sqlite_session, "after_soft_rollback", once=True)
- def persist_winning_request(_session: Session, _previous_transaction) -> None:
- lifecycle_events.append("after_soft_rollback")
- with sqlite_session_factory() as winner:
- winner.add(existing_state)
- winner.commit()
-
- result = StepByStepTourService.patch_state(
- account=_account(initialized_at=datetime(2026, 6, 28)),
- current_tenant_id="workspace-2",
- patch={"action": "enable_current_workspace"},
- session=sqlite_session,
- )
-
- assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"]
- assert lifecycle_events == ["before_flush", "after_soft_rollback"]
- with sqlite_session_factory() as observer:
- persisted = _load_state(observer)
- assert persisted is not None
- assert persisted.manually_enabled_workspace_ids == ["workspace-1", "workspace-2"]
+ with pytest.raises(RuntimeError, match="unknown account"):
+ service.get_state(_context())