feat: implement onboarding journeys

This commit is contained in:
tegwick 2026-06-15 23:24:59 +02:00
parent 660ce24995
commit 5d7685dc8d
15 changed files with 1605 additions and 17 deletions

View file

@ -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",