mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* feat(onboarding): backfill prompt for users missing source attribution Adds a one-shot popup shown after login to already-onboarded users whose `onboarding_questionnaire.source` was never recorded — either they completed onboarding before the source step shipped, or they clicked Skip on it. Reuses the existing 12-option StepSource UI and the existing `PATCH /api/me/onboarding` endpoint, so no schema or backend changes. Web renders it as a route at /onboarding/source (sibling of the reserved /onboarding); desktop dispatches it as a WindowOverlay per the Route categories rule. Submit and explicit Skip are terminal; the close X bumps a per-user localStorage counter and stops appearing after 3 dismissals. Emits source_backfill_shown / submitted / skipped / dismissed PostHog events so the funnel can be tracked separately from first-time onboarding. For MUL-2796. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): preserve role/use_case and respect dismiss cap in source backfill Round-2 fixes from Emacs's review of #3550: 1. PATCH wipe: `PATCH /api/me/onboarding` replaces the JSONB column wholesale (server/internal/handler/onboarding.go), so sending only the source slots was wiping role/use_case/version for exactly the historical users this targets. Read user.onboarding_questionnaire, overlay the source fields client-side via mergedQuestionnairePatch, and send the full shape. 7 unit cases cover the merge semantics. 2. Legacy single-string source: pre-multi-select rows wrote `source: "search"` as a bare string. needsSourceBackfill now treats that as already answered, matching mergeQuestionnaire (views) and stringOrSlice.UnmarshalJSON (server). Flipped the existing test and added empty-string + null coverage. 3. Dismiss cap honored in callback: the web auth callback was passing dismissCount=0, which would force-route capped users through /onboarding/source on every login (the route page would bounce them onward, but only after a blank detour and a re-fired `source_backfill_shown` event). Added readSourceBackfillDismissCount so the callback reads the same per-user localStorage bucket the prompt writes to. Test asserts a count of 3 bypasses the detour. Co-authored-by: multica-agent <github@multica.ai> * test(onboarding): clear source-backfill dismiss counter in callback test beforeEach Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): footer hint text matches the Submit button on the backfill prompt The Source step's hint reads "Hit Continue when you're ready" because its commit button is "Continue". The backfill view ships a "Submit" button instead, so the inherited hint was misleading. Add a dedicated `source_backfill.hint_ready` key across en / zh / ko and use it here. Caught during browser E2E in the round-2 verification stack. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): magic-code login also detours through source backfill The round-2 fix in PR #3550 only wired the source-backfill detour into the OAuth `/auth/callback` post-success path. Magic-code login goes through `/login` → `handleSuccess()` which calls `resolveLoggedInDestination()` and pushes directly to the workspace, so those users never reach `/onboarding/source`. Caught during the local-env demo for Jiayuan. Add `maybeSourceBackfillDetour` to the login page and apply it in both the already-authenticated useEffect and the post-verify-code handler. Predicate consults the same per-user localStorage bucket the prompt writes to, so a user who hit the close-X cap on this browser flows straight through. Co-authored-by: multica-agent <github@multica.ai> * refactor(onboarding): source backfill is a workspace-mounted modal, not a route detour Per UAT, the prompt should overlay the workspace as a Dialog with the workspace visible behind a dimmed backdrop — the original brief and reference screenshot both showed a modal. PR #3550 shipped a full-window takeover (web /onboarding/source + desktop WindowOverlay) which Jiayuan rejected. This commit replaces the full-window view with a Dialog-based `<SourceBackfillModal />` mounted once inside the shared `DashboardLayout` (packages/views/layout). The modal self-mounts: it reads `needsSourceBackfill(user, dismissCount)` and opens itself when the predicate flips to true; X / ESC / outside-click all bump the per-user localStorage cap and close. Removed: - apps/web/app/(auth)/onboarding/source/page.tsx (route) - paths.sourceBackfill (no longer needed) - callback page detour - login page maybeSourceBackfillDetour - desktop WindowOverlay type "source-backfill" - desktop navigation interception of /onboarding/source - desktop App.tsx dispatch effect - pageview-tracker case - views/onboarding `SourceBackfillView` + `readSourceBackfillDismissCount` exports Preserved (semantics unchanged): - `needsSourceBackfill` predicate (incl. legacy single-string source coercion) - `mergedQuestionnairePatch` so role / use_case survive Submit / Skip - PostHog events: source_backfill_shown / submitted / skipped / dismissed - Per-user dismiss-count cap (3) in localStorage - en / zh / ko i18n strings Tests: - 7 new tests for the modal in packages/views/onboarding/source-backfill-modal.test.tsx - Adjusted apps/web/app/auth/callback/page.test.tsx: detour tests dropped, one assertion remains that onboarded users with missing source land in the workspace (the modal handles the rest) - Full suite: 965 tests pass, typecheck + lint clean Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): mount source-backfill modal on the desktop workspace too Desktop's WorkspaceRouteLayout never wraps DashboardLayout, so the previous commit's modal mount only fired for web. Regression: desktop users were not seeing the prompt at all. Wire the same `<SourceBackfillModal />` next to `<WelcomeAfterOnboarding />` inside `workspace-route-layout.tsx`, with the matching `!overlayActive` suppression so the Dialog doesn't portal-jump above an active pre-workspace WindowOverlay (onboarding / accept-invite / new-workspace). Same component on both platforms — single source of truth lives in packages/views/onboarding/source-backfill-modal.tsx. Also drop the now-stale `source-backfill detour` comment in the web callback test fixture (Emacs nit, non-blocking). Co-authored-by: multica-agent <github@multica.ai> * test(desktop): assert workspace-route-layout mounts source-backfill modal Two structural tests pinning the round-4 fix: - `mounts SourceBackfillModal when no WindowOverlay is active` — guards against the regression Emacs caught (modal silently absent on desktop because the previous round only wired DashboardLayout). - `suppresses SourceBackfillModal while a WindowOverlay is active` — mirrors the existing `!overlayActive` rule that WelcomeAfterOnboarding already relies on so a portal-rendered Dialog can't visually outrank an active pre-workspace overlay. Mocks the SourceBackfillModal with a marker component so the test asserts mount/unmount without depending on the modal's own predicate gate. Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): backfill modal Other toggles off; entrance settles after 700ms UAT round-3 follow-ups from Jiayuan: 1. **Other can't be deselected**: the modal kept a parallel `pendingOther` flag set to true on every Other click, and `IconOtherOptionCard`'s row click was guarded with `if (!selected) onSelect()` — so a second click neither flipped pendingOther nor reached the parent toggle. Drop `pendingOther` (the `source.includes("other")` derivation is already authoritative) AND add an opt-in `allowToggleOff` prop to `IconOtherOptionCard` that lets the row toggle when already selected. The text input stops click propagation so typing never deselects. 2. **Rebase + absorb GitHub channel**: rebased onto origin/main which added `social_github` (PR #3612). Modal's option list now mirrors StepSource — GitHub slotted between YouTube and Other social, reusing the existing `GitHubIcon`. 3. **Soft entrance**: defer the dialog open by 700ms after the user lands on a workspace so the underlying view paints first and the modal feels like an inviting prompt rather than a hard block. Honour `prefers-reduced-motion: reduce` (open immediately for users who have opted out of incidental motion). Tests: - New `Other toggles off on the second click instead of getting stuck` - New `renders the GitHub channel rebased from origin/main` - New `defers the entrance by ~700ms when the user has not opted into reduced motion` - Existing tests stamp `prefers-reduced-motion: reduce` in beforeEach so the dialog opens synchronously and they don't need to drive fake timers. Full suite passes (969 tests). Co-authored-by: multica-agent <github@multica.ai> * fix(onboarding): backfill modal opens reliably + Other deselects via icon area Three follow-up fixes after live UAT: 1. Strict-mode regression on entrance delay: the gate ref was being stamped when the effect *scheduled* the timer, so React Strict Mode's double-invoke cleared the first timer and then bailed on the second pass because the ref was already set, leaving the dialog forever closed. Stamp the ref only inside the timer callback (or synchronously when reduced-motion is on) so the second strict pass starts a fresh timer. 2. Other deselect: dropping `pendingOther` wasn't enough — the input that replaces the label when Other is selected was previously stopping click propagation, so a re-click on the row never reached the toggle. Remove `e.stopPropagation()` and instead let the row's onClick ignore clicks whose target IS the input (typing / focusing the input still doesn't deselect; clicks on the icon, padding, or border do). 3. Tests: drive the Other re-click via Playwright `click({position: {x:24,y:24}})` so the click lands on the icon area instead of the center of the input, matching real-user behaviour. Co-authored-by: multica-agent <github@multica.ai> * refactor(onboarding): source picker is single-select primary source Per Jiayuan's call after the survey of HDYHAU UX in PLG SaaS (Linear / Vercel / Loom / Notion / Webflow / Stripe / Figma / Cursor / PostHog mostly skip the question entirely; where it's asked the documented default — Fairing / Recast / HockeyStack / Ruler Analytics — is to capture the primary source so channel weights sum to 100% and ROI math is defensible). Modal + StepSource both pivot from multi-select to single-select radio. Server schema is intentionally untouched: `source` stays `string[]` for back-compat with v2 multi-select rows; the client always sends a one-element array. Zero migration, zero data loss. Frontend: - `source-backfill-modal.tsx`: state pivots from a multi-element `source: Source[]` to a single `pickedSlug` derived from `source[0]`; click handler replaces the array instead of toggling. Cards switch to `mode="radio"`, the fieldset gets `role="radiogroup"`, the now-redundant `pendingOther` and `allowToggleOff` opt-in go away — radio mode means no toggle-off, so the original UAT bug ("Other can't be deselected") is structurally impossible. - `step-source.tsx`: drop the `multiSelect` prop so it routes through `step-question.tsx`'s existing radio path (same one StepRole already uses). Picking a second option replaces the first; switching away from Other clears `source_other` so a stale value can't leak. - `icon-option-card.tsx`: revert the `allowToggleOff` plumbing. Tests: - `source-backfill-modal.test.tsx`: drop the multi-select toggle-off assertion; add "picking a second option replaces the first" with explicit radio-role queries. - `step-source.test.tsx`: rewrite multi-select tests as single-select (no more "stacks several picks" / "toggle off" cases); add "switching away from Other clears source_other". Full suite (970 tests) green, typecheck + lint clean. Co-authored-by: multica-agent <github@multica.ai> * docs(onboarding): refresh stale multi-select comments around source Comment-only follow-up to the single-select refactor in d14f9d09f. Five docblocks still described `source` as multi-select; they now correctly say single-select and explain the array shape is kept purely for v2 back-compat with the JSONB column. - packages/core/onboarding/types.ts — QuestionnaireAnswers docblock - packages/core/onboarding/store.ts — PostHog mirror comment - packages/views/onboarding/steps/step-question.tsx — header docblock, canContinue branch, and footer-hint comment (Source moves from the multi-select side to the single-select side; Use case stays as the remaining multi-select consumer) - server/internal/handler/onboarding.go — questionnaireAnswers docblock and the stringOrSlice fall-back comment (the column "going multi- select" is no longer the current state; rename to "pre-array shape") - server/internal/analytics/events.go — OnboardingQuestionnaireSubmitted docblock No behaviour changes. Tests + Go build still green. Co-authored-by: multica-agent <github@multica.ai> * i18n(onboarding): add ja translations for source-backfill keys The Japanese locale landed on main (PR #3538) after this branch started, so my source-backfill round-2 keys (`common.close`, `source_backfill.eyebrow / lede / submit / hint_ready`) never made it into ja and the parity test fails in CI. Add them now with translations that match the en/zh-Hans/ko wording and tone. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
330 lines
11 KiB
Go
330 lines
11 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"
|
|
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")
|
|
}
|
|
h.Analytics.Capture(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
|
|
if beforeUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID)); err == nil {
|
|
_ = json.Unmarshal(beforeUser.OnboardingQuestionnaire, &before)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var after questionnaireAnswers
|
|
_ = json.Unmarshal(user.OnboardingQuestionnaire, &after)
|
|
if after.complete() && !before.complete() {
|
|
h.Analytics.Capture(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
|
|
}
|
|
|
|
h.Analytics.Capture(analytics.CloudWaitlistJoined(userID, reason != ""))
|
|
|
|
writeJSON(w, http.StatusOK, userToResponse(user))
|
|
}
|