mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) PR3 of the Grafana board metrics split (parent MUL-2328). Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics collector covering the activation/community/commercial funnels, and binds every PostHog event emission to a matching metric increment so the two sides cannot drift. Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*, cloud_waitlist_joined. Content: issue_created, chat_message_sent, agent_created, squad_created, autopilot_created, issue_executed. Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram, daemon_ws_message_received_total. Autopilot: autopilot_run_started/terminal/skipped. Webhook/GitHub: webhook_delivery_total, github_event_received_total, github_pr_review_total, github_pr_merge_seconds histogram. CloudRuntime: cloudruntime_request_total + duration histogram, wired through a small RequestRecorder interface so the cloudruntime package stays decoupled from metrics. Commercial: feedback_submitted, contact_sales_submitted. The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog event AND increments the matching counter via IncForEvent dispatch, reading labels from the analytics event Properties. Every existing h.Analytics.Capture(analytics.X(...)) call site has been migrated to the helper across handler/, service/, and cmd/server/runtime_sweeper.go. Lint enforcement (server/internal/metrics/business_pairing_test.go): - TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in analytics/events.go either dispatches via IncForEvent or is in the taskMetricEvents allow-list (PR2 typed RecordTask* methods). - TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/ service/cmd-server for direct Analytics.Capture(...) calls — only service/task.go's captureTaskEvent helper is allow-listed. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third arg of every metrics.RecordEvent call is built from analytics.*. Cardinality protection: all new label values pass through fixed allow-lists in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'. Refs: - Spec MUL-2328 / MUL-2949. - Builds on PR2 (MUL-2948) — collectors registered through the same BusinessMetrics struct, no separate Registry. - Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason label via NormalizeFailureReason. Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total emission point (no review event handler exists yet — counter is defined, TODO to wire up when /api/webhooks/github grows pull_request_review handling). Co-authored-by: multica-agent <github@multica.ai> * fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949) Addresses 张大彪's review on #3698: 1. signup_source: NormalizeSignupSource added to labels_pr3.go with a fixed allow-list bucket (direct/google/twitter/linkedin/.../other). Parses JSON cookie payload for utm_source/source/referrer fields, strips URL schemes, maps well-known hostnames to channel buckets. PostHog event still ships the raw cookie value for analytics; only the Prometheus label is bucketed. 2. Filled the unknown/other label gaps: - analytics.IssueCreated and analytics.ChatMessageSent now take a platform parameter sourced from middleware.ClientMetadataFromContext (X-Client-Platform header) at the handler. Autopilot-originated issues stamp PlatformServer. - analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback reads req.Kind (default "general") so the picker selection lights up the metric's kind label instead of long-term "other". - analytics.ContactSalesSubmitted now takes a formSource (page / onboarding / agents_page); CreateContactSales reads req.Source. The metric reads ev.Properties["form_source"] so the analytics CoreProperties.Source ("marketing_contact_sales") stays backward-compat for PostHog dashboards. 3. analytics.OnboardingStarted helper added; server-side emission lives in PatchOnboarding, fired exactly once per user on the first PATCH that carries a non-empty questionnaire payload (firstTouch logic compares prior bytes against {} / null). Frontend onboarding_started keeps firing on page open; the server emission is what guarantees the Prometheus counter exists so Grafana can be cross-checked against the PostHog funnel without depending on the SDK roundtrip. 4. business_pairing_test.go tightened: - TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at function granularity (just captureTaskEvent in service/task.go), not whole-file. Any future naked Capture in the same file fails CI. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use tracking inside the enclosing FuncDecl: when RecordEvent's third arg is an *ast.Ident, the test walks the function body for the assignment that defined it and confirms the RHS is an analytics.<Helper>(...) call. Bare local idents that didn't originate from analytics are now caught. 5. gofmt -w applied across the touched files; gofmt -l clean. Tests: go test ./internal/metrics/... ./internal/analytics/... pass. Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier failures on origin/main are DB-environment-dependent and not regressions from this change. Co-authored-by: multica-agent <github@multica.ai> * fix(server): normalise onboarding_started platform label + regression test (MUL-2949) Addresses 张大彪's last review nit: - IncForEvent's EventOnboardingStarted case now wraps the platform property with NormalizePlatform, matching every other platform-bearing metric. A misbehaving frontend can no longer leak a raw X-Client-Platform header value into the multica_onboarding_started_total{platform=...} series. - New labels_pr3_test.go covers every PR3 normalizer with both a happy-path value and an unknown value, asserting the unknown collapses to the documented fallback bucket. Includes a focused regression for onboarding_started: emits one event with an attacker-shaped platform string and asserts the metric only exposes web + unknown label values (no raw header bleed). - testutil.go gains a small GatherForTest helper so the regression test can pull the typed MetricFamily map without re-implementing the registry-walk dance. Co-authored-by: multica-agent <github@multica.ai> * fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949) Final review touch-ups before merge: - IncForEvent's EventWorkspaceCreated case wraps source through NormalizeTaskSource, matching the other source-bearing dispatches (issue_created, agent_created, issue_executed). Closes the last raw property leak in the dispatcher table. - business_pairing_test.go inline docstrings now spell out the two known limitations of the lint gate that 张大彪 / Eve flagged: analyticsBackedIdents matches by ident NAME (not SSA def-use, so a nested-scope shadow could pass) and isMetricsRecordEvent hard-codes the import alias set. PR description carries a Follow-ups section with the same two items so the work is visible after merge. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent+wei@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
703 lines
22 KiB
Go
703 lines
22 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"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"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
var nonAlpha = regexp.MustCompile(`[^a-zA-Z]`)
|
|
var workspaceSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
|
|
|
// generateIssuePrefix produces a 2-5 char uppercase prefix from a workspace name.
|
|
// Examples: "Jiayuan's Workspace" → "JIA", "My Team" → "MYT", "AB" → "AB".
|
|
func generateIssuePrefix(name string) string {
|
|
letters := nonAlpha.ReplaceAllString(name, "")
|
|
if len(letters) == 0 {
|
|
return "WS"
|
|
}
|
|
letters = strings.ToUpper(letters)
|
|
if len(letters) > 3 {
|
|
letters = letters[:3]
|
|
}
|
|
return letters
|
|
}
|
|
|
|
type WorkspaceResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description *string `json:"description"`
|
|
Context *string `json:"context"`
|
|
Settings any `json:"settings"`
|
|
Repos any `json:"repos"`
|
|
IssuePrefix string `json:"issue_prefix"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func workspaceToResponse(w db.Workspace) WorkspaceResponse {
|
|
var settings any
|
|
if w.Settings != nil {
|
|
json.Unmarshal(w.Settings, &settings)
|
|
}
|
|
if settings == nil {
|
|
settings = map[string]any{}
|
|
}
|
|
var repos any
|
|
if w.Repos != nil {
|
|
json.Unmarshal(w.Repos, &repos)
|
|
}
|
|
if repos == nil {
|
|
repos = []any{}
|
|
}
|
|
return WorkspaceResponse{
|
|
ID: uuidToString(w.ID),
|
|
Name: w.Name,
|
|
Slug: w.Slug,
|
|
Description: textToPtr(w.Description),
|
|
Context: textToPtr(w.Context),
|
|
Settings: settings,
|
|
Repos: repos,
|
|
IssuePrefix: w.IssuePrefix,
|
|
AvatarURL: textToPtr(w.AvatarUrl),
|
|
CreatedAt: timestampToString(w.CreatedAt),
|
|
UpdatedAt: timestampToString(w.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
type MemberResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func memberToResponse(m db.Member) MemberResponse {
|
|
return MemberResponse{
|
|
ID: uuidToString(m.ID),
|
|
WorkspaceID: uuidToString(m.WorkspaceID),
|
|
UserID: uuidToString(m.UserID),
|
|
Role: m.Role,
|
|
CreatedAt: timestampToString(m.CreatedAt),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
workspaces, err := h.Queries.ListWorkspaces(r.Context(), parseUUID(userID))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list workspaces")
|
|
return
|
|
}
|
|
|
|
resp := make([]WorkspaceResponse, len(workspaces))
|
|
for i, ws := range workspaces {
|
|
resp[i] = workspaceToResponse(ws)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
id := workspaceIDFromURL(r, "id")
|
|
idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ws, err := h.Queries.GetWorkspace(r.Context(), idUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "workspace not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, workspaceToResponse(ws))
|
|
}
|
|
|
|
type CreateWorkspaceRequest struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description *string `json:"description"`
|
|
Context *string `json:"context"`
|
|
IssuePrefix *string `json:"issue_prefix"`
|
|
}
|
|
|
|
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Self-host gate (#3433): when the operator has set
|
|
// DISABLE_WORKSPACE_CREATION=true, no caller — including existing
|
|
// workspace owners — may create additional workspaces. The frontend
|
|
// hides every "Create workspace" affordance via /api/config, but the
|
|
// 403 here is the only authoritative check.
|
|
if h.cfg.DisableWorkspaceCreation {
|
|
writeError(w, http.StatusForbidden, "workspace creation is disabled for this instance")
|
|
return
|
|
}
|
|
|
|
var req CreateWorkspaceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
|
|
if req.Name == "" || req.Slug == "" {
|
|
writeError(w, http.StatusBadRequest, "name and slug are required")
|
|
return
|
|
}
|
|
if !workspaceSlugPattern.MatchString(req.Slug) {
|
|
writeError(w, http.StatusBadRequest, "slug must contain only lowercase letters, numbers, and hyphens")
|
|
return
|
|
}
|
|
if isReservedSlug(req.Slug) {
|
|
writeError(w, http.StatusBadRequest, "slug is reserved")
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create workspace")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
issuePrefix := generateIssuePrefix(req.Name)
|
|
if req.IssuePrefix != nil && strings.TrimSpace(*req.IssuePrefix) != "" {
|
|
issuePrefix = strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
|
|
}
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
|
|
Name: req.Name,
|
|
Slug: req.Slug,
|
|
Description: ptrToText(req.Description),
|
|
Context: ptrToText(req.Context),
|
|
IssuePrefix: issuePrefix,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "workspace slug already exists")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
|
|
return
|
|
}
|
|
|
|
_, err = qtx.CreateMember(r.Context(), db.CreateMemberParams{
|
|
WorkspaceID: ws.ID,
|
|
UserID: parseUUID(userID),
|
|
Role: "owner",
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to add owner: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// NOTE: CreateWorkspace deliberately does NOT mark the user as
|
|
// onboarded. The `onboarded_at` flag is owned by CompleteOnboarding
|
|
// (Step 3 of the flow) and by AcceptInvitation (invitee joining an
|
|
// existing workspace). This decouples "the user has a workspace"
|
|
// from "the user has finished setup"; the workspace-layer route
|
|
// gate (web layout / desktop App.tsx overlay) redirects un-onboarded
|
|
// users back to /onboarding instead.
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create workspace")
|
|
return
|
|
}
|
|
|
|
wsID := uuidToString(ws.ID)
|
|
|
|
// "Is this the user's first workspace?" is derived in PostHog by looking
|
|
// at whether they have a prior workspace_created event, not stamped at
|
|
// emit time. Stamping here would race under concurrent creates without
|
|
// a schema change, and the event stream answers the question exactly.
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.WorkspaceCreated(userID, wsID))
|
|
|
|
slog.Info("workspace created", append(logger.RequestAttrs(r), "workspace_id", wsID, "name", ws.Name, "slug", ws.Slug)...)
|
|
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
|
|
}
|
|
|
|
type UpdateWorkspaceRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Context *string `json:"context"`
|
|
Settings any `json:"settings"`
|
|
Repos any `json:"repos"`
|
|
IssuePrefix *string `json:"issue_prefix"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
|
|
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
id := workspaceIDFromURL(r, "id")
|
|
idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req UpdateWorkspaceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
params := db.UpdateWorkspaceParams{
|
|
ID: idUUID,
|
|
}
|
|
if req.Name != nil {
|
|
name := strings.TrimSpace(*req.Name)
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
params.Name = pgtype.Text{String: name, Valid: true}
|
|
}
|
|
if req.Description != nil {
|
|
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
|
}
|
|
if req.Context != nil {
|
|
params.Context = pgtype.Text{String: *req.Context, Valid: true}
|
|
}
|
|
if req.Settings != nil {
|
|
s, _ := json.Marshal(req.Settings)
|
|
params.Settings = s
|
|
}
|
|
if req.Repos != nil {
|
|
reposJSON, _ := json.Marshal(req.Repos)
|
|
params.Repos = reposJSON
|
|
}
|
|
if req.IssuePrefix != nil {
|
|
prefix := strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
|
|
if prefix != "" {
|
|
params.IssuePrefix = pgtype.Text{String: prefix, Valid: true}
|
|
}
|
|
}
|
|
if req.AvatarURL != nil {
|
|
params.AvatarUrl = pgtype.Text{String: *req.AvatarURL, Valid: true}
|
|
}
|
|
|
|
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
|
|
if err != nil {
|
|
slog.Warn("update workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", id)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to update workspace: "+err.Error())
|
|
return
|
|
}
|
|
|
|
slog.Info("workspace updated", append(logger.RequestAttrs(r), "workspace_id", id)...)
|
|
userID := requestUserID(r)
|
|
h.publish(protocol.EventWorkspaceUpdated, uuidToString(ws.ID), "member", userID, map[string]any{"workspace": workspaceToResponse(ws)})
|
|
|
|
writeJSON(w, http.StatusOK, workspaceToResponse(ws))
|
|
}
|
|
|
|
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list members")
|
|
return
|
|
}
|
|
|
|
resp := make([]MemberResponse, len(members))
|
|
for i, m := range members {
|
|
resp[i] = memberToResponse(m)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
type MemberWithUserResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"`
|
|
CreatedAt string `json:"created_at"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
|
|
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
members, err := h.Queries.ListMembersWithUser(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list members")
|
|
return
|
|
}
|
|
|
|
resp := make([]MemberWithUserResponse, len(members))
|
|
for i, m := range members {
|
|
resp[i] = MemberWithUserResponse{
|
|
ID: uuidToString(m.ID),
|
|
WorkspaceID: uuidToString(m.WorkspaceID),
|
|
UserID: uuidToString(m.UserID),
|
|
Role: m.Role,
|
|
CreatedAt: timestampToString(m.CreatedAt),
|
|
Name: m.UserName,
|
|
Email: m.UserEmail,
|
|
AvatarURL: textToPtr(m.UserAvatarUrl),
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
type CreateMemberRequest struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func memberWithUserResponse(member db.Member, user db.User) MemberWithUserResponse {
|
|
return MemberWithUserResponse{
|
|
ID: uuidToString(member.ID),
|
|
WorkspaceID: uuidToString(member.WorkspaceID),
|
|
UserID: uuidToString(member.UserID),
|
|
Role: member.Role,
|
|
CreatedAt: timestampToString(member.CreatedAt),
|
|
Name: user.Name,
|
|
Email: user.Email,
|
|
AvatarURL: textToPtr(user.AvatarUrl),
|
|
}
|
|
}
|
|
|
|
func normalizeMemberRole(role string) (string, bool) {
|
|
if role == "" {
|
|
return "member", true
|
|
}
|
|
|
|
role = strings.TrimSpace(role)
|
|
switch role {
|
|
case "owner", "admin", "member":
|
|
return role, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
requester, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateMemberRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
if email == "" {
|
|
writeError(w, http.StatusBadRequest, "email is required")
|
|
return
|
|
}
|
|
|
|
role, valid := normalizeMemberRole(req.Role)
|
|
if !valid {
|
|
writeError(w, http.StatusBadRequest, "invalid member role")
|
|
return
|
|
}
|
|
if role == "owner" && requester.Role != "owner" {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return
|
|
}
|
|
|
|
user, err := h.Queries.GetUserByEmail(r.Context(), email)
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
// Auto-create user with email so they can be invited before signing up
|
|
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
|
|
Name: email,
|
|
Email: email,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create user")
|
|
return
|
|
}
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, "failed to load user")
|
|
return
|
|
}
|
|
}
|
|
|
|
member, err := h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
|
|
WorkspaceID: requester.WorkspaceID,
|
|
UserID: user.ID,
|
|
Role: role,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "user is already a member")
|
|
return
|
|
}
|
|
slog.Warn("create member failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "email", email)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create member")
|
|
return
|
|
}
|
|
|
|
slog.Info("member added", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "email", email, "role", role)...)
|
|
userID := requestUserID(r)
|
|
eventPayload := map[string]any{"member": memberWithUserResponse(member, user)}
|
|
if ws, err := h.Queries.GetWorkspace(r.Context(), requester.WorkspaceID); err == nil {
|
|
eventPayload["workspace_name"] = ws.Name
|
|
}
|
|
h.publish(protocol.EventMemberAdded, uuidToString(requester.WorkspaceID), "member", userID, eventPayload)
|
|
|
|
writeJSON(w, http.StatusCreated, memberWithUserResponse(member, user))
|
|
}
|
|
|
|
type UpdateMemberRequest struct {
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
requester, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
memberID := chi.URLParam(r, "memberId")
|
|
memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
|
|
if !ok {
|
|
return
|
|
}
|
|
target, err := h.Queries.GetMember(r.Context(), memberUUID)
|
|
if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
|
|
writeError(w, http.StatusNotFound, "member not found")
|
|
return
|
|
}
|
|
|
|
var req UpdateMemberRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Role) == "" {
|
|
writeError(w, http.StatusBadRequest, "role is required")
|
|
return
|
|
}
|
|
|
|
role, valid := normalizeMemberRole(req.Role)
|
|
if !valid {
|
|
writeError(w, http.StatusBadRequest, "invalid member role")
|
|
return
|
|
}
|
|
|
|
if (target.Role == "owner" || role == "owner") && requester.Role != "owner" {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return
|
|
}
|
|
|
|
if target.Role == "owner" && role != "owner" {
|
|
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update member")
|
|
return
|
|
}
|
|
if countOwners(members) <= 1 {
|
|
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
|
|
return
|
|
}
|
|
}
|
|
|
|
updatedMember, err := h.Queries.UpdateMemberRole(r.Context(), db.UpdateMemberRoleParams{
|
|
ID: target.ID,
|
|
Role: role,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update member")
|
|
return
|
|
}
|
|
|
|
h.MembershipCache.Invalidate(r.Context(), uuidToString(target.UserID), workspaceID)
|
|
|
|
user, err := h.Queries.GetUser(r.Context(), updatedMember.UserID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load member")
|
|
return
|
|
}
|
|
|
|
userID := requestUserID(r)
|
|
h.publish(protocol.EventMemberUpdated, uuidToString(requester.WorkspaceID), "member", userID, map[string]any{
|
|
"member": memberWithUserResponse(updatedMember, user),
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, memberWithUserResponse(updatedMember, user))
|
|
}
|
|
|
|
func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
requester, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
memberID := chi.URLParam(r, "memberId")
|
|
memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
|
|
if !ok {
|
|
return
|
|
}
|
|
target, err := h.Queries.GetMember(r.Context(), memberUUID)
|
|
if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
|
|
writeError(w, http.StatusNotFound, "member not found")
|
|
return
|
|
}
|
|
|
|
if target.Role == "owner" && requester.Role != "owner" {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return
|
|
}
|
|
|
|
if target.Role == "owner" {
|
|
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete member")
|
|
return
|
|
}
|
|
if countOwners(members) <= 1 {
|
|
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
|
|
return
|
|
}
|
|
}
|
|
|
|
requesterUserID := requestUserID(r)
|
|
result, err := h.revokeAndRemoveMember(r.Context(), target.WorkspaceID, target.UserID, target.ID, parseUUID(requesterUserID))
|
|
if err != nil {
|
|
slog.Warn("delete member failed", append(logger.RequestAttrs(r), "error", err, "member_id", memberID, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete member")
|
|
return
|
|
}
|
|
|
|
h.MembershipCache.Invalidate(r.Context(), uuidToString(target.UserID), workspaceID)
|
|
|
|
wsIDStr := uuidToString(requester.WorkspaceID)
|
|
logRevocation(result, wsIDStr, uuidToString(target.UserID))
|
|
h.publishRevocation(r.Context(), result, wsIDStr, "member", requesterUserID)
|
|
|
|
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(target.ID), "workspace_id", workspaceID, "user_id", uuidToString(target.UserID))...)
|
|
h.publish(protocol.EventMemberRemoved, wsIDStr, "member", requesterUserID, map[string]any{
|
|
"member_id": uuidToString(target.ID),
|
|
"workspace_id": wsIDStr,
|
|
"user_id": uuidToString(target.UserID),
|
|
})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if member.Role == "owner" {
|
|
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
|
|
return
|
|
}
|
|
if countOwners(members) <= 1 {
|
|
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
|
|
return
|
|
}
|
|
}
|
|
|
|
result, err := h.revokeAndRemoveMember(r.Context(), member.WorkspaceID, member.UserID, member.ID, member.UserID)
|
|
if err != nil {
|
|
slog.Warn("leave workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
|
|
return
|
|
}
|
|
|
|
h.MembershipCache.Invalidate(r.Context(), uuidToString(member.UserID), workspaceID)
|
|
|
|
userID := requestUserID(r)
|
|
logRevocation(result, workspaceID, uuidToString(member.UserID))
|
|
h.publishRevocation(r.Context(), result, workspaceID, "member", userID)
|
|
|
|
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "user_id", uuidToString(member.UserID))...)
|
|
h.publish(protocol.EventMemberRemoved, workspaceID, "member", userID, map[string]any{
|
|
"member_id": uuidToString(member.ID),
|
|
"workspace_id": workspaceID,
|
|
"user_id": uuidToString(member.UserID),
|
|
})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := workspaceIDFromURL(r, "id")
|
|
|
|
// Defense in depth: the route is already gated by the
|
|
// RequireWorkspaceRoleFromURL("owner") middleware, but we re-check here
|
|
// so that the handler is safe regardless of how it gets wired up
|
|
// (direct calls in tests, future router refactors, etc.).
|
|
requester, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if requester.Role != "owner" {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return
|
|
}
|
|
|
|
// Invalidate membership cache for all workspace members before deletion.
|
|
// After CASCADE deletes the member rows, cache entries become harmless
|
|
// orphans (downstream lookups for the deleted workspace will fail), but
|
|
// proactive invalidation prevents any stale-access window up to TTL.
|
|
if members, err := h.Queries.ListMembers(r.Context(), requester.WorkspaceID); err == nil {
|
|
for _, m := range members {
|
|
h.MembershipCache.Invalidate(r.Context(), uuidToString(m.UserID), workspaceID)
|
|
}
|
|
}
|
|
|
|
// At this point workspaceMember has resolved → workspaceID is a valid UUID
|
|
// (the lookup would have errored otherwise), so reuse the resolved value.
|
|
if err := h.Queries.DeleteWorkspace(r.Context(), requester.WorkspaceID); err != nil {
|
|
slog.Warn("delete workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
|
|
return
|
|
}
|
|
|
|
slog.Info("workspace deleted", append(logger.RequestAttrs(r), "workspace_id", workspaceID)...)
|
|
h.publish(protocol.EventWorkspaceDeleted, workspaceID, "member", requestUserID(r), map[string]any{
|
|
"workspace_id": workspaceID,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|