feat: implement onboarding journeys
This commit is contained in:
parent
660ce24995
commit
5d7685dc8d
15 changed files with 1605 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue