mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add onboarding info fields to first user setup (#23989)
Add optional demographic and newsletter preference fields to the setup page: business use (yes/no), industry type, organization size, and two newsletter toggles (marketing, release/security updates). The new data flows through telemetry via a FirstUserOnboarding struct in the snapshot payload, sent once when the first user is created. The telemetry-server and BigQuery schema changes are required separately to persist this data. --------- Co-authored-by: default <davidiii@fraley.us> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
default
Claude Sonnet 4.6
parent
53482adc2d
commit
da3c46b557
Generated
+112
@@ -14565,6 +14565,59 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CreateFirstUserOnboardingInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"industry_type": {
|
||||
"enum": [
|
||||
"Technology",
|
||||
"Financial Services",
|
||||
"Healthcare",
|
||||
"Government",
|
||||
"Education",
|
||||
"Retail",
|
||||
"Manufacturing",
|
||||
"Media",
|
||||
"Telecom",
|
||||
"Energy",
|
||||
"Transportation",
|
||||
"Consulting",
|
||||
"Non-Profit",
|
||||
"Other"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.IndustryType"
|
||||
}
|
||||
]
|
||||
},
|
||||
"is_business": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"newsletter_marketing": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"newsletter_releases": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"org_size": {
|
||||
"enum": [
|
||||
"Just me",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-1000",
|
||||
"1001-5000",
|
||||
"5000+"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.OrgSizeRange"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CreateFirstUserRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -14579,6 +14632,9 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"onboarding_info": {
|
||||
"$ref": "#/definitions/codersdk.CreateFirstUserOnboardingInfo"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16430,6 +16486,41 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.IndustryType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Technology",
|
||||
"Financial Services",
|
||||
"Healthcare",
|
||||
"Government",
|
||||
"Education",
|
||||
"Retail",
|
||||
"Manufacturing",
|
||||
"Media",
|
||||
"Telecom",
|
||||
"Energy",
|
||||
"Transportation",
|
||||
"Consulting",
|
||||
"Non-Profit",
|
||||
"Other"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"IndustryTypeTechnology",
|
||||
"IndustryTypeFinancial",
|
||||
"IndustryTypeHealthcare",
|
||||
"IndustryTypeGovernment",
|
||||
"IndustryTypeEducation",
|
||||
"IndustryTypeRetail",
|
||||
"IndustryTypeManufacturing",
|
||||
"IndustryTypeMedia",
|
||||
"IndustryTypeTelecom",
|
||||
"IndustryTypeEnergy",
|
||||
"IndustryTypeTransportation",
|
||||
"IndustryTypeConsulting",
|
||||
"IndustryTypeNonProfit",
|
||||
"IndustryTypeOther"
|
||||
]
|
||||
},
|
||||
"codersdk.InsightsReportInterval": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -17616,6 +17707,27 @@ const docTemplate = `{
|
||||
"OptionTypeListString"
|
||||
]
|
||||
},
|
||||
"codersdk.OrgSizeRange": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Just me",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-1000",
|
||||
"1001-5000",
|
||||
"5000+"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"OrgSizeRangeJustMe",
|
||||
"OrgSizeRange2To10",
|
||||
"OrgSizeRange11To50",
|
||||
"OrgSizeRange51To200",
|
||||
"OrgSizeRange201To1K",
|
||||
"OrgSizeRange1KTo5K",
|
||||
"OrgSizeRange5KPlus"
|
||||
]
|
||||
},
|
||||
"codersdk.Organization": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
Generated
+112
@@ -13105,6 +13105,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CreateFirstUserOnboardingInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"industry_type": {
|
||||
"enum": [
|
||||
"Technology",
|
||||
"Financial Services",
|
||||
"Healthcare",
|
||||
"Government",
|
||||
"Education",
|
||||
"Retail",
|
||||
"Manufacturing",
|
||||
"Media",
|
||||
"Telecom",
|
||||
"Energy",
|
||||
"Transportation",
|
||||
"Consulting",
|
||||
"Non-Profit",
|
||||
"Other"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.IndustryType"
|
||||
}
|
||||
]
|
||||
},
|
||||
"is_business": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"newsletter_marketing": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"newsletter_releases": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"org_size": {
|
||||
"enum": [
|
||||
"Just me",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-1000",
|
||||
"1001-5000",
|
||||
"5000+"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.OrgSizeRange"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.CreateFirstUserRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password", "username"],
|
||||
@@ -13115,6 +13168,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"onboarding_info": {
|
||||
"$ref": "#/definitions/codersdk.CreateFirstUserOnboardingInfo"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14914,6 +14970,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.IndustryType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Technology",
|
||||
"Financial Services",
|
||||
"Healthcare",
|
||||
"Government",
|
||||
"Education",
|
||||
"Retail",
|
||||
"Manufacturing",
|
||||
"Media",
|
||||
"Telecom",
|
||||
"Energy",
|
||||
"Transportation",
|
||||
"Consulting",
|
||||
"Non-Profit",
|
||||
"Other"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"IndustryTypeTechnology",
|
||||
"IndustryTypeFinancial",
|
||||
"IndustryTypeHealthcare",
|
||||
"IndustryTypeGovernment",
|
||||
"IndustryTypeEducation",
|
||||
"IndustryTypeRetail",
|
||||
"IndustryTypeManufacturing",
|
||||
"IndustryTypeMedia",
|
||||
"IndustryTypeTelecom",
|
||||
"IndustryTypeEnergy",
|
||||
"IndustryTypeTransportation",
|
||||
"IndustryTypeConsulting",
|
||||
"IndustryTypeNonProfit",
|
||||
"IndustryTypeOther"
|
||||
]
|
||||
},
|
||||
"codersdk.InsightsReportInterval": {
|
||||
"type": "string",
|
||||
"enum": ["day", "week"],
|
||||
@@ -16038,6 +16129,27 @@
|
||||
"OptionTypeListString"
|
||||
]
|
||||
},
|
||||
"codersdk.OrgSizeRange": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Just me",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-1000",
|
||||
"1001-5000",
|
||||
"5000+"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"OrgSizeRangeJustMe",
|
||||
"OrgSizeRange2To10",
|
||||
"OrgSizeRange11To50",
|
||||
"OrgSizeRange51To200",
|
||||
"OrgSizeRange201To1K",
|
||||
"OrgSizeRange1KTo5K",
|
||||
"OrgSizeRange5KPlus"
|
||||
]
|
||||
},
|
||||
"codersdk.Organization": {
|
||||
"type": "object",
|
||||
"required": ["created_at", "id", "is_default", "updated_at"],
|
||||
|
||||
@@ -1502,6 +1502,7 @@ type Snapshot struct {
|
||||
PrebuiltWorkspaces []PrebuiltWorkspace `json:"prebuilt_workspaces"`
|
||||
AIBridgeInterceptionsSummaries []AIBridgeInterceptionsSummary `json:"aibridge_interceptions_summaries"`
|
||||
BoundaryUsageSummary *BoundaryUsageSummary `json:"boundary_usage_summary"`
|
||||
FirstUserOnboarding *FirstUserOnboarding `json:"first_user_onboarding"`
|
||||
}
|
||||
|
||||
// Deployment contains information about the host running Coder.
|
||||
@@ -1551,6 +1552,18 @@ type User struct {
|
||||
LoginType string `json:"login_type,omitempty"`
|
||||
}
|
||||
|
||||
// FirstUserOnboarding contains optional demographic and newsletter
|
||||
// preference data collected during first user setup. This is sent
|
||||
// once when the first user is created. Pointer fields distinguish an
|
||||
// explicit answer from a skipped question.
|
||||
type FirstUserOnboarding struct {
|
||||
IsBusiness *bool `json:"is_business"`
|
||||
IndustryType string `json:"industry_type"`
|
||||
OrgSize string `json:"org_size"`
|
||||
NewsletterMarketing *bool `json:"newsletter_marketing"`
|
||||
NewsletterReleases *bool `json:"newsletter_releases"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
+19
-1
@@ -281,8 +281,26 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) {
|
||||
telemetryUser := telemetry.ConvertUser(user)
|
||||
// Send the initial users email address!
|
||||
telemetryUser.Email = &user.Email
|
||||
// Only populate onboarding data when the client actually sent it. A nil
|
||||
// OnboardingInfo means the request came from an older client, the CLI, or
|
||||
// the OIDC flow — not from a user who answered "no" to every question.
|
||||
//
|
||||
// Note: newsletter consent (NewsletterMarketing) is bundled here alongside
|
||||
// product-analytics fields. These may have different legal bases under GDPR
|
||||
// and should be separated in a follow-up once the legal posture is clarified.
|
||||
var onboarding *telemetry.FirstUserOnboarding
|
||||
if createUser.OnboardingInfo != nil {
|
||||
onboarding = &telemetry.FirstUserOnboarding{
|
||||
IsBusiness: createUser.OnboardingInfo.IsBusiness,
|
||||
IndustryType: string(createUser.OnboardingInfo.IndustryType),
|
||||
OrgSize: string(createUser.OnboardingInfo.OrgSize),
|
||||
NewsletterMarketing: createUser.OnboardingInfo.NewsletterMarketing,
|
||||
NewsletterReleases: createUser.OnboardingInfo.NewsletterReleases,
|
||||
}
|
||||
}
|
||||
api.Telemetry.Report(&telemetry.Snapshot{
|
||||
Users: []telemetry.User{telemetryUser},
|
||||
Users: []telemetry.User{telemetryUser},
|
||||
FirstUserOnboarding: onboarding,
|
||||
})
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateFirstUserResponse{
|
||||
|
||||
@@ -116,6 +116,93 @@ func TestFirstUser(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFirstUser_OnboardingTelemetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OnboardingInfoFlowsToSnapshot", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
fTelemetry := newFakeTelemetryReporter(ctx, t, 10)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
TelemetryReporter: fTelemetry,
|
||||
})
|
||||
|
||||
isBusiness := true
|
||||
wantMarketing := false
|
||||
wantReleases := true
|
||||
_, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "admin@coder.com",
|
||||
Username: "admin",
|
||||
Password: "SomeSecurePassword!",
|
||||
OnboardingInfo: &codersdk.CreateFirstUserOnboardingInfo{
|
||||
IsBusiness: &isBusiness,
|
||||
IndustryType: codersdk.IndustryTypeTechnology,
|
||||
OrgSize: codersdk.OrgSizeRange51To200,
|
||||
NewsletterMarketing: &wantMarketing,
|
||||
NewsletterReleases: &wantReleases,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshot := testutil.TryReceive(ctx, t, fTelemetry.snapshots)
|
||||
require.NotNil(t, snapshot.FirstUserOnboarding)
|
||||
require.NotNil(t, snapshot.FirstUserOnboarding.IsBusiness)
|
||||
require.True(t, *snapshot.FirstUserOnboarding.IsBusiness)
|
||||
require.Equal(t, string(codersdk.IndustryTypeTechnology), snapshot.FirstUserOnboarding.IndustryType)
|
||||
require.Equal(t, string(codersdk.OrgSizeRange51To200), snapshot.FirstUserOnboarding.OrgSize)
|
||||
require.NotNil(t, snapshot.FirstUserOnboarding.NewsletterMarketing)
|
||||
require.False(t, *snapshot.FirstUserOnboarding.NewsletterMarketing)
|
||||
require.NotNil(t, snapshot.FirstUserOnboarding.NewsletterReleases)
|
||||
require.True(t, *snapshot.FirstUserOnboarding.NewsletterReleases)
|
||||
})
|
||||
|
||||
t.Run("NilWhenOnboardingInfoOmitted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
fTelemetry := newFakeTelemetryReporter(ctx, t, 10)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
TelemetryReporter: fTelemetry,
|
||||
})
|
||||
|
||||
_, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "admin@coder.com",
|
||||
Username: "admin",
|
||||
Password: "SomeSecurePassword!",
|
||||
// No OnboardingInfo — simulates old CLI or OIDC flow.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshot := testutil.TryReceive(ctx, t, fTelemetry.snapshots)
|
||||
require.Nil(t, snapshot.FirstUserOnboarding)
|
||||
})
|
||||
|
||||
t.Run("EmptyOnboardingInfoIsNonNilWithZeroFields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
fTelemetry := newFakeTelemetryReporter(ctx, t, 10)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
TelemetryReporter: fTelemetry,
|
||||
})
|
||||
_, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "admin@coder.com", Username: "admin",
|
||||
Password: "SomeSecurePassword!",
|
||||
OnboardingInfo: &codersdk.CreateFirstUserOnboardingInfo{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
snapshot := testutil.TryReceive(ctx, t, fTelemetry.snapshots)
|
||||
require.NotNil(t, snapshot.FirstUserOnboarding,
|
||||
"non-nil OnboardingInfo must produce non-nil telemetry")
|
||||
require.Nil(t, snapshot.FirstUserOnboarding.IsBusiness,
|
||||
"nil *bool must stay nil, not become false")
|
||||
require.Nil(t, snapshot.FirstUserOnboarding.NewsletterMarketing)
|
||||
require.Nil(t, snapshot.FirstUserOnboarding.NewsletterReleases)
|
||||
require.Empty(t, snapshot.FirstUserOnboarding.IndustryType)
|
||||
require.Empty(t, snapshot.FirstUserOnboarding.OrgSize)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("InvalidUser", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user