Calls statistics

This commit is contained in:
Daniel Ponte 2025-02-14 12:50:05 -05:00
parent d42abd339d
commit 1e2e606130
9 changed files with 339 additions and 11 deletions

View file

@ -35,14 +35,17 @@ type Store interface {
// Calls gets paginated Calls.
Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error)
// CallStats gets call stats by interval.
CallStats(ctx context.Context, interval calls.StatsInterval, start, end jsontypes.Time) (*calls.Stats, error)
}
type store struct {
type postgresStore struct {
db database.Store
}
func NewStore(db database.Store) *store {
return &store{
func NewStore(db database.Store) *postgresStore {
return &postgresStore{
db: db,
}
}
@ -86,7 +89,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams {
}
}
func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
func (s *postgresStore) AddCall(ctx context.Context, call *calls.Call) error {
_, err := rbac.Check(ctx, call, rbac.WithActions(entities.ActionCreate))
if err != nil {
return err
@ -124,7 +127,7 @@ func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
return nil
}
func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) {
func (s *postgresStore) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) {
_, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
@ -145,7 +148,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio,
}, nil
}
func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
func (s *postgresStore) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
_, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
@ -195,7 +198,7 @@ type CallsParams struct {
AtLeastSeconds *float32 `json:"atLeastSeconds"`
}
func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
_, err = rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, 0, err
@ -253,7 +256,7 @@ func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListC
return rows, int(count), err
}
func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
func (s *postgresStore) Delete(ctx context.Context, id uuid.UUID) error {
callOwn, err := s.getCallOwner(ctx, id)
if err != nil {
return err
@ -267,7 +270,7 @@ func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
return database.FromCtx(ctx).DeleteCall(ctx, id)
}
func (s *store) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, error) {
func (s *postgresStore) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, error) {
subInt, err := database.FromCtx(ctx).GetCallSubmitter(ctx, id)
var sub *users.UserID
@ -277,3 +280,35 @@ func (s *store) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, err
}
return calls.Call{ID: id, Submitter: sub}, err
}
func (s *postgresStore) CallStats(ctx context.Context, interval calls.StatsInterval, start, end jsontypes.Time) (*calls.Stats, error) {
if !interval.IsValid() {
return nil, calls.ErrInvalidInterval
}
cs := &calls.Stats{
Interval: interval,
}
_, err := rbac.Check(ctx, cs, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
db := database.FromCtx(ctx)
dbs, err := db.GetCallStatsByInterval(ctx, string(interval), start.PGTypeTSTZ(), end.PGTypeTSTZ())
if err != nil {
return nil, err
}
cs.Stats = make([]calls.Stat, 0, len(dbs))
for _, st := range dbs {
cs.Stats = append(cs.Stats, calls.Stat{
Count: st.Count,
Time: jsontypes.Time(st.Date.Time),
})
}
return cs, nil
}

46
pkg/calls/stats.go Normal file
View file

@ -0,0 +1,46 @@
package calls
import (
"errors"
"dynatron.me/x/stillbox/internal/jsontypes"
)
type Stats struct {
Stats []Stat `json:"stats"`
Interval StatsInterval `json:"interval"`
}
var (
ErrInvalidInterval = errors.New("invalid interval")
)
func (s *Stats) GetResourceName() string {
return "CallStats"
}
type StatsInterval string
const (
IntervalMinute StatsInterval = "minute"
IntervalHour StatsInterval = "hour"
IntervalDay StatsInterval = "day"
IntervalWeek StatsInterval = "week"
IntervalMonth StatsInterval = "month"
IntervalQuarter StatsInterval = "quarter"
IntervalYear StatsInterval = "year"
)
func (si StatsInterval) IsValid() bool {
switch si {
case IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, IntervalMonth, IntervalQuarter, IntervalYear:
return true
}
return false
}
type Stat struct {
Count int64 `json:"count"`
Time jsontypes.Time `json:"time"`
}

View file

@ -1520,6 +1520,128 @@ func (_c *Store_GetCallAudioByID_Call) RunAndReturn(run func(context.Context, uu
return _c
}
// GetCallStatsByInterval provides a mock function with given fields: ctx, truncField, start, end
func (_m *Store) GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error) {
ret := _m.Called(ctx, truncField, start, end)
if len(ret) == 0 {
panic("no return value specified for GetCallStatsByInterval")
}
var r0 []database.GetCallStatsByIntervalRow
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error)); ok {
return rf(ctx, truncField, start, end)
}
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) []database.GetCallStatsByIntervalRow); ok {
r0 = rf(ctx, truncField, start, end)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]database.GetCallStatsByIntervalRow)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) error); ok {
r1 = rf(ctx, truncField, start, end)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetCallStatsByInterval_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCallStatsByInterval'
type Store_GetCallStatsByInterval_Call struct {
*mock.Call
}
// GetCallStatsByInterval is a helper method to define mock.On call
// - ctx context.Context
// - truncField string
// - start pgtype.Timestamptz
// - end pgtype.Timestamptz
func (_e *Store_Expecter) GetCallStatsByInterval(ctx interface{}, truncField interface{}, start interface{}, end interface{}) *Store_GetCallStatsByInterval_Call {
return &Store_GetCallStatsByInterval_Call{Call: _e.mock.On("GetCallStatsByInterval", ctx, truncField, start, end)}
}
func (_c *Store_GetCallStatsByInterval_Call) Run(run func(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz)) *Store_GetCallStatsByInterval_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(pgtype.Timestamptz), args[3].(pgtype.Timestamptz))
})
return _c
}
func (_c *Store_GetCallStatsByInterval_Call) Return(_a0 []database.GetCallStatsByIntervalRow, _a1 error) *Store_GetCallStatsByInterval_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetCallStatsByInterval_Call) RunAndReturn(run func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByIntervalRow, error)) *Store_GetCallStatsByInterval_Call {
_c.Call.Return(run)
return _c
}
// GetCallStatsByTalkgroup provides a mock function with given fields: ctx, truncField, start, end
func (_m *Store) GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error) {
ret := _m.Called(ctx, truncField, start, end)
if len(ret) == 0 {
panic("no return value specified for GetCallStatsByTalkgroup")
}
var r0 []database.GetCallStatsByTalkgroupRow
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error)); ok {
return rf(ctx, truncField, start, end)
}
if rf, ok := ret.Get(0).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) []database.GetCallStatsByTalkgroupRow); ok {
r0 = rf(ctx, truncField, start, end)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]database.GetCallStatsByTalkgroupRow)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) error); ok {
r1 = rf(ctx, truncField, start, end)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetCallStatsByTalkgroup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCallStatsByTalkgroup'
type Store_GetCallStatsByTalkgroup_Call struct {
*mock.Call
}
// GetCallStatsByTalkgroup is a helper method to define mock.On call
// - ctx context.Context
// - truncField string
// - start pgtype.Timestamptz
// - end pgtype.Timestamptz
func (_e *Store_Expecter) GetCallStatsByTalkgroup(ctx interface{}, truncField interface{}, start interface{}, end interface{}) *Store_GetCallStatsByTalkgroup_Call {
return &Store_GetCallStatsByTalkgroup_Call{Call: _e.mock.On("GetCallStatsByTalkgroup", ctx, truncField, start, end)}
}
func (_c *Store_GetCallStatsByTalkgroup_Call) Run(run func(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz)) *Store_GetCallStatsByTalkgroup_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(pgtype.Timestamptz), args[3].(pgtype.Timestamptz))
})
return _c
}
func (_c *Store_GetCallStatsByTalkgroup_Call) Return(_a0 []database.GetCallStatsByTalkgroupRow, _a1 error) *Store_GetCallStatsByTalkgroup_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetCallStatsByTalkgroup_Call) RunAndReturn(run func(context.Context, string, pgtype.Timestamptz, pgtype.Timestamptz) ([]database.GetCallStatsByTalkgroupRow, error)) *Store_GetCallStatsByTalkgroup_Call {
_c.Call.Return(run)
return _c
}
// GetCallSubmitter provides a mock function with given fields: ctx, id
func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) {
ret := _m.Called(ctx, id)

View file

@ -35,6 +35,8 @@ type Querier interface {
GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error)
GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error)
GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error)
GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByIntervalRow, error)
GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByTalkgroupRow, error)
GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error)
GetDatabaseSize(ctx context.Context) (string, error)
GetIncident(ctx context.Context, id uuid.UUID) (Incident, error)

