Files
krow_backend/go-api/internal/tools/activity.go
2026-08-28 12:21:44 +05:30

275 lines
9.0 KiB
Go

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
}