A campaign organizes published GuidanceContent into an ordered, audience-scoped, scheduled multi-step guided path and tracks independent progress per user. It is not the same as contextual guidance. Contextual guidance is “show help when a user reaches a page.” A campaign is “an operator actively publishes a set of steps to guide a class of users along a path.”
1. Two aggregate roots
GuidanceCampaign is the tenant-level aggregate root. It combines an ordered list of published GuidanceContent step IDs with audience-targeting rules and a scheduling window. GuidanceCampaignAssignment is the user-level assignment-and-progress aggregate root, unique on (TenantId, CampaignId, UserId) (index UX_GuidanceCampaignAssignment_Tenant_Campaign_User).
2. Campaign state machine
GuidanceCampaignStatus has four states:
| State | Meaning |
|---|---|
Draft | Under editing, not visible to end users |
Active | Activated, visible to users matching audience and schedule |
Paused | Delivery paused, assigned progress is retained |
Archived | Archived, no longer delivered |
Transitions are strictly guarded by the aggregate. A violation returns the corresponding Guidance.Campaign.* error:
CreateproducesDraft;Configure(steps, audience, schedule) is allowed only inDraftorPaused, otherwiseGuidance.Campaign.MustBeEditable;Activateis allowed only fromDraftorPaused, and rejects an expired schedule (Guidance.Campaign.ScheduleExpired);Pauseis allowed only fromActive(Guidance.Campaign.TransitionInvalid);Archiveis allowed from any non-archived state.
Every lifecycle change must carry a LastChangeReason (max 512 characters) as a bounded audit field.
3. Audience and schedule
Audience targeting does not rely on a single role string. It combines these fields:
| Field | Limit | Semantics |
|---|---|---|
TargetRoleNames | at most 50 | matching any role qualifies |
TargetUserIds | at most 200 | explicitly named users |
TargetsAllUsers | computed | true when both are empty, meaning the whole tenant |
IsEligible(userId, roles, now) combines state, scheduling window, and role/user membership into one decision. Scheduling is controlled by StartsAt (null means immediate) and EndsAt (null means no automatic expiry). The database CHECK constraint CK_GuidanceCampaign_Schedule enforces StartsAt < EndsAt. Activating a campaign whose window has already ended is rejected with Guidance.Campaign.ScheduleExpired.
On activation, the system pre-creates assignments for explicitly named target users (EnsureExplicitAssignmentsAsync). Campaigns targeting the whole tenant or by role create assignments implicitly on first user interaction (CreateAssignmentAsync).
4. Ordered multi-step and progress state machine
Steps are an ordered ContentIds list, capped at StepLimit = 20, each identifier at most 64 characters. User progress is described by GuidanceProgressStatus:
| State | Meaning |
|---|---|
NotStarted | Assigned but not started |
InProgress | At least one step completed, not all |
Completed | All steps completed |
Dismissed | User dismissed |
Progress is server-authoritative, not self-reported by the client:
CompleteStepis idempotent. Completing any step for the first time movesNotStartedtoInProgress; once all steps are completed it advances toCompleted. Completing the same step again does not error. Completing an already-completed campaign returnsGuidance.Campaign.AlreadyCompleted.Dismissis idempotent but controlled by the campaign-levelAllowDismiss. WhenAllowDismissis false it returnsGuidance.Campaign.DismissDenied; dismissing an already-dismissed campaign returnsGuidance.Campaign.AlreadyDismissed.- A missing step returns
Guidance.Campaign.StepNotFound.
This “server-authoritative plus idempotent plus AllowDismiss” chain keeps progress trustworthy: a client cannot fabricate completion or bypass the dismissal policy.
5. HTTP routes
Campaign routes are grouped under /api/v1/guidance/campaigns and source-generated:
| Method and route | Purpose | Permission |
|---|---|---|
POST /api/v1/guidance/campaigns | create campaign | guidance.campaign.manage |
GET /api/v1/guidance/campaigns | administration list | guidance.campaign.read |
GET /api/v1/guidance/campaigns/{campaignId} | campaign detail | guidance.campaign.read |
PUT /api/v1/guidance/campaigns/{campaignId} | update steps/audience/schedule | guidance.campaign.manage |
POST /api/v1/guidance/campaigns/{campaignId}/activate | activate | guidance.campaign.manage |
POST /api/v1/guidance/campaigns/{campaignId}/pause | pause | guidance.campaign.manage |
POST /api/v1/guidance/campaigns/{campaignId}/archive | archive | guidance.campaign.manage |
GET /api/v1/guidance/campaigns/{campaignId}/analytics | completion rate and distribution analytics | guidance.campaign.read |
GET /api/v1/guidance/campaigns/me | campaigns visible to the current user | guidance.campaign.use |
POST /api/v1/guidance/campaigns/me/{campaignId}/steps/{contentId}/complete | complete a step | guidance.campaign.use |
POST /api/v1/guidance/campaigns/me/{campaignId}/dismiss | dismiss a campaign | guidance.campaign.use |
Administration operations (create, update, state transitions, read, analytics) use guidance.campaign.read / guidance.campaign.manage. End-user operations (view own campaigns, complete a step, dismiss) use guidance.campaign.use. The three permission classes are independent: operators cannot accidentally use end-user permissions, and end users cannot gain administration power.
6. Analytics definition
GET /api/v1/guidance/campaigns/{campaignId}/analytics returns GuidanceCampaignAnalytics with actual (non-estimated) progress distribution counts: NotStarted, InProgress, Completed, Dismissed, plus CompletionRate. These numbers come from the real state of assignment aggregates, not from sampling or estimation. A console can render a funnel directly from them.
7. Persistence
The SQL Server migration 202607300004-guidance-campaigns.sql creates two tables:
dbo.GuidanceCampaign, withCK_GuidanceCampaign_Status('Draft','Active','Paused','Archived') andCK_GuidanceCampaign_Schedule, indexed byTenant/StatusandTenant/Language.dbo.GuidanceCampaignAssignment, withCK_GuidanceCampaignAssignment_Status('NotStarted','InProgress','Completed','Dismissed') and the unique indexUX_GuidanceCampaignAssignment_Tenant_Campaign_User, plus a query index onTenant/Campaign/Status.
GuidanceCampaignStore implements IGuidanceCampaignStore in the Infrastructure layer and backs the commands and queries above. Guidance Campaign has the same SqlSugar/EF Core parity foundation as GuidanceContent and Session.
8. Difference from contextual guidance
Two concepts are easy to confuse:
- Contextual guidance (
GuidanceContent+ Contextual Query): passive. When a user reaches a route/component, a four-level match returns one published help entry. Audience is a singleRequiredRolestring. - A campaign (
GuidanceCampaign): active. An operator strings several published items into ordered steps, targets by role/user/whole-tenant, and carries scheduling with server-authoritative progress.
Both share GuidanceContent as the content source, but a campaign only references published content IDs and never copies the body. The content publication lifecycle (Draft/Published) and the campaign lifecycle (Draft/Active/Paused/Archived) are therefore independent. Unpublishing content referenced by a campaign affects the readability of that campaign step.
9. Error code reference
Campaign error codes live in the Guidance.Campaign.* namespace. Representative entries:
| Error code | Trigger |
|---|---|
Guidance.Campaign.Invalid | input validation failed |
Guidance.Campaign.TenantRequired | missing trusted tenant context |
Guidance.Campaign.NotFound | campaign does not exist |
Guidance.Campaign.ContentInvalid | step content reference is invalid |
Guidance.Campaign.MustBeEditable | Configure run in a non-editable state |
Guidance.Campaign.TransitionInvalid | illegal state transition |
Guidance.Campaign.ScheduleExpired | activating an expired schedule |
Guidance.Campaign.NotEligible | user does not match the audience |
Guidance.Campaign.StepNotFound | step does not exist |
Guidance.Campaign.AlreadyDismissed | repeated dismissal |
Guidance.Campaign.DismissDenied | campaign does not allow dismissal |
Guidance.Campaign.AlreadyCompleted | repeated completion |
Guidance.Campaign.VersionConflict | optimistic concurrency conflict |
10. Source review
# Campaign aggregate, state machine, assignment, and progress.rg -n "GuidanceCampaign|GuidanceCampaignStatus|GuidanceProgressStatus|GuidanceCampaignAssignment" \ src/Platform/Guidance -g '*.cs'
# Campaign permission codes and routes.rg -n "guidance.campaign|CampaignResource|HttpRoute" \ src/Platform/Guidance -g '*.cs'
# Physical tables and constraints.rg -n "GuidanceCampaign|CK_GuidanceCampaign" \ src/Hosts/BitzOrcas.Api/SchemaMigrations/SqlServer/202607300004-guidance-campaigns.sqlGuidance overview · Content lifecycle and persistence · Contextual selection and authorization