97
pkg/database/stats.sql.go Normal file
View file

@ -0,0 +1,97 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: stats.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getCallStatsByInterval = `-- name: GetCallStatsByInterval :many
SELECT
COUNT(*),
date_trunc($1, c.call_date)::TIMESTAMPTZ date
FROM calls c
WHERE
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= $2 ELSE TRUE END AND
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= $3 ELSE TRUE END
GROUP BY 2
`
type GetCallStatsByIntervalRow struct {
Count int64 `json:"count"`
Date pgtype.Timestamptz `json:"date"`
}
func (q *Queries) GetCallStatsByInterval(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByIntervalRow, error) {
rows, err := q.db.Query(ctx, getCallStatsByInterval, truncField, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCallStatsByIntervalRow
for rows.Next() {
var i GetCallStatsByIntervalRow
if err := rows.Scan(&i.Count, &i.Date); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getCallStatsByTalkgroup = `-- name: GetCallStatsByTalkgroup :many
SELECT
COUNT(*),
c.system,
c.talkgroup,
date_trunc($1, c.call_date)
FROM calls c
WHERE
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= $2 ELSE TRUE END AND
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= $3 ELSE TRUE END
GROUP BY 2, 3, 4
`
type GetCallStatsByTalkgroupRow struct {
Count int64 `json:"count"`
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
DateTrunc pgtype.Interval `json:"dateTrunc"`
}
func (q *Queries) GetCallStatsByTalkgroup(ctx context.Context, truncField string, start pgtype.Timestamptz, end pgtype.Timestamptz) ([]GetCallStatsByTalkgroupRow, error) {
rows, err := q.db.Query(ctx, getCallStatsByTalkgroup, truncField, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCallStatsByTalkgroupRow
for rows.Next() {
var i GetCallStatsByTalkgroupRow
if err := rows.Scan(
&i.Count,
&i.System,
&i.Talkgroup,
&i.DateTrunc,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -22,6 +22,7 @@ const (
ResourceAlert = "Alert"
ResourceShare = "Share"
ResourceAPIKey = "APIKey"
ResourceCallStats = "CallStats"
ActionRead = "read"
ActionCreate = "create"

View file

@ -12,7 +12,7 @@ import (
)
var (
ErrBadSubject = errors.New("bad subject in token")
ErrBadSubject = errors.New("bad subject in token")
ErrAccessDenied = errors.New("access denied")
)

View file

@ -72,7 +72,7 @@ func (u *User) GetName() string {
}
func (u *User) String() string {
return "USER:"+u.GetName()
return "USER:" + u.GetName()
}
func (u *User) GetRoles() []string {

View file

@ -0,0 +1,25 @@
-- name: GetCallStatsByTalkgroup :many
SELECT
COUNT(*),
c.system,
c.talkgroup,
date_trunc(@trunc_field, c.call_date)
FROM calls c
WHERE
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= @start ELSE TRUE END AND
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= sqlc.narg('end') ELSE TRUE END
GROUP BY 2, 3, 4;
-- name: GetCallStatsByInterval :many
SELECT
COUNT(*),
date_trunc(@trunc_field, c.call_date)::TIMESTAMPTZ date
FROM calls c
WHERE
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= @start ELSE TRUE END AND
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= sqlc.narg('end') ELSE TRUE END
GROUP BY 2;