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 gets paginated Calls.
Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error) 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 db database.Store
} }
func NewStore(db database.Store) *store { func NewStore(db database.Store) *postgresStore {
return &store{ return &postgresStore{
db: db, 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)) _, err := rbac.Check(ctx, call, rbac.WithActions(entities.ActionCreate))
if err != nil { if err != nil {
return err return err
@ -124,7 +127,7 @@ func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
return nil 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)) _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, err return nil, err
@ -145,7 +148,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio,
}, nil }, 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)) _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, err return nil, err
@ -195,7 +198,7 @@ type CallsParams struct {
AtLeastSeconds *float32 `json:"atLeastSeconds"` 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)) _, err = rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, 0, err 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 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) callOwn, err := s.getCallOwner(ctx, id)
if err != nil { if err != nil {
return err return err
@ -267,7 +270,7 @@ func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
return database.FromCtx(ctx).DeleteCall(ctx, id) 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) subInt, err := database.FromCtx(ctx).GetCallSubmitter(ctx, id)
var sub *users.UserID 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 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 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 // GetCallSubmitter provides a mock function with given fields: ctx, id
func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) { func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)

View file

@ -35,6 +35,8 @@ type Querier interface {
GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error) GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error)
GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error)
GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, 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) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error)
GetDatabaseSize(ctx context.Context) (string, error) GetDatabaseSize(ctx context.Context) (string, error)
GetIncident(ctx context.Context, id uuid.UUID) (Incident, 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" ResourceAlert = "Alert"
ResourceShare = "Share" ResourceShare = "Share"
ResourceAPIKey = "APIKey" ResourceAPIKey = "APIKey"
ResourceCallStats = "CallStats"
ActionRead = "read" ActionRead = "read"
ActionCreate = "create" ActionCreate = "create"

View file

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

View file

@ -72,7 +72,7 @@ func (u *User) GetName() string {
} }
func (u *User) String() string { func (u *User) String() string {
return "USER:"+u.GetName() return "USER:" + u.GetName()
} }
func (u *User) GetRoles() []string { 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;