package platform import ( "database/sql" "encoding/json" "errors" "io" "net/http" "strings" ) type Question struct { Key string `json:"key"` Required bool `json:"required"` } type OnboardingConfig struct { RequireWorkspace bool `json:"require_workspace"` Questions []Question `json:"questions"` } var surveyTypes = map[string]string{ "team_activity": "string", "job_role": "string", "referral": "string", "previous_use": "boolean", "purpose": "string", "company_size": "string", } func ParseOnboarding(raw string, callbacks map[string]string) (map[string]OnboardingConfig, error) { configs := map[string]OnboardingConfig{} if strings.TrimSpace(raw) == "" { return configs, nil } decoder := json.NewDecoder(strings.NewReader(raw)) decoder.DisallowUnknownFields() if err := decoder.Decode(&configs); err != nil { return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") } if decoder.Decode(new(any)) != io.EOF { return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") } for project, config := range configs { if _, ok := callbacks[project]; !ok { return nil, errors.New("onboarding project is not configured") } seen := map[string]bool{} for _, question := range config.Questions { if surveyTypes[question.Key] == "" || seen[question.Key] { return nil, errors.New("invalid or duplicate onboarding question") } seen[question.Key] = true } } return configs, nil } func validAnswers(config OnboardingConfig, answers map[string]json.RawMessage) bool { questions := map[string]Question{} for _, question := range config.Questions { questions[question.Key] = question } for key, value := range answers { if _, ok := questions[key]; !ok { return false } if surveyTypes[key] == "boolean" { var answer *bool if json.Unmarshal(value, &answer) != nil || answer == nil { return false } } else { var answer *string if json.Unmarshal(value, &answer) != nil || answer == nil || len(*answer) > 2000 { return false } } } for _, question := range config.Questions { value, ok := answers[question.Key] if question.Required { if !ok { return false } if surveyTypes[question.Key] == "string" { var answer string if json.Unmarshal(value, &answer) != nil || strings.TrimSpace(answer) == "" { return false } } } } return true } func (s *service) onboarding(w http.ResponseWriter, r *http.Request, project, account string) error { config := s.onboardingConfigs[project] if config.Questions == nil { config.Questions = []Question{} } if r.Method == http.MethodGet { var answers, result json.RawMessage err := s.db.QueryRowContext(r.Context(), "SELECT answers,result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&answers, &result) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } reply(w, 200, response{"config": config, "completed": err == nil, "answers": answers, "result": result}) return nil } if r.Method != http.MethodPost { reject(w, 405, "method_not_allowed") return nil } var input struct { Username *string `json:"username"` TeamName string `json:"team_name"` GenerateInvite bool `json:"generate_invite"` Answers map[string]json.RawMessage `json:"answers"` } if !decode(w, r, &input) { return nil } input.TeamName = strings.TrimSpace(input.TeamName) if input.Answers == nil { input.Answers = map[string]json.RawMessage{} } if input.Username != nil { value := strings.ToLower(strings.TrimSpace(*input.Username)) input.Username = &value if !usernamePattern.MatchString(value) { reject(w, 400, "invalid_username") return nil } } if !validAnswers(config, input.Answers) || len(input.TeamName) > 160 || (config.RequireWorkspace && input.TeamName == "") || (input.GenerateInvite && input.TeamName == "") { reject(w, 400, "invalid_onboarding") return nil } tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() var locked string if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM project_accounts WHERE project=$1 AND id=$2 FOR UPDATE", project, account).Scan(&locked); err != nil { return err } var prior json.RawMessage err = tx.QueryRowContext(r.Context(), "SELECT result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&prior) if err == nil { reply(w, 200, response{"completed": true, "result": prior, "already_completed": true}) return nil } if !errors.Is(err, sql.ErrNoRows) { return err } if input.Username != nil { _, err = tx.ExecContext(r.Context(), "UPDATE project_accounts SET username=$3,updated_at=now() WHERE project=$1 AND id=$2", project, account, *input.Username) if err != nil { return err } } result := response{} var invitation string if input.TeamName != "" { workspace, err := createWorkspace(r.Context(), tx, project, account, input.TeamName) if err != nil { return err } result["workspace_id"] = workspace if input.GenerateInvite { token, hash := newLinkToken() var id string err = tx.QueryRowContext(r.Context(), ` INSERT INTO share_links(project,workspace_id,token_hash,role,created_by,expires_at) VALUES($1,$2,$3,'viewer',$4,now()+interval '7 days') RETURNING id::text `, project, workspace, hash, account).Scan(&id) if err != nil { return err } result["invite_id"] = id invitation = s.origin + "/s/" + token } } answers, _ := json.Marshal(input.Answers) resultJSON, _ := json.Marshal(result) _, err = tx.ExecContext(r.Context(), "INSERT INTO onboarding_responses(project,account_id,answers,result) VALUES($1,$2,$3,$4)", project, account, answers, resultJSON) if err != nil { return err } if err = tx.Commit(); err != nil { return err } reply(w, 201, response{"completed": true, "result": result, "invite_url": invitation, "already_completed": false}) return nil }