From 1b756d36ceaa4b78897921222f13c0fe8eeae09e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:24:30 +0800 Subject: [PATCH] Add configurable onboarding and scoped invitation links --- .gitea/workflows/deploy.yml | 1 + cmd/foundry/main.go | 6 +- db/002-platform.sql | 7 +- deploy/check-schema.sh | 6 + deploy/release.sh | 2 +- internal/platform/http.go | 14 +- internal/platform/integration_test.go | 120 +++++++++++ internal/platform/onboarding.go | 192 +++++++++++++++++ internal/platform/shares.go | 287 ++++++++++++++++++++++++++ 9 files changed, 624 insertions(+), 11 deletions(-) create mode 100644 internal/platform/integration_test.go create mode 100644 internal/platform/onboarding.go create mode 100644 internal/platform/shares.go diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 0839aca..564745f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -36,4 +36,5 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} + FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }} run: bash deploy/release.sh bin/foundry diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index abf4427..0d3ce4c 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -54,7 +54,11 @@ func main() { if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { log.Fatal(err) } - platform.Register(mux, db, callbacks) + onboarding, err := platform.ParseOnboarding(os.Getenv("FOUNDRY_ONBOARDING"), callbacks) + if err != nil { + log.Fatal(err) + } + platform.Register(mux, db, callbacks, origin.Scheme+"://"+origin.Host, onboarding) mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), diff --git a/db/002-platform.sql b/db/002-platform.sql index 97bf9e4..dc8af41 100644 --- a/db/002-platform.sql +++ b/db/002-platform.sql @@ -62,16 +62,11 @@ CREATE TABLE auth_rate_limits ( expires_at timestamptz NOT NULL ); -CREATE TABLE onboarding_configs ( - project text PRIMARY KEY, - require_workspace boolean NOT NULL DEFAULT false, - questions jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(questions) = 'array'), - updated_at timestamptz NOT NULL DEFAULT now() -); CREATE TABLE onboarding_responses ( project text NOT NULL, account_id uuid NOT NULL, answers jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(answers) = 'object'), + result jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(result) = 'object'), completed_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (project, account_id), FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index 09f1aff..b97a56c 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -56,3 +56,9 @@ END $$; ROLLBACK; SQL printf 'Migration preserved existing logins and enforced project isolation.\n' +CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform +docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/platform-tests,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable \ + --entrypoint /opt/platform-tests postgres:17-alpine -test.v diff --git a/deploy/release.sh b/deploy/release.sh index 202127e..eddcf71 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -66,7 +66,7 @@ fi docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ - -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ + -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ --read-only --memory 128m \ --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ diff --git a/internal/platform/http.go b/internal/platform/http.go index 8a468be..58d24d2 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -16,7 +16,10 @@ import ( ) type service struct { - db *sql.DB + db *sql.DB + origin string + callbacks map[string]string + onboardingConfigs map[string]OnboardingConfig } type response map[string]any @@ -98,8 +101,13 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H } } -func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { - s := &service{db: db} +func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) { + s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} + mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) + mux.HandleFunc("/s/{token}", s.shortLink) + mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares", s.protected(callbacks, s.shares)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}", s.protected(callbacks, s.revokeShare)) mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go new file mode 100644 index 0000000..bd7b126 --- /dev/null +++ b/internal/platform/integration_test.go @@ -0,0 +1,120 @@ +package platform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func TestOnboardingAndSharing(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires an isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + var raw [12]byte + rand.Read(raw[:]) + project := "test-" + base64.RawURLEncoding.EncodeToString(raw[:]) + accounts := []string{} + tokens := []string{} + for range 3 { + var id string + err = db.QueryRowContext(ctx, "INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&id) + if err != nil { + t.Fatal(err) + } + accounts = append(accounts, id) + var value [32]byte + rand.Read(value[:]) + token := base64.RawURLEncoding.EncodeToString(value[:]) + hash := sha256.Sum256([]byte(token)) + _, err = db.ExecContext(ctx, "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '1 hour')", base64.RawURLEncoding.EncodeToString(hash[:]), project, id) + if err != nil { + t.Fatal(err) + } + tokens = append(tokens, token) + } + defer func() { + _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) + }() + callbacks := map[string]string{project: "https://app.example.com/auth/callback"} + mux := http.NewServeMux() + Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ + project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, + }) + call := func(method, path, token string, body any, status int) response { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(method, path, bytes.NewReader(encoded)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status { + t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status) + } + out := response{} + if w.Body.Len() > 0 && w.Code != 302 { + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return out + } + base := "/projects/" + project + call("GET", base+"/workspaces", "", nil, 401) + call("POST", base+"/onboarding", tokens[0], response{"answers": response{}}, 400) + first := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 201) + workspace := first["result"].(map[string]any)["workspace_id"].(string) + retry := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 200) + if retry["result"].(map[string]any)["workspace_id"] != workspace { + t.Fatal("onboarding created a second workspace") + } + workspacePath := base + "/workspaces/" + workspace + share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) + token := share["token"].(string) + call("GET", "/s/"+token, "", nil, 302) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) + call("PATCH", workspacePath, tokens[1], response{"name": "Forbidden"}, 403) + call("GET", workspacePath, tokens[2], nil, 404) + call("PATCH", workspacePath+"/members/"+accounts[0], tokens[0], response{"role": "viewer"}, 409) + call("DELETE", workspacePath+"/shares/"+share["id"].(string), tokens[0], nil, 204) + call("GET", "/s/"+token, "", nil, 404) + var material string + err = db.QueryRowContext(ctx, "INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'article','Shared',$3) RETURNING id::text", project, workspace, accounts[0]).Scan(&material) + if err != nil { + t.Fatal(err) + } + contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) + call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) + call("GET", workspacePath, tokens[2], nil, 404) + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { + t.Fatalf("material grant missing %v", err) + } + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 { + t.Fatalf("click accounting incorrect %v", err) + } +} diff --git a/internal/platform/onboarding.go b/internal/platform/onboarding.go new file mode 100644 index 0000000..c770c46 --- /dev/null +++ b/internal/platform/onboarding.go @@ -0,0 +1,192 @@ +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 +} diff --git a/internal/platform/shares.go b/internal/platform/shares.go new file mode 100644 index 0000000..f660f99 --- /dev/null +++ b/internal/platform/shares.go @@ -0,0 +1,287 @@ +package platform + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "net/http" + "net/url" + "regexp" + "time" +) + +var linkTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) + +func newLinkToken() (string, string) { + var raw [16]byte + rand.Read(raw[:]) + value := base64.RawURLEncoding.EncodeToString(raw[:]) + return value, linkDigest(value) +} +func linkDigest(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +type shareInput struct { + Role string `json:"role"` + MaterialID *string `json:"material_id"` + ExpiresIn int `json:"expires_in"` + MaxUses *int `json:"max_uses"` +} + +func (s *service) shares(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + if r.Method != http.MethodPost && r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT l.id::text,l.material_id::text,l.role,l.expires_at,l.revoked_at,l.max_uses,l.use_count, + (SELECT count(*) FROM link_clicks c WHERE (c.project,c.workspace_id,c.link_id)=(l.project,l.workspace_id,l.id)) +FROM share_links l WHERE project=$1 AND workspace_id=$2 ORDER BY created_at DESC,id LIMIT 200 +`, project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + var material *string + var expires time.Time + var revoked *time.Time + var max *int + var uses, clicks int64 + if err := rows.Scan(&id, &material, &role, &expires, &revoked, &max, &uses, &clicks); err != nil { + return err + } + items = append(items, response{"id": id, "material_id": material, "role": role, "expires_at": expires, "revoked_at": revoked, "max_uses": max, "use_count": uses, "click_count": clicks}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input shareInput + if !decode(w, r, &input) { + return nil + } + if input.ExpiresIn == 0 { + input.ExpiresIn = 7 * 86400 + } + if (input.Role != "admin" && input.Role != "editor" && input.Role != "viewer") || + (input.MaterialID != nil && input.Role == "admin") || input.ExpiresIn < 60 || input.ExpiresIn > 30*86400 || + (input.MaxUses != nil && (*input.MaxUses < 1 || *input.MaxUses > 100000)) { + reject(w, 400, "invalid_share") + return nil + } + token, hash := newLinkToken() + var id string + var expires time.Time + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO share_links(project,workspace_id,material_id,token_hash,role,created_by,expires_at,max_uses) +VALUES($1,$2,$3,$4,$5,$6,now()+$7*interval '1 second',$8) RETURNING id::text,expires_at +`, project, workspace, input.MaterialID, hash, input.Role, account, input.ExpiresIn, input.MaxUses).Scan(&id, &expires) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "url": s.origin + "/s/" + token, "token": token, "role": input.Role, "expires_at": expires}) + return nil +} + +func (s *service) revokeShare(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + result, err := tx.ExecContext(r.Context(), ` +UPDATE share_links SET revoked_at=COALESCE(revoked_at,now()) WHERE project=$1 AND workspace_id=$2 AND id=$3 +`, project, r.PathValue("workspace"), r.PathValue("link")) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +// A public short link records only a referring origin and redirects to a fixed +// frontend. The fragment carries the invitation, never a login session. +func (s *service) shortLink(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return + } + token := r.PathValue("token") + if !linkTokenPattern.MatchString(token) { + reject(w, 404, "not_found") + return + } + ref := "" + if u, err := url.Parse(r.Referer()); err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil { + ref = u.Scheme + "://" + u.Host + if len(ref) > 300 { + ref = "" + } + } + var project string + err := s.db.QueryRowContext(r.Context(), ` +WITH valid AS( + SELECT project,workspace_id,id FROM share_links + WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now() AND (max_uses IS NULL OR use_countnow(), (max_uses IS NULL OR use_count 0 { + if material == nil { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,account_id) DO UPDATE SET role=CASE + WHEN workspace_members.role='admin' OR EXCLUDED.role='admin' THEN 'admin' + WHEN workspace_members.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, account, role) + } else { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE + WHEN material_grants.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, *material, account, role) + } + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "UPDATE share_links SET use_count=use_count+1 WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, id) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 200, response{"workspace_id": workspace, "material_id": material, "role": role, "already_redeemed": n == 0}) + return nil +}