You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// handler.go 暴露 admin-only 的审计日志查看接口(spec §11 步骤4)。
|
||||
//
|
||||
// GET /api/v1/admin/audit-logs?action=&action_prefix=&target_type=&target_id=
|
||||
// &user_id=&from=&to=&limit=&offset=
|
||||
//
|
||||
// 鉴权:复刻 chat.adminGuard —— Auth 中间件解出 uid,再经 AdminLookup.IsAdmin
|
||||
// 门禁;非 admin 返回 403。from/to 接受 RFC3339 时间字符串。
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// AdminLookup resolves whether a user is an admin (narrow interface; mirrors
|
||||
// chat/acp handler pattern). user.Service satisfies it.
|
||||
type AdminLookup interface {
|
||||
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// Handler exposes the admin audit-log read endpoint.
|
||||
type Handler struct {
|
||||
reader Reader
|
||||
resolver middleware.SessionResolver
|
||||
admin AdminLookup
|
||||
}
|
||||
|
||||
// NewHandler constructs the audit read handler.
|
||||
func NewHandler(reader Reader, resolver middleware.SessionResolver, admin AdminLookup) *Handler {
|
||||
return &Handler{reader: reader, resolver: resolver, admin: admin}
|
||||
}
|
||||
|
||||
// Mount registers the admin audit routes under /api/v1/admin.
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
r.Route("/api/v1/admin/audit-logs", func(r chi.Router) {
|
||||
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||
r.Use(h.adminGuard)
|
||||
r.Get("/", h.list)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) adminGuard(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
h.writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized"))
|
||||
return
|
||||
}
|
||||
isAdmin, err := h.admin.IsAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if !isAdmin {
|
||||
h.writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type auditLogDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type,omitempty"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
f := AuditFilter{
|
||||
Action: q.Get("action"),
|
||||
ActionPrefix: q.Get("action_prefix"),
|
||||
TargetType: q.Get("target_type"),
|
||||
TargetID: q.Get("target_id"),
|
||||
}
|
||||
if v := q.Get("user_id"); v != "" {
|
||||
uid, err := uuid.Parse(v)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 格式错误"))
|
||||
return
|
||||
}
|
||||
f.UserID = &uid
|
||||
}
|
||||
if v := q.Get("from"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "from 须为 RFC3339"))
|
||||
return
|
||||
}
|
||||
f.From = &t
|
||||
}
|
||||
if v := q.Get("to"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "to 须为 RFC3339"))
|
||||
return
|
||||
}
|
||||
f.To = &t
|
||||
}
|
||||
if v := q.Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "limit 须为非负整数"))
|
||||
return
|
||||
}
|
||||
f.Limit = n
|
||||
}
|
||||
if v := q.Get("offset"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "offset 须为非负整数"))
|
||||
return
|
||||
}
|
||||
f.Offset = n
|
||||
}
|
||||
|
||||
entries, total, err := h.reader.List(r.Context(), f)
|
||||
if err != nil {
|
||||
h.writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
out := make([]auditLogDTO, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
dto := auditLogDTO{
|
||||
ID: e.ID,
|
||||
Action: e.Action,
|
||||
TargetType: e.TargetType,
|
||||
TargetID: e.TargetID,
|
||||
IP: e.IP,
|
||||
Metadata: e.Metadata,
|
||||
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if e.UserID != nil {
|
||||
s := e.UserID.String()
|
||||
dto.UserID = &s
|
||||
}
|
||||
out = append(out, dto)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": out, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type fakeResolver struct{ uid uuid.UUID }
|
||||
|
||||
func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
|
||||
if token == "" {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
return f.uid, true, nil
|
||||
}
|
||||
|
||||
type fakeAdmin struct{ admins map[uuid.UUID]bool }
|
||||
|
||||
func (f *fakeAdmin) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) {
|
||||
return f.admins[id], nil
|
||||
}
|
||||
|
||||
type fakeReader struct {
|
||||
lastFilter AuditFilter
|
||||
entries []LogEntry
|
||||
total int
|
||||
}
|
||||
|
||||
func (f *fakeReader) List(_ context.Context, flt AuditFilter) ([]LogEntry, int, error) {
|
||||
f.lastFilter = flt
|
||||
return f.entries, f.total, nil
|
||||
}
|
||||
func (f *fakeReader) PurgeBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
|
||||
|
||||
func mount(reader Reader, uid uuid.UUID, admins map[uuid.UUID]bool) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
NewHandler(reader, &fakeResolver{uid: uid}, &fakeAdmin{admins: admins}).Mount(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAuditHandlerNonAdminForbidden(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{}) // not admin
|
||||
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditHandlerUnauthenticated(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true})
|
||||
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil) // no token
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditHandlerAdminListWithFilters(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
filterUID := uuid.New()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
reader := &fakeReader{
|
||||
entries: []LogEntry{{
|
||||
ID: 7,
|
||||
UserID: &filterUID,
|
||||
Action: "user.login",
|
||||
CreatedAt: now,
|
||||
}},
|
||||
total: 1,
|
||||
}
|
||||
h := mount(reader, uid, map[uuid.UUID]bool{uid: true})
|
||||
|
||||
from := now.Add(-time.Hour).Format(time.RFC3339)
|
||||
url := "/api/v1/admin/audit-logs/?action=user.login&target_type=user&user_id=" +
|
||||
filterUID.String() + "&from=" + from + "&limit=10&offset=5"
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Filter parsed and forwarded to reader.
|
||||
if reader.lastFilter.Action != "user.login" {
|
||||
t.Errorf("action filter = %q, want user.login", reader.lastFilter.Action)
|
||||
}
|
||||
if reader.lastFilter.TargetType != "user" {
|
||||
t.Errorf("target_type filter = %q", reader.lastFilter.TargetType)
|
||||
}
|
||||
if reader.lastFilter.UserID == nil || *reader.lastFilter.UserID != filterUID {
|
||||
t.Errorf("user_id filter not parsed: %v", reader.lastFilter.UserID)
|
||||
}
|
||||
if reader.lastFilter.From == nil {
|
||||
t.Errorf("from filter not parsed")
|
||||
}
|
||||
if reader.lastFilter.Limit != 10 || reader.lastFilter.Offset != 5 {
|
||||
t.Errorf("limit/offset = %d/%d, want 10/5", reader.lastFilter.Limit, reader.lastFilter.Offset)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []auditLogDTO `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Total != 1 || len(resp.Items) != 1 || resp.Items[0].ID != 7 {
|
||||
t.Errorf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditHandlerBadQueryParam(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true})
|
||||
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/?from=not-a-date", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,32 @@
|
||||
-- name: InsertAuditLog :exec
|
||||
INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: ListAuditLog :many
|
||||
-- Paginated read with optional filters. NULL filter args match everything
|
||||
-- (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits).
|
||||
SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at
|
||||
FROM audit_logs
|
||||
WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id'))
|
||||
AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action'))
|
||||
AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%')
|
||||
AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type'))
|
||||
AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id'))
|
||||
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts'))
|
||||
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts'))
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: CountAuditLog :one
|
||||
SELECT COUNT(*)
|
||||
FROM audit_logs
|
||||
WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id'))
|
||||
AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action'))
|
||||
AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%')
|
||||
AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type'))
|
||||
AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id'))
|
||||
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts'))
|
||||
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts'));
|
||||
|
||||
-- name: DeleteAuditLogsBefore :execrows
|
||||
DELETE FROM audit_logs WHERE created_at < $1;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// reader.go 提供审计日志的只读查询路径(spec §11 步骤4)。
|
||||
//
|
||||
// audit.Recorder 是 write-only 门面;Reader 是与之对称的 read 门面,供 admin
|
||||
// 审计查看器(GET /api/v1/admin/audit-logs)与保留期清理 runner 使用。pgReader
|
||||
// 复用 auditsqlc 生成的 List/Count/Delete 查询,并在 pgtype <-> 领域类型间转换。
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
auditsqlc "github.com/yan1h/agent-coding-workflow/internal/audit/sqlc"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// LogEntry 是一条已落库审计记录的只读领域视图。区别于写入用的 Entry:含 ID/
|
||||
// 时间戳,IP 以字符串形式返回。
|
||||
type LogEntry struct {
|
||||
ID int64
|
||||
UserID *uuid.UUID
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
IP string
|
||||
Metadata map[string]any
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AuditFilter 是 List/Count 的可选过滤条件。零值字段表示不过滤。Limit<=0 时由
|
||||
// Reader 兜底为默认页大小;Limit 上限由 Reader 钳制。
|
||||
type AuditFilter struct {
|
||||
UserID *uuid.UUID
|
||||
Action string // 精确匹配
|
||||
ActionPrefix string // 前缀匹配(搜索用)
|
||||
TargetType string
|
||||
TargetID string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultAuditPageSize 是未指定 Limit 时的默认页大小。
|
||||
DefaultAuditPageSize = 50
|
||||
// MaxAuditPageSize 是单页返回的硬上限,避免一次拉全表。
|
||||
MaxAuditPageSize = 500
|
||||
)
|
||||
|
||||
// Reader 是审计读取的对外契约:分页列表 + 总数 + 保留期清理。
|
||||
type Reader interface {
|
||||
List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error)
|
||||
PurgeBefore(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type pgReader struct {
|
||||
q *auditsqlc.Queries
|
||||
}
|
||||
|
||||
// NewPostgresReader 用现有 pgxpool 构造 Reader 实现。
|
||||
func NewPostgresReader(pool *pgxpool.Pool) Reader {
|
||||
return &pgReader{q: auditsqlc.New(pool)}
|
||||
}
|
||||
|
||||
// List 返回符合过滤条件的审计记录页 + 满足条件的总数(用于分页)。
|
||||
func (r *pgReader) List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = DefaultAuditPageSize
|
||||
}
|
||||
if limit > MaxAuditPageSize {
|
||||
limit = MaxAuditPageSize
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
rows, err := r.q.ListAuditLog(ctx, auditsqlc.ListAuditLogParams{
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
UserID: pgUUIDFromPtr(f.UserID),
|
||||
Action: ptr(f.Action),
|
||||
ActionPrefix: ptr(f.ActionPrefix),
|
||||
TargetType: ptr(f.TargetType),
|
||||
TargetID: ptr(f.TargetID),
|
||||
FromTs: tsFromPtr(f.From),
|
||||
ToTs: tsFromPtr(f.To),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, errs.Wrap(err, errs.CodeInternal, "list audit logs")
|
||||
}
|
||||
|
||||
total, err := r.q.CountAuditLog(ctx, auditsqlc.CountAuditLogParams{
|
||||
UserID: pgUUIDFromPtr(f.UserID),
|
||||
Action: ptr(f.Action),
|
||||
ActionPrefix: ptr(f.ActionPrefix),
|
||||
TargetType: ptr(f.TargetType),
|
||||
TargetID: ptr(f.TargetID),
|
||||
FromTs: tsFromPtr(f.From),
|
||||
ToTs: tsFromPtr(f.To),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, errs.Wrap(err, errs.CodeInternal, "count audit logs")
|
||||
}
|
||||
|
||||
out := make([]LogEntry, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToEntry(row))
|
||||
}
|
||||
return out, int(total), nil
|
||||
}
|
||||
|
||||
// PurgeBefore 删除 created_at < before 的全部审计记录,返回删除行数。保留期 runner 调用。
|
||||
func (r *pgReader) PurgeBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||
n, err := r.q.DeleteAuditLogsBefore(ctx, toPgTimestamptz(before))
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "purge audit logs")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func rowToEntry(row auditsqlc.AuditLog) LogEntry {
|
||||
e := LogEntry{
|
||||
ID: row.ID,
|
||||
Action: row.Action,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
if row.UserID.Valid {
|
||||
u := uuid.UUID(row.UserID.Bytes)
|
||||
e.UserID = &u
|
||||
}
|
||||
if row.TargetType != nil {
|
||||
e.TargetType = *row.TargetType
|
||||
}
|
||||
if row.TargetID != nil {
|
||||
e.TargetID = *row.TargetID
|
||||
}
|
||||
if row.Ip != nil {
|
||||
e.IP = row.Ip.String()
|
||||
}
|
||||
if len(row.Metadata) > 0 {
|
||||
_ = json.Unmarshal(row.Metadata, &e.Metadata)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// tsFromPtr 把 *time.Time 转为 pgtype.Timestamptz;nil 映射为 NULL(不过滤)。
|
||||
func tsFromPtr(t *time.Time) pgtype.Timestamptz {
|
||||
if t == nil {
|
||||
return pgtype.Timestamptz{Valid: false}
|
||||
}
|
||||
return pgtype.Timestamptz{Time: *t, Valid: true}
|
||||
}
|
||||
|
||||
// toPgTimestamptz 把非空 time.Time 转为有效 Timestamptz。
|
||||
func toPgTimestamptz(t time.Time) pgtype.Timestamptz {
|
||||
return pgtype.Timestamptz{Time: t, Valid: true}
|
||||
}
|
||||
@@ -12,6 +12,55 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countAuditLog = `-- name: CountAuditLog :one
|
||||
SELECT COUNT(*)
|
||||
FROM audit_logs
|
||||
WHERE ($1::uuid IS NULL OR user_id = $1)
|
||||
AND ($2::text IS NULL OR action = $2)
|
||||
AND ($3::text IS NULL OR action LIKE $3 || '%')
|
||||
AND ($4::text IS NULL OR target_type = $4)
|
||||
AND ($5::text IS NULL OR target_id = $5)
|
||||
AND ($6::timestamptz IS NULL OR created_at >= $6)
|
||||
AND ($7::timestamptz IS NULL OR created_at < $7)
|
||||
`
|
||||
|
||||
type CountAuditLogParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Action *string `json:"action"`
|
||||
ActionPrefix *string `json:"action_prefix"`
|
||||
TargetType *string `json:"target_type"`
|
||||
TargetID *string `json:"target_id"`
|
||||
FromTs pgtype.Timestamptz `json:"from_ts"`
|
||||
ToTs pgtype.Timestamptz `json:"to_ts"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountAuditLog(ctx context.Context, arg CountAuditLogParams) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countAuditLog,
|
||||
arg.UserID,
|
||||
arg.Action,
|
||||
arg.ActionPrefix,
|
||||
arg.TargetType,
|
||||
arg.TargetID,
|
||||
arg.FromTs,
|
||||
arg.ToTs,
|
||||
)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const deleteAuditLogsBefore = `-- name: DeleteAuditLogsBefore :execrows
|
||||
DELETE FROM audit_logs WHERE created_at < $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAuditLogsBefore(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteAuditLogsBefore, createdAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const insertAuditLog = `-- name: InsertAuditLog :exec
|
||||
INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
@@ -37,3 +86,70 @@ func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listAuditLog = `-- name: ListAuditLog :many
|
||||
SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at
|
||||
FROM audit_logs
|
||||
WHERE ($3::uuid IS NULL OR user_id = $3)
|
||||
AND ($4::text IS NULL OR action = $4)
|
||||
AND ($5::text IS NULL OR action LIKE $5 || '%')
|
||||
AND ($6::text IS NULL OR target_type = $6)
|
||||
AND ($7::text IS NULL OR target_id = $7)
|
||||
AND ($8::timestamptz IS NULL OR created_at >= $8)
|
||||
AND ($9::timestamptz IS NULL OR created_at < $9)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAuditLogParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Action *string `json:"action"`
|
||||
ActionPrefix *string `json:"action_prefix"`
|
||||
TargetType *string `json:"target_type"`
|
||||
TargetID *string `json:"target_id"`
|
||||
FromTs pgtype.Timestamptz `json:"from_ts"`
|
||||
ToTs pgtype.Timestamptz `json:"to_ts"`
|
||||
}
|
||||
|
||||
// Paginated read with optional filters. NULL filter args match everything
|
||||
// (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits).
|
||||
func (q *Queries) ListAuditLog(ctx context.Context, arg ListAuditLogParams) ([]AuditLog, error) {
|
||||
rows, err := q.db.Query(ctx, listAuditLog,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
arg.UserID,
|
||||
arg.Action,
|
||||
arg.ActionPrefix,
|
||||
arg.TargetType,
|
||||
arg.TargetID,
|
||||
arg.FromTs,
|
||||
arg.ToTs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AuditLog
|
||||
for rows.Next() {
|
||||
var i AuditLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Action,
|
||||
&i.TargetType,
|
||||
&i.TargetID,
|
||||
&i.Ip,
|
||||
&i.Metadata,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
+240
-17
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
|
||||
}
|
||||
|
||||
type AcpSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
LastStopReason *string `json:"last_stop_reason"`
|
||||
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
|
||||
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
|
||||
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
|
||||
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
|
||||
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
|
||||
TerminatedReason *string `json:"terminated_reason"`
|
||||
SandboxMode *string `json:"sandbox_mode"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
TokensTotal int64 `json:"tokens_total"`
|
||||
}
|
||||
|
||||
type AcpSessionUsage struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
SourceEventID *int64 `json:"source_event_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpTurn struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
TurnIndex int32 `json:"turn_index"`
|
||||
PromptRequestID *string `json:"prompt_request_id"`
|
||||
Status string `json:"status"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
UpdateCount int32 `json:"update_count"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
@@ -94,6 +142,81 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type ChangeRequest struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Number int32 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
State string `json:"state"`
|
||||
ReviewVerdict string `json:"review_verdict"`
|
||||
CiState string `json:"ci_state"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalID *int64 `json:"external_id"`
|
||||
ExternalUrl string `json:"external_url"`
|
||||
MergeCommitSha string `json:"merge_commit_sha"`
|
||||
ReviewedBy pgtype.UUID `json:"reviewed_by"`
|
||||
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CodeChunk struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
FilePath string `json:"file_path"`
|
||||
Lang *string `json:"lang"`
|
||||
StartLine int32 `json:"start_line"`
|
||||
EndLine int32 `json:"end_line"`
|
||||
Content string `json:"content"`
|
||||
ContentSha []byte `json:"content_sha"`
|
||||
TokenCount *int32 `json:"token_count"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CodeIndexRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Status string `json:"status"`
|
||||
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
Dims int32 `json:"dims"`
|
||||
FilesIndexed int32 `json:"files_indexed"`
|
||||
ChunksIndexed int32 `json:"chunks_indexed"`
|
||||
Error *string `json:"error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CommitSummary struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Kind string `json:"kind"`
|
||||
Title *string `json:"title"`
|
||||
BodyMd string `json:"body_md"`
|
||||
Model *string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
@@ -110,6 +233,14 @@ type Conversation struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
}
|
||||
|
||||
type CryptoKey struct {
|
||||
Version int32 `json:"version"`
|
||||
Provider string `json:"provider"`
|
||||
KeyRef *string `json:"key_ref"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
@@ -119,6 +250,7 @@ type GitCredential struct {
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
@@ -135,6 +267,17 @@ type Issue struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
type IssueDependency struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
BlockedID pgtype.UUID `json:"blocked_id"`
|
||||
BlockerID pgtype.UUID `json:"blocker_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type LlmModel struct {
|
||||
@@ -252,6 +396,50 @@ type Notification struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type NotificationPref struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
ImEnabled bool `json:"im_enabled"`
|
||||
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OrchestratorRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Config []byte `json:"config"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type OrchestratorStep struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
Phase string `json:"phase"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
ParentStepID pgtype.UUID `json:"parent_step_id"`
|
||||
Depth int32 `json:"depth"`
|
||||
Status string `json:"status"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
GateResult []byte `json:"gate_result"`
|
||||
LastError *string `json:"last_error"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -264,6 +452,30 @@ type Project struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProjectMember struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
AddedBy pgtype.UUID `json:"added_by"`
|
||||
}
|
||||
|
||||
type ProjectMemory struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
EmbeddingModel *string `json:"embedding_model"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PromptTemplate struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
|
||||
SourceMessageID *int64 `json:"source_message_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
type RequirementPhasePointer struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
|
||||
ApprovedBy pgtype.UUID `json:"approved_by"`
|
||||
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
|
||||
Reference in New Issue
Block a user