diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go index 297ddbb..84b5bc7 100644 --- a/pkg/calls/callstore/store.go +++ b/pkg/calls/callstore/store.go @@ -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 +} diff --git a/pkg/calls/stats.go b/pkg/calls/stats.go new file mode 100644 index 0000000..06d6d90 --- /dev/null +++ b/pkg/calls/stats.go @@ -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"` +} diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index be3d593..34021cd 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -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) diff --git a/pkg/database/querier.go b/pkg/database/querier.go index 36e68d7..f2c7e35 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -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) diff --git a/pkg/database/stats.sql.go b/pkg/database/stats.sql.go new file mode 100644 index 0000000..f3bc314 --- /dev/null +++ b/pkg/database/stats.sql.go @@ -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 +} diff --git a/pkg/rbac/entities/entities.go b/pkg/rbac/entities/entities.go index 42e1548..293a9ca 100644 --- a/pkg/rbac/entities/entities.go +++ b/pkg/rbac/entities/entities.go @@ -22,6 +22,7 @@ const ( ResourceAlert = "Alert" ResourceShare = "Share" ResourceAPIKey = "APIKey" + ResourceCallStats = "CallStats" ActionRead = "read" ActionCreate = "create" diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index b153885..c443da5 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -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") ) diff --git a/pkg/users/user.go b/pkg/users/user.go index b5d5d27..e505da5 100644 --- a/pkg/users/user.go +++ b/pkg/users/user.go @@ -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 { diff --git a/sql/postgres/queries/stats.sql b/sql/postgres/queries/stats.sql new file mode 100644 index 0000000..a1c92a5 --- /dev/null +++ b/sql/postgres/queries/stats.sql @@ -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;