generated from coulomb/repo-seed
feat: implement onboarding journeys
This commit is contained in:
@@ -22,6 +22,7 @@ from user_engine.domain import (
|
||||
FamilyInvitation,
|
||||
IdentityFactor,
|
||||
Membership,
|
||||
OnboardingJourney,
|
||||
OutboxEvent,
|
||||
PreparedAccount,
|
||||
ProfileScope,
|
||||
@@ -29,6 +30,7 @@ from user_engine.domain import (
|
||||
RegistrationSession,
|
||||
TenantAccount,
|
||||
User,
|
||||
WelcomeProtocol,
|
||||
)
|
||||
|
||||
SCHEMA_VERSION = "0001_initial"
|
||||
@@ -62,6 +64,8 @@ class InMemoryUserEngineStore:
|
||||
active_access_contexts: dict[
|
||||
tuple[str, str], ActiveAccessContext
|
||||
] = field(default_factory=dict)
|
||||
welcome_protocols: dict[str, WelcomeProtocol] = field(default_factory=dict)
|
||||
onboarding_journeys: dict[str, OnboardingJourney] = field(default_factory=dict)
|
||||
profile_values: dict[
|
||||
tuple[str, str, ProfileScope, str | None], ProfileValue
|
||||
] = field(default_factory=dict)
|
||||
@@ -234,6 +238,45 @@ class InMemoryUserEngineStore:
|
||||
if context.tenant == tenant
|
||||
)
|
||||
|
||||
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
|
||||
self.welcome_protocols[protocol.protocol_id] = protocol
|
||||
|
||||
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
|
||||
return self.welcome_protocols.get(protocol_id)
|
||||
|
||||
def welcome_protocols_for_tenant(
|
||||
self, tenant: str
|
||||
) -> tuple[WelcomeProtocol, ...]:
|
||||
return tuple(
|
||||
protocol
|
||||
for protocol in self.welcome_protocols.values()
|
||||
if protocol.tenant == tenant
|
||||
)
|
||||
|
||||
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
|
||||
self.onboarding_journeys[journey.journey_id] = journey
|
||||
|
||||
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
|
||||
return self.onboarding_journeys.get(journey_id)
|
||||
|
||||
def onboarding_journeys_for_user(
|
||||
self, user_id: str, *, tenant: str | None = None
|
||||
) -> tuple[OnboardingJourney, ...]:
|
||||
return tuple(
|
||||
journey
|
||||
for journey in self.onboarding_journeys.values()
|
||||
if journey.user_id == user_id and (tenant is None or journey.tenant == tenant)
|
||||
)
|
||||
|
||||
def onboarding_journeys_for_tenant(
|
||||
self, tenant: str
|
||||
) -> tuple[OnboardingJourney, ...]:
|
||||
return tuple(
|
||||
journey
|
||||
for journey in self.onboarding_journeys.values()
|
||||
if journey.tenant == tenant
|
||||
)
|
||||
|
||||
def save_profile_value(self, value: ProfileValue) -> None:
|
||||
self.profile_values[
|
||||
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
||||
@@ -296,6 +339,8 @@ class InMemoryUserEngineStore:
|
||||
"prepared_accounts": len(self.prepared_accounts),
|
||||
"access_profiles": len(self.access_profiles),
|
||||
"active_access_contexts": len(self.active_access_contexts),
|
||||
"welcome_protocols": len(self.welcome_protocols),
|
||||
"onboarding_journeys": len(self.onboarding_journeys),
|
||||
"profile_values": len(self.profile_values),
|
||||
"audit_records": len(self.audit_records),
|
||||
"pending_outbox_events": len(self.outbox_events),
|
||||
@@ -317,6 +362,8 @@ class InMemoryUserEngineStore:
|
||||
"prepared_accounts": copy.deepcopy(self.prepared_accounts),
|
||||
"access_profiles": copy.deepcopy(self.access_profiles),
|
||||
"active_access_contexts": copy.deepcopy(self.active_access_contexts),
|
||||
"welcome_protocols": copy.deepcopy(self.welcome_protocols),
|
||||
"onboarding_journeys": copy.deepcopy(self.onboarding_journeys),
|
||||
"profile_values": copy.deepcopy(self.profile_values),
|
||||
"audit_records": copy.deepcopy(self.audit_records),
|
||||
"outbox_events": copy.deepcopy(self.outbox_events),
|
||||
@@ -349,6 +396,10 @@ class InMemoryUserEngineStore:
|
||||
self.active_access_contexts = snapshot[
|
||||
"active_access_contexts"
|
||||
] # type: ignore[assignment]
|
||||
self.welcome_protocols = snapshot["welcome_protocols"] # type: ignore[assignment]
|
||||
self.onboarding_journeys = snapshot[
|
||||
"onboarding_journeys"
|
||||
] # type: ignore[assignment]
|
||||
self.profile_values = snapshot["profile_values"] # type: ignore[assignment]
|
||||
self.audit_records = [*snapshot_audit_records, *denied_audit_records]
|
||||
self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment]
|
||||
|
||||
@@ -32,6 +32,12 @@ from user_engine.domain.models import (
|
||||
ManagementMode,
|
||||
Membership,
|
||||
Mutability,
|
||||
OnboardingJourney,
|
||||
OnboardingJourneyStatus,
|
||||
OnboardingStep,
|
||||
OnboardingStepStatus,
|
||||
OnboardingTask,
|
||||
OnboardingTriggerType,
|
||||
OutboxEvent,
|
||||
PrincipalType,
|
||||
PreparedAccount,
|
||||
@@ -45,9 +51,12 @@ from user_engine.domain.models import (
|
||||
RegistrationSession,
|
||||
RegistrationStatus,
|
||||
Sensitivity,
|
||||
SubsystemHandoff,
|
||||
TenantAccount,
|
||||
User,
|
||||
Visibility,
|
||||
WelcomeProtocol,
|
||||
WelcomeProtocolStep,
|
||||
new_id,
|
||||
utc_now,
|
||||
)
|
||||
@@ -84,6 +93,12 @@ __all__ = [
|
||||
"ManagementMode",
|
||||
"Membership",
|
||||
"Mutability",
|
||||
"OnboardingJourney",
|
||||
"OnboardingJourneyStatus",
|
||||
"OnboardingStep",
|
||||
"OnboardingStepStatus",
|
||||
"OnboardingTask",
|
||||
"OnboardingTriggerType",
|
||||
"OutboxEvent",
|
||||
"PrincipalType",
|
||||
"PreparedAccount",
|
||||
@@ -97,9 +112,12 @@ __all__ = [
|
||||
"RegistrationSession",
|
||||
"RegistrationStatus",
|
||||
"Sensitivity",
|
||||
"SubsystemHandoff",
|
||||
"TenantAccount",
|
||||
"User",
|
||||
"Visibility",
|
||||
"WelcomeProtocol",
|
||||
"WelcomeProtocolStep",
|
||||
"new_id",
|
||||
"utc_now",
|
||||
]
|
||||
|
||||
@@ -101,6 +101,32 @@ class AccessScopeType(StrEnum):
|
||||
GROUP = "group"
|
||||
|
||||
|
||||
class OnboardingTriggerType(StrEnum):
|
||||
REGISTRATION = "registration"
|
||||
PREPARED_ACCOUNT = "prepared_account"
|
||||
INVITATION = "invitation"
|
||||
ACCESS_PROFILE = "access_profile"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class OnboardingJourneyStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
BLOCKED = "blocked"
|
||||
COMPLETED = "completed"
|
||||
SKIPPED = "skipped"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class OnboardingStepStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
BLOCKED = "blocked"
|
||||
COMPLETED = "completed"
|
||||
SKIPPED = "skipped"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ProfileScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
TENANT = "tenant"
|
||||
@@ -375,6 +401,91 @@ class AccessControlFact:
|
||||
source_system: str = "user-engine"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnboardingTask:
|
||||
subsystem: str
|
||||
task_kind: str
|
||||
task_id: str = field(default_factory=lambda: new_id("otask"))
|
||||
external_ref: str | None = None
|
||||
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubsystemHandoff:
|
||||
subsystem: str
|
||||
handoff_id: str = field(default_factory=lambda: new_id("hnd"))
|
||||
callback_ref: str | None = None
|
||||
lifecycle_gap: str | None = None
|
||||
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WelcomeProtocolStep:
|
||||
step_key: str
|
||||
title: str
|
||||
subsystem: str
|
||||
task_kind: str = "welcome"
|
||||
task_ref: str | None = None
|
||||
callback_ref: str | None = None
|
||||
support_ref: str | None = None
|
||||
requires_subsystem_callback: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WelcomeProtocol:
|
||||
tenant: str
|
||||
name: str
|
||||
trigger_type: OnboardingTriggerType
|
||||
steps: tuple[WelcomeProtocolStep, ...]
|
||||
protocol_id: str = field(default_factory=lambda: new_id("wpro"))
|
||||
journey_key: str | None = None
|
||||
registration_status: RegistrationStatus | None = None
|
||||
prepared_journey: str | None = None
|
||||
application_id: str | None = None
|
||||
realm_id: str | None = None
|
||||
service_id: str | None = None
|
||||
role: str | None = None
|
||||
hat: str | None = None
|
||||
required_factor_types: tuple[IdentityFactorType, ...] = ()
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnboardingStep:
|
||||
step_key: str
|
||||
title: str
|
||||
subsystem: str
|
||||
step_id: str = field(default_factory=lambda: new_id("ostep"))
|
||||
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
|
||||
task: OnboardingTask | None = None
|
||||
handoff: SubsystemHandoff | None = None
|
||||
support_ref: str | None = None
|
||||
lifecycle_gap: str | None = None
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnboardingJourney:
|
||||
tenant: str
|
||||
user_id: str
|
||||
protocol_id: str
|
||||
trigger_type: OnboardingTriggerType
|
||||
steps: tuple[OnboardingStep, ...]
|
||||
journey_id: str = field(default_factory=lambda: new_id("ojrn"))
|
||||
status: OnboardingJourneyStatus = OnboardingJourneyStatus.PENDING
|
||||
source_id: str | None = None
|
||||
source_event_type: str | None = None
|
||||
journey_key: str | None = None
|
||||
active_step_key: str | None = None
|
||||
correlation_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
completed_at: datetime | None = None
|
||||
failed_at: datetime | None = None
|
||||
skipped_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyMemberSpec:
|
||||
primary_email: str
|
||||
|
||||
@@ -28,12 +28,14 @@ from user_engine.domain import (
|
||||
FamilyInvitation,
|
||||
IdentityFactor,
|
||||
Membership,
|
||||
OnboardingJourney,
|
||||
OutboxEvent,
|
||||
PreparedAccount,
|
||||
ProfileValue,
|
||||
RegistrationSession,
|
||||
TenantAccount,
|
||||
User,
|
||||
WelcomeProtocol,
|
||||
)
|
||||
|
||||
|
||||
@@ -185,6 +187,33 @@ class UserEngineStore(Protocol):
|
||||
) -> tuple[ActiveAccessContext, ...]:
|
||||
"""Return active access contexts for a tenant."""
|
||||
|
||||
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
|
||||
"""Create or replace a welcome protocol template."""
|
||||
|
||||
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
|
||||
"""Return a welcome protocol template by id."""
|
||||
|
||||
def welcome_protocols_for_tenant(
|
||||
self, tenant: str
|
||||
) -> tuple[WelcomeProtocol, ...]:
|
||||
"""Return welcome protocol templates for a tenant."""
|
||||
|
||||
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
|
||||
"""Create or replace an onboarding journey."""
|
||||
|
||||
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
|
||||
"""Return an onboarding journey by id."""
|
||||
|
||||
def onboarding_journeys_for_user(
|
||||
self, user_id: str, *, tenant: str | None = None
|
||||
) -> tuple[OnboardingJourney, ...]:
|
||||
"""Return onboarding journeys for a user."""
|
||||
|
||||
def onboarding_journeys_for_tenant(
|
||||
self, tenant: str
|
||||
) -> tuple[OnboardingJourney, ...]:
|
||||
"""Return onboarding journeys for a tenant."""
|
||||
|
||||
def save_profile_value(self, value: ProfileValue) -> None:
|
||||
"""Create or replace a profile value."""
|
||||
|
||||
@@ -260,6 +289,41 @@ class AccessControlFactExporter(Protocol):
|
||||
"""Return an adapter-neutral access-control fact manifest."""
|
||||
|
||||
|
||||
class OnboardingNotificationPort(Protocol):
|
||||
"""Notify a delivery system about onboarding journey state."""
|
||||
|
||||
def notify(self, journey: OnboardingJourney) -> Mapping[str, Any]:
|
||||
"""Return adapter metadata for a notification request."""
|
||||
|
||||
|
||||
class OnboardingTaskPort(Protocol):
|
||||
"""Create or link external lifecycle tasks for onboarding steps."""
|
||||
|
||||
def link_task(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
|
||||
"""Return task-link metadata for one onboarding step."""
|
||||
|
||||
|
||||
class SupportContentPort(Protocol):
|
||||
"""Resolve support or help content references for onboarding."""
|
||||
|
||||
def content_ref(self, protocol: WelcomeProtocol, step_key: str) -> str | None:
|
||||
"""Return an adapter-owned content reference for a protocol step."""
|
||||
|
||||
|
||||
class SubsystemWelcomePort(Protocol):
|
||||
"""Call a protected subsystem welcome callback."""
|
||||
|
||||
def start(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
|
||||
"""Return callback metadata for a subsystem welcome step."""
|
||||
|
||||
|
||||
class LifecycleTaskLinkPort(Protocol):
|
||||
"""Link onboarding journeys to external lifecycle task systems."""
|
||||
|
||||
def link(self, journey: OnboardingJourney) -> Mapping[str, Any]:
|
||||
"""Return lifecycle task references for an onboarding journey."""
|
||||
|
||||
|
||||
class EventOutbox(Protocol):
|
||||
"""Persist and publish durable domain events."""
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ from user_engine.domain import (
|
||||
InvitationStatus,
|
||||
Membership,
|
||||
Mutability,
|
||||
OnboardingJourney,
|
||||
OnboardingJourneyStatus,
|
||||
OnboardingStep,
|
||||
OnboardingStepStatus,
|
||||
OnboardingTask,
|
||||
OnboardingTriggerType,
|
||||
OutboxEvent,
|
||||
PreparedAccount,
|
||||
PreparedAccountStatus,
|
||||
@@ -47,9 +53,12 @@ from user_engine.domain import (
|
||||
RegistrationSession,
|
||||
RegistrationStatus,
|
||||
Sensitivity,
|
||||
SubsystemHandoff,
|
||||
TenantAccount,
|
||||
User,
|
||||
Visibility,
|
||||
WelcomeProtocol,
|
||||
WelcomeProtocolStep,
|
||||
new_id,
|
||||
utc_now,
|
||||
)
|
||||
@@ -142,6 +151,7 @@ class IdentityContext:
|
||||
gaps: tuple[str, ...] = ()
|
||||
active_access_context: ActiveAccessContext | None = None
|
||||
access_control_facts: tuple[AccessControlFact, ...] = ()
|
||||
onboarding_journeys: tuple[OnboardingJourney, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -167,6 +177,22 @@ class AccessProfileDiagnostics:
|
||||
issues: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnboardingJourneyStart:
|
||||
journey: OnboardingJourney
|
||||
protocol: WelcomeProtocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnboardingDiagnostics:
|
||||
tenant: str
|
||||
protocol_count: int
|
||||
journey_count: int
|
||||
statuses: Mapping[str, int]
|
||||
blocked_steps: tuple[str, ...]
|
||||
lifecycle_gaps: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyMemberInvitation:
|
||||
user: User
|
||||
@@ -549,6 +575,16 @@ class UserEngineService:
|
||||
),
|
||||
},
|
||||
)
|
||||
self._auto_start_onboarding_journeys(
|
||||
actor,
|
||||
user.user_id,
|
||||
self._matching_welcome_protocols_for_registration(completed),
|
||||
trigger_type=OnboardingTriggerType.REGISTRATION,
|
||||
source_id=session.registration_id,
|
||||
source_event_type="registration.completed",
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
)
|
||||
identity_context = self.identity_context(
|
||||
actor,
|
||||
user_id=user.user_id,
|
||||
@@ -979,6 +1015,16 @@ class UserEngineService:
|
||||
"journey": journey,
|
||||
},
|
||||
)
|
||||
self._auto_start_onboarding_journeys(
|
||||
actor,
|
||||
user.user_id,
|
||||
self._matching_welcome_protocols_for_prepared_account(claimed),
|
||||
trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT,
|
||||
source_id=prepared.prepared_account_id,
|
||||
source_event_type="prepared_account.claimed",
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
)
|
||||
return PreparedAccountClaim(
|
||||
prepared_account=claimed,
|
||||
registration=session,
|
||||
@@ -1254,6 +1300,350 @@ class UserEngineService:
|
||||
issues=tuple(issues),
|
||||
)
|
||||
|
||||
def register_welcome_protocol(
|
||||
self,
|
||||
actor: Actor,
|
||||
protocol: WelcomeProtocol,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> WelcomeProtocol:
|
||||
tenant_context = self.resolve_tenant_context(actor, protocol.tenant)
|
||||
self._validate_welcome_protocol(protocol, tenant_context.tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="welcome_protocol.write",
|
||||
resource_type="user-engine:welcome-protocol",
|
||||
resource_id=protocol.protocol_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
context={
|
||||
"trigger_type": protocol.trigger_type.value,
|
||||
"journey_key": protocol.journey_key,
|
||||
"step_count": len(protocol.steps),
|
||||
},
|
||||
)
|
||||
updated = replace(protocol, tenant=tenant_context.tenant, updated_at=utc_now())
|
||||
with self.store.transaction():
|
||||
self.store.save_welcome_protocol(updated)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="welcome_protocol.write",
|
||||
subject=updated.protocol_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="welcome_protocol.registered",
|
||||
aggregate_id=updated.protocol_id,
|
||||
payload={
|
||||
"protocol_id": updated.protocol_id,
|
||||
"trigger_type": updated.trigger_type,
|
||||
"journey_key": updated.journey_key,
|
||||
"step_count": len(updated.steps),
|
||||
},
|
||||
)
|
||||
return updated
|
||||
|
||||
def list_welcome_protocols(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
tenant: str,
|
||||
correlation_id: str | None = None,
|
||||
) -> tuple[WelcomeProtocol, ...]:
|
||||
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
self._authorize(
|
||||
actor,
|
||||
action="welcome_protocol.read",
|
||||
resource_type="user-engine:welcome-protocol",
|
||||
resource_id=tenant_context.tenant,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self.store.welcome_protocols_for_tenant(tenant_context.tenant)
|
||||
|
||||
def start_onboarding_journey(
|
||||
self,
|
||||
actor: Actor,
|
||||
user_id: str,
|
||||
protocol_id: str,
|
||||
*,
|
||||
trigger_type: OnboardingTriggerType = OnboardingTriggerType.MANUAL,
|
||||
source_id: str | None = None,
|
||||
source_event_type: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> OnboardingJourneyStart:
|
||||
protocol = self._require_welcome_protocol(protocol_id)
|
||||
tenant_context = self.resolve_tenant_context(actor, protocol.tenant)
|
||||
user = self._require_user(user_id)
|
||||
if self._onboarding_journey_exists(user.user_id, protocol.protocol_id, source_id):
|
||||
raise ConflictError("onboarding journey already exists for source")
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="onboarding_journey.start",
|
||||
resource_type="user-engine:onboarding-journey",
|
||||
resource_id=protocol.protocol_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
target_user_id=user.user_id,
|
||||
context={
|
||||
"protocol_id": protocol.protocol_id,
|
||||
"trigger_type": trigger_type.value,
|
||||
"source_event_type": source_event_type,
|
||||
},
|
||||
)
|
||||
journey = self._build_onboarding_journey(
|
||||
protocol,
|
||||
user.user_id,
|
||||
trigger_type=trigger_type,
|
||||
source_id=source_id,
|
||||
source_event_type=source_event_type,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
with self.store.transaction():
|
||||
self.store.save_onboarding_journey(journey)
|
||||
self._record_onboarding_journey_started(
|
||||
actor,
|
||||
journey,
|
||||
decision_id=decision.decision_id,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return OnboardingJourneyStart(journey=journey, protocol=protocol)
|
||||
|
||||
def start_onboarding_for_registration(
|
||||
self,
|
||||
actor: Actor,
|
||||
registration_id: str,
|
||||
*,
|
||||
protocol_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> tuple[OnboardingJourneyStart, ...]:
|
||||
session = self._require_registration_session(registration_id)
|
||||
if session.status != RegistrationStatus.COMPLETED or session.user_id is None:
|
||||
raise ValidationError("registration must be completed before onboarding")
|
||||
protocols = (
|
||||
(self._require_welcome_protocol(protocol_id),)
|
||||
if protocol_id is not None
|
||||
else self._matching_welcome_protocols_for_registration(session)
|
||||
)
|
||||
starts = []
|
||||
for protocol in protocols:
|
||||
starts.append(
|
||||
self.start_onboarding_journey(
|
||||
actor,
|
||||
session.user_id,
|
||||
protocol.protocol_id,
|
||||
trigger_type=OnboardingTriggerType.REGISTRATION,
|
||||
source_id=session.registration_id,
|
||||
source_event_type="registration.completed",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
)
|
||||
return tuple(starts)
|
||||
|
||||
def start_onboarding_for_prepared_account(
|
||||
self,
|
||||
actor: Actor,
|
||||
prepared_account_id: str,
|
||||
*,
|
||||
protocol_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> tuple[OnboardingJourneyStart, ...]:
|
||||
prepared = self._require_prepared_account(prepared_account_id)
|
||||
if (
|
||||
prepared.status != PreparedAccountStatus.CLAIMED
|
||||
or prepared.claimed_by_user_id is None
|
||||
):
|
||||
raise ValidationError("prepared account must be claimed before onboarding")
|
||||
protocols = (
|
||||
(self._require_welcome_protocol(protocol_id),)
|
||||
if protocol_id is not None
|
||||
else self._matching_welcome_protocols_for_prepared_account(prepared)
|
||||
)
|
||||
starts = []
|
||||
for protocol in protocols:
|
||||
starts.append(
|
||||
self.start_onboarding_journey(
|
||||
actor,
|
||||
prepared.claimed_by_user_id,
|
||||
protocol.protocol_id,
|
||||
trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT,
|
||||
source_id=prepared.prepared_account_id,
|
||||
source_event_type="prepared_account.claimed",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
)
|
||||
return tuple(starts)
|
||||
|
||||
def progress_onboarding_step(
|
||||
self,
|
||||
actor: Actor,
|
||||
journey_id: str,
|
||||
step_key: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> OnboardingJourney:
|
||||
return self._transition_onboarding_step(
|
||||
actor,
|
||||
journey_id,
|
||||
step_key,
|
||||
status=OnboardingStepStatus.IN_PROGRESS,
|
||||
action="onboarding_step.progress",
|
||||
event_type="onboarding_step.progressed",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def complete_onboarding_step(
|
||||
self,
|
||||
actor: Actor,
|
||||
journey_id: str,
|
||||
step_key: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> OnboardingJourney:
|
||||
return self._transition_onboarding_step(
|
||||
actor,
|
||||
journey_id,
|
||||
step_key,
|
||||
status=OnboardingStepStatus.COMPLETED,
|
||||
action="onboarding_step.complete",
|
||||
event_type="onboarding_step.completed",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def skip_onboarding_step(
|
||||
self,
|
||||
actor: Actor,
|
||||
journey_id: str,
|
||||
step_key: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> OnboardingJourney:
|
||||
return self._transition_onboarding_step(
|
||||
actor,
|
||||
journey_id,
|
||||
step_key,
|
||||
status=OnboardingStepStatus.SKIPPED,
|
||||
action="onboarding_step.skip",
|
||||
event_type="onboarding_step.skipped",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def fail_onboarding_step(
|
||||
self,
|
||||
actor: Actor,
|
||||
journey_id: str,
|
||||
step_key: str,
|
||||
*,
|
||||
lifecycle_gap: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> OnboardingJourney:
|
||||
return self._transition_onboarding_step(
|
||||
actor,
|
||||
journey_id,
|
||||
step_key,
|
||||
status=OnboardingStepStatus.FAILED,
|
||||
action="onboarding_step.fail",
|
||||
event_type="onboarding_step.failed",
|
||||
lifecycle_gap=lifecycle_gap,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def resume_onboarding_journey(
|
||||
self,
|
||||
actor: Actor,
|
||||
journey_id: str,
|
||||
*,
|
||||
callback_refs: Mapping[str, str] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> OnboardingJourney:
|
||||
journey = self._require_onboarding_journey(journey_id)
|
||||
tenant_context = self.resolve_tenant_context(actor, journey.tenant)
|
||||
if journey.status not in (
|
||||
OnboardingJourneyStatus.BLOCKED,
|
||||
OnboardingJourneyStatus.FAILED,
|
||||
OnboardingJourneyStatus.PENDING,
|
||||
):
|
||||
raise ValidationError("only pending, blocked, or failed journeys can resume")
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="onboarding_journey.resume",
|
||||
resource_type="user-engine:onboarding-journey",
|
||||
resource_id=journey.journey_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
target_user_id=journey.user_id,
|
||||
)
|
||||
updated_steps = _resume_onboarding_steps(journey.steps, callback_refs or {})
|
||||
updated = replace(
|
||||
journey,
|
||||
steps=updated_steps,
|
||||
status=_journey_status_from_steps(updated_steps),
|
||||
active_step_key=_active_onboarding_step_key(updated_steps),
|
||||
updated_at=utc_now(),
|
||||
failed_at=None,
|
||||
)
|
||||
with self.store.transaction():
|
||||
self.store.save_onboarding_journey(updated)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="onboarding_journey.resume",
|
||||
subject=updated.journey_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="onboarding_journey.resumed",
|
||||
aggregate_id=updated.user_id,
|
||||
payload={
|
||||
"journey_id": updated.journey_id,
|
||||
"user_id": updated.user_id,
|
||||
"status": updated.status,
|
||||
"active_step_key": updated.active_step_key,
|
||||
},
|
||||
)
|
||||
return updated
|
||||
|
||||
def onboarding_diagnostics(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
tenant: str,
|
||||
correlation_id: str | None = None,
|
||||
) -> OnboardingDiagnostics:
|
||||
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
self._authorize(
|
||||
actor,
|
||||
action="onboarding.diagnostics.read",
|
||||
resource_type="user-engine:onboarding",
|
||||
resource_id=tenant_context.tenant,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
protocols = self.store.welcome_protocols_for_tenant(tenant_context.tenant)
|
||||
journeys = self.store.onboarding_journeys_for_tenant(tenant_context.tenant)
|
||||
statuses: dict[str, int] = {}
|
||||
blocked_steps = []
|
||||
lifecycle_gaps = []
|
||||
for journey in journeys:
|
||||
statuses[journey.status.value] = statuses.get(journey.status.value, 0) + 1
|
||||
for step in journey.steps:
|
||||
if step.status == OnboardingStepStatus.BLOCKED:
|
||||
blocked_steps.append(f"{journey.journey_id}:{step.step_key}")
|
||||
if step.lifecycle_gap:
|
||||
lifecycle_gaps.append(step.lifecycle_gap)
|
||||
return OnboardingDiagnostics(
|
||||
tenant=tenant_context.tenant,
|
||||
protocol_count=len(protocols),
|
||||
journey_count=len(journeys),
|
||||
statuses=statuses,
|
||||
blocked_steps=tuple(blocked_steps),
|
||||
lifecycle_gaps=tuple(sorted(set(lifecycle_gaps))),
|
||||
)
|
||||
|
||||
def me(
|
||||
self,
|
||||
claims: Mapping[str, Any],
|
||||
@@ -1803,6 +2193,9 @@ class UserEngineService:
|
||||
memberships=memberships,
|
||||
active_context=active_access_context,
|
||||
)
|
||||
onboarding_journeys = self.store.onboarding_journeys_for_user(
|
||||
user.user_id, tenant=tenant_context.tenant
|
||||
)
|
||||
entity_refs = self._identity_entity_refs(
|
||||
actor,
|
||||
user,
|
||||
@@ -1844,6 +2237,7 @@ class UserEngineService:
|
||||
gaps=gaps,
|
||||
active_access_context=active_access_context,
|
||||
access_control_facts=access_control_facts,
|
||||
onboarding_journeys=onboarding_journeys,
|
||||
)
|
||||
|
||||
def onboard_family_dataspace(
|
||||
@@ -2702,6 +3096,241 @@ class UserEngineService:
|
||||
)
|
||||
)
|
||||
|
||||
def _require_welcome_protocol(self, protocol_id: str) -> WelcomeProtocol:
|
||||
protocol = self.store.welcome_protocol(protocol_id)
|
||||
if protocol is None:
|
||||
raise NotFoundError("welcome protocol not found")
|
||||
return protocol
|
||||
|
||||
def _require_onboarding_journey(self, journey_id: str) -> OnboardingJourney:
|
||||
journey = self.store.onboarding_journey(journey_id)
|
||||
if journey is None:
|
||||
raise NotFoundError("onboarding journey not found")
|
||||
return journey
|
||||
|
||||
def _validate_welcome_protocol(
|
||||
self, protocol: WelcomeProtocol, tenant: str
|
||||
) -> None:
|
||||
if protocol.tenant != tenant:
|
||||
raise ValidationError("welcome protocol tenant must match context")
|
||||
if not protocol.name.strip():
|
||||
raise ValidationError("welcome protocol name is required")
|
||||
if not protocol.steps:
|
||||
raise ValidationError("welcome protocol requires at least one step")
|
||||
step_keys = [step.step_key for step in protocol.steps]
|
||||
if len(step_keys) != len(set(step_keys)):
|
||||
raise ValidationError("welcome protocol step keys must be unique")
|
||||
for step in protocol.steps:
|
||||
if not step.step_key.strip() or not step.title.strip():
|
||||
raise ValidationError("welcome protocol steps require key and title")
|
||||
if not step.subsystem.strip():
|
||||
raise ValidationError("welcome protocol steps require subsystem")
|
||||
|
||||
def _build_onboarding_journey(
|
||||
self,
|
||||
protocol: WelcomeProtocol,
|
||||
user_id: str,
|
||||
*,
|
||||
trigger_type: OnboardingTriggerType,
|
||||
source_id: str | None,
|
||||
source_event_type: str | None,
|
||||
correlation_id: str,
|
||||
) -> OnboardingJourney:
|
||||
if trigger_type != OnboardingTriggerType.MANUAL and (
|
||||
protocol.trigger_type != trigger_type
|
||||
):
|
||||
raise ValidationError("welcome protocol trigger does not match journey")
|
||||
steps = tuple(_onboarding_step_from_template(step) for step in protocol.steps)
|
||||
steps = _activate_initial_onboarding_step(steps)
|
||||
status = _journey_status_from_steps(steps)
|
||||
return OnboardingJourney(
|
||||
tenant=protocol.tenant,
|
||||
user_id=user_id,
|
||||
protocol_id=protocol.protocol_id,
|
||||
trigger_type=trigger_type,
|
||||
steps=steps,
|
||||
status=status,
|
||||
source_id=source_id,
|
||||
source_event_type=source_event_type,
|
||||
journey_key=protocol.journey_key or protocol.prepared_journey,
|
||||
active_step_key=_active_onboarding_step_key(steps),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _auto_start_onboarding_journeys(
|
||||
self,
|
||||
actor: Actor,
|
||||
user_id: str,
|
||||
protocols: tuple[WelcomeProtocol, ...],
|
||||
*,
|
||||
trigger_type: OnboardingTriggerType,
|
||||
source_id: str,
|
||||
source_event_type: str,
|
||||
correlation_id: str,
|
||||
decision_id: str | None,
|
||||
) -> tuple[OnboardingJourney, ...]:
|
||||
journeys = []
|
||||
for protocol in protocols:
|
||||
if self._onboarding_journey_exists(
|
||||
user_id,
|
||||
protocol.protocol_id,
|
||||
source_id,
|
||||
):
|
||||
continue
|
||||
journey = self._build_onboarding_journey(
|
||||
protocol,
|
||||
user_id,
|
||||
trigger_type=trigger_type,
|
||||
source_id=source_id,
|
||||
source_event_type=source_event_type,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.store.save_onboarding_journey(journey)
|
||||
self._record_onboarding_journey_started(
|
||||
actor,
|
||||
journey,
|
||||
decision_id=decision_id,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
journeys.append(journey)
|
||||
return tuple(journeys)
|
||||
|
||||
def _record_onboarding_journey_started(
|
||||
self,
|
||||
actor: Actor,
|
||||
journey: OnboardingJourney,
|
||||
*,
|
||||
decision_id: str | None,
|
||||
correlation_id: str,
|
||||
) -> None:
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="onboarding_journey.start",
|
||||
subject=journey.journey_id,
|
||||
tenant=journey.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision_id,
|
||||
event_type="onboarding_journey.started",
|
||||
aggregate_id=journey.user_id,
|
||||
payload={
|
||||
"journey_id": journey.journey_id,
|
||||
"user_id": journey.user_id,
|
||||
"protocol_id": journey.protocol_id,
|
||||
"trigger_type": journey.trigger_type,
|
||||
"status": journey.status,
|
||||
"source_id": journey.source_id,
|
||||
"source_event_type": journey.source_event_type,
|
||||
"active_step_key": journey.active_step_key,
|
||||
"lifecycle_gaps": tuple(
|
||||
step.lifecycle_gap for step in journey.steps if step.lifecycle_gap
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def _matching_welcome_protocols_for_registration(
|
||||
self, session: RegistrationSession
|
||||
) -> tuple[WelcomeProtocol, ...]:
|
||||
factors = self.store.factors_for_registration(session.registration_id)
|
||||
factor_types = {factor.factor_type for factor in factors}
|
||||
return tuple(
|
||||
protocol
|
||||
for protocol in self.store.welcome_protocols_for_tenant(session.tenant)
|
||||
if protocol.trigger_type == OnboardingTriggerType.REGISTRATION
|
||||
and (
|
||||
protocol.registration_status is None
|
||||
or protocol.registration_status == session.status
|
||||
)
|
||||
and set(protocol.required_factor_types).issubset(factor_types)
|
||||
)
|
||||
|
||||
def _matching_welcome_protocols_for_prepared_account(
|
||||
self, prepared: PreparedAccount
|
||||
) -> tuple[WelcomeProtocol, ...]:
|
||||
prepared_journeys = {
|
||||
entitlement.onboarding_journey
|
||||
for entitlement in prepared.entitlements
|
||||
if entitlement.onboarding_journey
|
||||
}
|
||||
return tuple(
|
||||
protocol
|
||||
for protocol in self.store.welcome_protocols_for_tenant(prepared.tenant)
|
||||
if protocol.trigger_type == OnboardingTriggerType.PREPARED_ACCOUNT
|
||||
and (
|
||||
not protocol.prepared_journey
|
||||
or protocol.prepared_journey in prepared_journeys
|
||||
)
|
||||
)
|
||||
|
||||
def _onboarding_journey_exists(
|
||||
self,
|
||||
user_id: str,
|
||||
protocol_id: str,
|
||||
source_id: str | None,
|
||||
) -> bool:
|
||||
return any(
|
||||
journey.protocol_id == protocol_id and journey.source_id == source_id
|
||||
for journey in self.store.onboarding_journeys_for_user(user_id)
|
||||
)
|
||||
|
||||
def _transition_onboarding_step(
|
||||
self,
|
||||
actor: Actor,
|
||||
journey_id: str,
|
||||
step_key: str,
|
||||
*,
|
||||
status: OnboardingStepStatus,
|
||||
action: str,
|
||||
event_type: str,
|
||||
lifecycle_gap: str | None = None,
|
||||
correlation_id: str | None,
|
||||
) -> OnboardingJourney:
|
||||
journey = self._require_onboarding_journey(journey_id)
|
||||
tenant_context = self.resolve_tenant_context(actor, journey.tenant)
|
||||
if journey.status in (
|
||||
OnboardingJourneyStatus.COMPLETED,
|
||||
OnboardingJourneyStatus.SKIPPED,
|
||||
):
|
||||
raise ValidationError("completed onboarding journeys cannot change")
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action=action,
|
||||
resource_type="user-engine:onboarding-step",
|
||||
resource_id=f"{journey.journey_id}:{step_key}",
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
target_user_id=journey.user_id,
|
||||
)
|
||||
updated_steps = _replace_onboarding_step_status(
|
||||
journey.steps,
|
||||
step_key,
|
||||
status=status,
|
||||
lifecycle_gap=lifecycle_gap,
|
||||
)
|
||||
updated = _replace_onboarding_journey_steps(journey, updated_steps)
|
||||
with self.store.transaction():
|
||||
self.store.save_onboarding_journey(updated)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action=action,
|
||||
subject=updated.journey_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type=event_type,
|
||||
aggregate_id=updated.user_id,
|
||||
payload={
|
||||
"journey_id": updated.journey_id,
|
||||
"user_id": updated.user_id,
|
||||
"step_key": step_key,
|
||||
"step_status": status,
|
||||
"journey_status": updated.status,
|
||||
"active_step_key": updated.active_step_key,
|
||||
"lifecycle_gap": lifecycle_gap,
|
||||
},
|
||||
)
|
||||
return updated
|
||||
|
||||
def _require_access_profile(self, access_profile_id: str) -> AccessProfile:
|
||||
profile = self.store.access_profile(access_profile_id)
|
||||
if profile is None:
|
||||
@@ -3869,6 +4498,201 @@ def _active_group_ids(
|
||||
return tuple(sorted(group_ids))
|
||||
|
||||
|
||||
def _onboarding_step_from_template(template: WelcomeProtocolStep) -> OnboardingStep:
|
||||
lifecycle_gap = (
|
||||
f"subsystem-callback-missing:{template.subsystem}:{template.step_key}"
|
||||
if template.requires_subsystem_callback and not template.callback_ref
|
||||
else None
|
||||
)
|
||||
initial_status = (
|
||||
OnboardingStepStatus.BLOCKED
|
||||
if lifecycle_gap is not None
|
||||
else OnboardingStepStatus.PENDING
|
||||
)
|
||||
task = OnboardingTask(
|
||||
subsystem=template.subsystem,
|
||||
task_kind=template.task_kind,
|
||||
external_ref=template.task_ref,
|
||||
status=initial_status,
|
||||
)
|
||||
handoff = SubsystemHandoff(
|
||||
subsystem=template.subsystem,
|
||||
callback_ref=template.callback_ref,
|
||||
lifecycle_gap=lifecycle_gap,
|
||||
status=initial_status,
|
||||
)
|
||||
return OnboardingStep(
|
||||
step_key=template.step_key,
|
||||
title=template.title,
|
||||
subsystem=template.subsystem,
|
||||
status=initial_status,
|
||||
task=task,
|
||||
handoff=handoff,
|
||||
support_ref=template.support_ref,
|
||||
lifecycle_gap=lifecycle_gap,
|
||||
)
|
||||
|
||||
|
||||
def _activate_initial_onboarding_step(
|
||||
steps: tuple[OnboardingStep, ...]
|
||||
) -> tuple[OnboardingStep, ...]:
|
||||
if any(step.status == OnboardingStepStatus.BLOCKED for step in steps):
|
||||
return steps
|
||||
for index, step in enumerate(steps):
|
||||
if step.status == OnboardingStepStatus.PENDING:
|
||||
return (
|
||||
*steps[:index],
|
||||
_onboarding_step_with_status(step, OnboardingStepStatus.IN_PROGRESS),
|
||||
*steps[index + 1 :],
|
||||
)
|
||||
return steps
|
||||
|
||||
|
||||
def _replace_onboarding_step_status(
|
||||
steps: tuple[OnboardingStep, ...],
|
||||
step_key: str,
|
||||
*,
|
||||
status: OnboardingStepStatus,
|
||||
lifecycle_gap: str | None,
|
||||
) -> tuple[OnboardingStep, ...]:
|
||||
updated = []
|
||||
found = False
|
||||
for step in steps:
|
||||
if step.step_key != step_key:
|
||||
updated.append(step)
|
||||
continue
|
||||
found = True
|
||||
updated.append(
|
||||
_onboarding_step_with_status(
|
||||
step,
|
||||
status,
|
||||
lifecycle_gap=lifecycle_gap,
|
||||
)
|
||||
)
|
||||
if not found:
|
||||
raise NotFoundError("onboarding step not found")
|
||||
return _activate_next_onboarding_step(tuple(updated))
|
||||
|
||||
|
||||
def _activate_next_onboarding_step(
|
||||
steps: tuple[OnboardingStep, ...]
|
||||
) -> tuple[OnboardingStep, ...]:
|
||||
if any(
|
||||
step.status
|
||||
in (OnboardingStepStatus.IN_PROGRESS, OnboardingStepStatus.BLOCKED)
|
||||
for step in steps
|
||||
):
|
||||
return steps
|
||||
for index, step in enumerate(steps):
|
||||
if step.status == OnboardingStepStatus.PENDING:
|
||||
return (
|
||||
*steps[:index],
|
||||
_onboarding_step_with_status(step, OnboardingStepStatus.IN_PROGRESS),
|
||||
*steps[index + 1 :],
|
||||
)
|
||||
return steps
|
||||
|
||||
|
||||
def _resume_onboarding_steps(
|
||||
steps: tuple[OnboardingStep, ...], callback_refs: Mapping[str, str]
|
||||
) -> tuple[OnboardingStep, ...]:
|
||||
updated = []
|
||||
for step in steps:
|
||||
if step.status not in (
|
||||
OnboardingStepStatus.BLOCKED,
|
||||
OnboardingStepStatus.FAILED,
|
||||
):
|
||||
updated.append(step)
|
||||
continue
|
||||
callback_ref = callback_refs.get(step.step_key)
|
||||
handoff = step.handoff
|
||||
if handoff is not None and callback_ref is not None:
|
||||
handoff = replace(
|
||||
handoff,
|
||||
callback_ref=callback_ref,
|
||||
lifecycle_gap=None,
|
||||
status=OnboardingStepStatus.IN_PROGRESS,
|
||||
)
|
||||
updated.append(
|
||||
_onboarding_step_with_status(
|
||||
step,
|
||||
OnboardingStepStatus.IN_PROGRESS,
|
||||
lifecycle_gap=None,
|
||||
handoff=handoff,
|
||||
)
|
||||
)
|
||||
return tuple(updated)
|
||||
|
||||
|
||||
def _onboarding_step_with_status(
|
||||
step: OnboardingStep,
|
||||
status: OnboardingStepStatus,
|
||||
*,
|
||||
lifecycle_gap: str | None = None,
|
||||
handoff: SubsystemHandoff | None = None,
|
||||
) -> OnboardingStep:
|
||||
task = replace(step.task, status=status) if step.task is not None else None
|
||||
resolved_handoff = handoff if handoff is not None else step.handoff
|
||||
if resolved_handoff is not None:
|
||||
resolved_handoff = replace(
|
||||
resolved_handoff,
|
||||
status=status,
|
||||
lifecycle_gap=lifecycle_gap,
|
||||
)
|
||||
return replace(
|
||||
step,
|
||||
status=status,
|
||||
task=task,
|
||||
handoff=resolved_handoff,
|
||||
lifecycle_gap=lifecycle_gap,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _replace_onboarding_journey_steps(
|
||||
journey: OnboardingJourney, steps: tuple[OnboardingStep, ...]
|
||||
) -> OnboardingJourney:
|
||||
now = utc_now()
|
||||
status = _journey_status_from_steps(steps)
|
||||
return replace(
|
||||
journey,
|
||||
steps=steps,
|
||||
status=status,
|
||||
active_step_key=_active_onboarding_step_key(steps),
|
||||
updated_at=now,
|
||||
completed_at=now if status == OnboardingJourneyStatus.COMPLETED else None,
|
||||
failed_at=now if status == OnboardingJourneyStatus.FAILED else journey.failed_at,
|
||||
skipped_at=now if status == OnboardingJourneyStatus.SKIPPED else None,
|
||||
)
|
||||
|
||||
|
||||
def _journey_status_from_steps(
|
||||
steps: tuple[OnboardingStep, ...]
|
||||
) -> OnboardingJourneyStatus:
|
||||
if any(step.status == OnboardingStepStatus.FAILED for step in steps):
|
||||
return OnboardingJourneyStatus.FAILED
|
||||
if any(step.status == OnboardingStepStatus.BLOCKED for step in steps):
|
||||
return OnboardingJourneyStatus.BLOCKED
|
||||
if all(step.status == OnboardingStepStatus.SKIPPED for step in steps):
|
||||
return OnboardingJourneyStatus.SKIPPED
|
||||
if all(
|
||||
step.status in (OnboardingStepStatus.COMPLETED, OnboardingStepStatus.SKIPPED)
|
||||
for step in steps
|
||||
):
|
||||
return OnboardingJourneyStatus.COMPLETED
|
||||
if any(step.status == OnboardingStepStatus.IN_PROGRESS for step in steps):
|
||||
return OnboardingJourneyStatus.IN_PROGRESS
|
||||
return OnboardingJourneyStatus.PENDING
|
||||
|
||||
|
||||
def _active_onboarding_step_key(steps: tuple[OnboardingStep, ...]) -> str | None:
|
||||
for status in (OnboardingStepStatus.IN_PROGRESS, OnboardingStepStatus.BLOCKED):
|
||||
for step in steps:
|
||||
if step.status == status:
|
||||
return step.step_key
|
||||
return None
|
||||
|
||||
|
||||
def _scope_concept(scope_type: str) -> str:
|
||||
concepts = {
|
||||
"team": "Team",
|
||||
|
||||
Reference in New Issue
Block a user