package tools import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/repo" ) // Periods a caller may ask for. A closed set, because a period is a window this // code computes — never a date a caller supplies, and never a string // interpolated anywhere near SQL. var periodWindows = map[string]time.Duration{ "today": 24 * time.Hour, "last-7-days": 7 * 24 * time.Hour, "last-30-days": 30 * 24 * time.Hour, "this-month": 0, // computed from the 1st — see windowFor "previous-month": 0, } type activityInput struct { Period string `json:"period"` Limit int `json:"limit"` } // ActivityBreakdown counts the audit log by kind of event and by account. // // The Go port of `activity.breakdown` from the frontend's dataResolver, and the // difference is the entire point of Phase 2. The JavaScript took records that // had already been fetched into the browser: // // 'activity.breakdown': ({ activity = [] }, section, now) => … // // It had no principal, so it could not have authorized anything even if it had // wanted to — the filtering had already happened, or hadn't, somewhere else. // This version takes the caller, resolves the policy, and pushes the resulting // predicate into the query. A talent caller's own scope is a WHERE clause, so // the counts, the shares and the "accounts active" figure are all computed over // exactly the rows that caller could have read directly. I1 and I2, in one // query. func ActivityBreakdown(db repo.Querier) Tool { return Tool{ Name: "activity_breakdown", Description: "Count workspace activity by kind of event and by account, " + "optionally within a period. Returns totals, the number of distinct event " + "kinds, how many accounts were active, and a row per event kind with its " + "count and share of the total. Use this for questions about what has " + "happened, who did it, and in what proportion.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "period": map[string]any{ "type": "string", "enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"}, "description": "The window to count within. Omit to count the whole log. " + "Windows are computed from the current date; do not pass a date.", }, "limit": map[string]any{ "type": "integer", "minimum": 1, "maximum": 100, "description": "How many event kinds to return, most frequent first. Defaults to 20.", }, }, "additionalProperties": false, }, Effect: EffectRead, MaxResultBytes: DefaultMaxResultBytes, Handler: activityBreakdownHandler(db), } } func activityBreakdownHandler(db repo.Querier) Handler { return func(ctx context.Context, tc Context, inputs json.RawMessage) Result { // Authorization, first line of the body. §8. role, ok := domain.ParseRole(tc.Principal.Role) if !ok || !activityPolicy().Allows(domain.OpList, role) { return Denied() } if tc.OrgID() == "" { // No tenant means no query. I5 — there is no "all organizations" // read, and a missing org is a bug upstream, not a wildcard. return Denied() } var in activityInput if len(inputs) > 0 { if err := json.Unmarshal(inputs, &in); err != nil { return Failf(CodeInvalidInput, "the arguments were not valid JSON") } } if in.Limit <= 0 { in.Limit = 20 } if in.Limit > 100 { in.Limit = 100 } from, to, err := windowFor(in.Period, time.Now()) if err != nil { return Failf(CodeInvalidInput, "%s", err.Error()) } // The predicate, built from the policy rather than written by hand. // Every value is a bind parameter; no identifier comes from input. where := []string{"org_id = $1::uuid"} args := []any{tc.OrgID()} if scope := activityPolicy().ScopeFor(role); scope.Kind == domain.ScopeEmail { // A talent caller sees their own entries. Pushed into the query, // so the totals and shares below are computed over their rows and // nobody else's — post-filtering here would leak the organization's // volume through every percentage. args = append(args, tc.Principal.Email) where = append(where, fmt.Sprintf("%s = $%d", scope.Column, len(args))) } if !from.IsZero() { args = append(args, from) where = append(where, fmt.Sprintf("created_date >= $%d", len(args))) args = append(args, to) where = append(where, fmt.Sprintf("created_date < $%d", len(args))) } query := ` SELECT event_type, count(*) AS n FROM user_activity WHERE ` + strings.Join(where, " AND ") + ` GROUP BY event_type ORDER BY n DESC, event_type ASC` rows, err := db.Query(ctx, query, args...) if err != nil { return Failf(CodeFailed, "the activity log could not be read") } defer rows.Close() type kind struct { Event string `json:"event"` Count int64 `json:"count"` Share int `json:"sharePercent"` } var ( kinds []kind total int64 ) for rows.Next() { var eventType string var n int64 if err := rows.Scan(&eventType, &n); err != nil { return Failf(CodeFailed, "the activity log could not be read") } total += n // The stored name is a machine key. Rendered as words so the model // is not left translating `hire_candidate` and guessing. kinds = append(kinds, kind{Event: strings.ReplaceAll(eventType, "_", " "), Count: n}) } if err := rows.Err(); err != nil { return Failf(CodeFailed, "the activity log could not be read") } for i := range kinds { if total > 0 { kinds[i].Share = int(float64(kinds[i].Count)/float64(total)*100 + 0.5) } } // Counted before the limit is applied, so "how many kinds are there" // stays true even when the list shown is shorter. distinctKinds := len(kinds) omitted := 0 if len(kinds) > in.Limit { omitted = len(kinds) - in.Limit kinds = kinds[:in.Limit] } accounts, err := countActiveAccounts(ctx, db, where, args) if err != nil { return Failf(CodeFailed, "the activity log could not be read") } data := map[string]any{ "totalEvents": total, "distinctKinds": distinctKinds, "activeAccounts": accounts, "kinds": kinds, "period": periodOrAll(in.Period), } // Never a silent drop: a model handed a shortened list with no marker // reasons about it as if it were the whole. if omitted > 0 { data["omittedKinds"] = omitted } if total == 0 { data["note"] = "No activity matches that. This is a real answer, not a failure to look." } return OK(data) } } // countActiveAccounts counts distinct actors under the same predicate. // // The same WHERE the breakdown used, so the two figures cannot disagree — a // separate hand-written predicate here is exactly how "12 events across 40 // accounts" gets shipped. func countActiveAccounts(ctx context.Context, db repo.Querier, where []string, args []any) (int64, error) { var n int64 err := db.QueryRow(ctx, `SELECT count(DISTINCT user_email) FROM user_activity WHERE `+strings.Join(where, " AND "), args..., ).Scan(&n) return n, err } func periodOrAll(p string) string { if p == "" { return "all time" } return p } // windowFor resolves a period name to a half-open [from, to). // // Computed from the clock at read time, never stored and never supplied. A zero // `from` means "no window" — count everything. func windowFor(period string, now time.Time) (from, to time.Time, err error) { if period == "" { return time.Time{}, time.Time{}, nil } if _, ok := periodWindows[period]; !ok { return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period) } startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) switch period { case "today": return startOfDay, startOfDay.AddDate(0, 0, 1), nil case "last-7-days": return startOfDay.AddDate(0, 0, -6), startOfDay.AddDate(0, 0, 1), nil case "last-30-days": return startOfDay.AddDate(0, 0, -29), startOfDay.AddDate(0, 0, 1), nil case "this-month": return firstOfMonth, firstOfMonth.AddDate(0, 1, 0), nil case "previous-month": return firstOfMonth.AddDate(0, -1, 0), firstOfMonth, nil } return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period) } // activityPolicy is the audit log's access rules. // // Read from the descriptor table rather than restated here. §13 lists "passing // the tenant id as a plain function argument through five layers" as an // anti-pattern for the same reason this matters: an authorization rule with two // copies has two chances to drift, and the copy in the tool layer would be the // one nobody re-reads when the policy changes. // // A missing descriptor yields a nil *Policy, which denies everything — the // deny-by-default the table already promises. func activityPolicy() *domain.Policy { res, ok := domain.ResourceByPath["user-activity"] if !ok { return nil } return res.Policy }