Files
foundry-core/internal/platform/onboarding.go
T
codex-bot 1b756d36ce
Foundry Go checks / check (push) Successful in 1m47s
Foundry Go checks / check (pull_request) Successful in 1m57s
Add configurable onboarding and scoped invitation links
2026-11-05 18:24:30 +08:00

193 lines
5.9 KiB
Go

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
}