Files
multica/server/internal/handler/onboarding.go
Naiyuan Qing 7b3cad664b revert(self-host): remove source channel reporting (#4799)
* Revert "test(onboarding): cover official source reporting controls (#4782)"

This reverts commit fc88c7720f.

* Revert "fix(self-host): restore official source report endpoint (#4781)"

This reverts commit ad1afdd48d.

* Revert "feat(self-host): collect anonymous source channels mul-3878 (#4741)"

This reverts commit 26142d74aa.
2026-07-01 17:53:16 +08:00

347 lines
12 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"net/mail"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Upper bound on free-text fields. `cloudWaitlistReasonMaxLen` is a
// product cap ("we don't need an essay for a waitlist"); the body-size
// cap further down is defense in depth against arbitrary storage
// abuse via the JSON body.
const (
cloudWaitlistReasonMaxLen = 500
// PatchOnboarding body is a tiny JSON with at most a 3-question
// questionnaire. 16 KiB is ~10x the realistic ceiling — it's the
// minimum that keeps the door open for future fields without
// letting a malicious user stuff the JSONB column.
patchOnboardingBodyLimit = 16 * 1024
)
// completeOnboardingRequest carries the client's view of which exit the
// user took from the flow. Used purely as an analytics dimension — server
// state (onboarded_at) flips the same way regardless. Unknown / missing
// → OnboardingPathUnknown so legacy clients still complete cleanly, just
// without a funnel-ready label.
//
// `workspace_id` is retained for analytics enrichment; the v2 code path
// used it to seed an install-runtime issue inside the same transaction,
// but in v3 every workspace-content seeding lives in the frontend
// welcome hook (see packages/views/workspace/welcome-after-onboarding.tsx).
type completeOnboardingRequest struct {
CompletionPath string `json:"completion_path,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
}
var validCompletionPaths = map[string]struct{}{
analytics.OnboardingPathFull: {},
analytics.OnboardingPathRuntimeSkipped: {},
analytics.OnboardingPathCloudWaitlist: {},
analytics.OnboardingPathSkipExisting: {},
analytics.OnboardingPathInviteAccept: {},
}
// CompleteOnboarding marks the authenticated user as having completed
// onboarding. Idempotent: the underlying query uses COALESCE so the
// original timestamp is preserved if called more than once.
//
// Emits `onboarding_completed` exactly once — the first call that
// actually flips `onboarded_at` from NULL. Subsequent calls are still
// 200 OK (for client-side retries) but skip the event so the funnel
// counts honest first-completion.
//
// V3 has no in-handler seeding side effect: workspace content (Helper
// agent, starter issues, install-runtime guides) is created by the
// frontend welcome hook via the generic CreateAgent / CreateIssue
// endpoints. This handler does one thing: flip the field.
func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Body is optional — an empty body is a legal legacy call.
var req completeOnboardingRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
// Validate workspace_id if supplied; we don't write with it, but a
// malformed value should fail fast rather than silently land in
// PostHog as a junk dimension.
if req.WorkspaceID != "" {
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
if !ok {
return
}
req.WorkspaceID = uuidToString(wsUUID)
}
before, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
return
}
firstCompletion := !before.OnboardedAt.Valid
user, err := h.Queries.MarkUserOnboarded(r.Context(), parseUUID(userID))
if err != nil {
slog.Warn("complete onboarding: mark user onboarded failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to complete onboarding")
return
}
if firstCompletion {
path := req.CompletionPath
if _, ok := validCompletionPaths[path]; !ok {
path = analytics.OnboardingPathUnknown
}
onboardedAt := ""
if user.OnboardedAt.Valid {
onboardedAt = user.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
}
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingCompleted(
userID,
req.WorkspaceID,
path,
onboardedAt,
user.CloudWaitlistEmail.Valid,
))
}
writeJSON(w, http.StatusOK, userToResponse(user))
}
type patchOnboardingRequest struct {
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
}
// questionnaireAnswers mirrors the frontend's `QuestionnaireAnswers`
// shape. `use_case` is multi-select (Step 3 allows picking several);
// `source` is single-select (primary acquisition channel) but kept
// as `stringOrSlice` for back-compat with v2 multi-select rows — the
// client now always commits a one-element array. `role` stays
// single-select.
//
// stringOrSlice also tolerates pre-array rows that wrote a bare
// string into the JSONB column — `json.Unmarshal` would otherwise
// fail on type mismatch when reading those back.
type stringOrSlice []string
func (s *stringOrSlice) UnmarshalJSON(data []byte) error {
// Empty / null both decode to nil slice.
if len(data) == 0 || string(data) == "null" {
*s = nil
return nil
}
// Try array first (current shape).
var arr []string
if err := json.Unmarshal(data, &arr); err == nil {
*s = arr
return nil
}
// Fall back to single string (pre-array shape from before this
// column held a slice). Empty string means "unanswered" — keep nil.
var single string
if err := json.Unmarshal(data, &single); err != nil {
return err
}
if single == "" {
*s = nil
return nil
}
*s = []string{single}
return nil
}
type questionnaireAnswers struct {
Source stringOrSlice `json:"source"`
SourceOther string `json:"source_other"`
SourceSkipped bool `json:"source_skipped"`
Role string `json:"role"`
RoleOther string `json:"role_other"`
RoleSkipped bool `json:"role_skipped"`
UseCase stringOrSlice `json:"use_case"`
UseCaseOther string `json:"use_case_other"`
UseCaseSkipped bool `json:"use_case_skipped"`
Version int `json:"version"`
}
func (q questionnaireAnswers) sourceResolved() bool {
return len(q.Source) > 0 || q.SourceSkipped
}
func (q questionnaireAnswers) roleResolved() bool {
return q.Role != "" || q.RoleSkipped
}
func (q questionnaireAnswers) useCaseResolved() bool {
return len(q.UseCase) > 0 || q.UseCaseSkipped
}
// questionnaireSchemaVersion is the schema this handler understands.
// `complete()` and the funnel event are scoped to this version so a
// future v3 row can't be silently mis-counted against v2 semantics.
const questionnaireSchemaVersion = 2
func (q questionnaireAnswers) complete() bool {
if q.Version != questionnaireSchemaVersion {
return false
}
return q.sourceResolved() && q.roleResolved() && q.useCaseResolved()
}
// PatchOnboarding persists the user's questionnaire answers. The
// field is optional; an omitted questionnaire is preserved. Which
// step the user is on is deliberately not persisted — every
// onboarding entry starts at Welcome.
//
// Emits `onboarding_questionnaire_submitted` exactly once per user:
// the first PATCH that transitions the answers from "at least one
// slot empty" to "all three filled". Revisions past that point don't
// re-emit — the funnel counts users, not edits.
func (h *Handler) PatchOnboarding(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Bound the body so the JSONB column can't be weaponized as bulk
// storage — otherwise every subsequent `/api/me` read would have
// to return the bloat.
r.Body = http.MaxBytesReader(w, r.Body, patchOnboardingBodyLimit)
var req patchOnboardingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Read prior answers so we can detect the NULL/partial → complete
// transition after the update. An errored decode on the prior row
// is treated as "incomplete" — worst case we emit once more than
// we should, never twice for the same transition.
var before questionnaireAnswers
beforeRaw := []byte("{}")
if beforeUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID)); err == nil {
beforeRaw = beforeUser.OnboardingQuestionnaire
_ = json.Unmarshal(beforeRaw, &before)
}
// firstTouch is true when the user has never written any
// onboarding state on the server before this PATCH. Used to fire
// onboarding_started exactly once per user from the server side.
firstTouch := len(beforeRaw) == 0 || string(beforeRaw) == "null" || string(beforeRaw) == "{}"
params := db.PatchUserOnboardingParams{ID: parseUUID(userID)}
if req.Questionnaire != nil {
params.Questionnaire = []byte(*req.Questionnaire)
}
user, err := h.Queries.PatchUserOnboarding(r.Context(), params)
if err != nil {
slog.Warn("patch onboarding failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to update onboarding")
return
}
// Server-side onboarding_started: fire on the first PATCH that
// actually carries a questionnaire payload. The frontend also
// emits its own onboarding_started on page open; the two together
// let Grafana cross-check the funnel against PostHog.
if firstTouch && req.Questionnaire != nil && len(*req.Questionnaire) > 0 && string(*req.Questionnaire) != "{}" {
platform, _, _ := middleware.ClientMetadataFromContext(r.Context())
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingStarted(userID, platform))
}
var after questionnaireAnswers
_ = json.Unmarshal(user.OnboardingQuestionnaire, &after)
if after.complete() && !before.complete() {
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.OnboardingQuestionnaireSubmitted(
userID,
[]string(after.Source),
after.Role,
[]string(after.UseCase),
after.SourceSkipped,
after.RoleSkipped,
after.UseCaseSkipped,
after.SourceOther != "",
after.RoleOther != "",
after.UseCaseOther != "",
))
}
writeJSON(w, http.StatusOK, userToResponse(user))
}
type joinCloudWaitlistRequest struct {
Email string `json:"email"`
Reason string `json:"reason"`
}
// JoinCloudWaitlist records a user's interest in cloud runtimes.
// Pure side effect — does NOT complete onboarding. The user still
// has to pick a real Step 3 path (CLI with a detected runtime) or
// Skip to move on. Repeating the call overwrites email + reason.
func (h *Handler) JoinCloudWaitlist(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req joinCloudWaitlistRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// RFC 5321 caps email at 254 chars; the column is VARCHAR(254) and
// the format check below rejects anything net/mail can't parse.
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
if len(email) > 254 {
writeError(w, http.StatusBadRequest, "email is too long")
return
}
if _, err := mail.ParseAddress(email); err != nil {
writeError(w, http.StatusBadRequest, "email is invalid")
return
}
reason := strings.TrimSpace(req.Reason)
if len(reason) > cloudWaitlistReasonMaxLen {
writeError(w, http.StatusBadRequest, "reason is too long")
return
}
reasonParam := pgtype.Text{}
if reason != "" {
reasonParam = pgtype.Text{String: reason, Valid: true}
}
user, err := h.Queries.JoinCloudWaitlist(r.Context(), db.JoinCloudWaitlistParams{
ID: parseUUID(userID),
CloudWaitlistEmail: pgtype.Text{String: email, Valid: true},
CloudWaitlistReason: reasonParam,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to join waitlist")
return
}
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.CloudWaitlistJoined(userID, reason != ""))
writeJSON(w, http.StatusOK, userToResponse(user))
}