diff --git a/internal/common/pagination.go b/internal/common/pagination.go index d6996d9..aacdf7d 100644 --- a/internal/common/pagination.go +++ b/internal/common/pagination.go @@ -13,3 +13,32 @@ func (p Pagination) OffsetPerPage(perPageDefault int) (offset int32, perPage int return } + +type SortDirection string + +const ( + DirAsc SortDirection = "asc" + DirDesc SortDirection = "desc" +) + +func (t *SortDirection) DirString(def SortDirection) string { + if t == nil { + return string(def) + } + + return string(*t) +} + +func (t *SortDirection) IsValid() bool { + if t == nil { + return true + } + + switch *t { + case DirAsc, DirDesc: + return true + } + + return false + +} diff --git a/internal/jsontypes/jsontime.go b/internal/jsontypes/jsontime.go index 8c2f164..6658c40 100644 --- a/internal/jsontypes/jsontime.go +++ b/internal/jsontypes/jsontime.go @@ -6,6 +6,7 @@ import ( "time" "github.com/araddon/dateparse" + "github.com/jackc/pgx/v5/pgtype" "gopkg.in/yaml.v3" ) @@ -27,6 +28,17 @@ func (t *Time) UnmarshalYAML(n *yaml.Node) error { return nil } +func (t *Time) PGTypeTSTZ() pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + + return pgtype.Timestamptz{ + Time: time.Time(*t), + Valid: true, + } +} + func (t *Time) UnmarshalJSON(b []byte) error { s := strings.Trim(string(b), `"`) tm, err := dateparse.ParseAny(s) diff --git a/pkg/calls/call.go b/pkg/calls/call.go index 6359940..dcb77a3 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -5,6 +5,7 @@ import ( "time" "dynatron.me/x/stillbox/internal/audio" + "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/auth" "dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/talkgroups" @@ -32,6 +33,14 @@ func (d CallDuration) Seconds() int32 { return int32(time.Duration(d).Seconds()) } +// CallAudio is a skinny Call used for audio API calls. +type CallAudio struct { + CallDate jsontypes.Time `json:"callDate"` + AudioName *string `json:"audioName"` + AudioType *string `json:"audioType"` + AudioBlob []byte `json:"audioBlob"` +} + type Call struct { ID uuid.UUID `form:"-"` Audio []byte `form:"audio" filenameField:"AudioName"` diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go new file mode 100644 index 0000000..2552a43 --- /dev/null +++ b/pkg/calls/callstore/store.go @@ -0,0 +1,109 @@ +package callstore + +import ( + "context" + + "dynatron.me/x/stillbox/internal/common" + "dynatron.me/x/stillbox/internal/jsontypes" + + "dynatron.me/x/stillbox/pkg/calls" + "dynatron.me/x/stillbox/pkg/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type Store interface { + // CallAudio returns a CallAudio struct + CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) + + // Calls gets paginated Calls. + Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error) +} + +type store struct { +} + +func New() *store { + return new(store) +} + +type storeCtxKey string + +const StoreCtxKey storeCtxKey = "store" + +func CtxWithStore(ctx context.Context, s Store) context.Context { + return context.WithValue(ctx, StoreCtxKey, s) +} + +func FromCtx(ctx context.Context) Store { + s, ok := ctx.Value(StoreCtxKey).(Store) + if !ok { + return New() + } + + return s +} + +func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) { + db := database.FromCtx(ctx) + + dbCall, err := db.GetCallAudioByID(ctx, id) + if err != nil { + return nil, err + } + + return &calls.CallAudio{ + CallDate: jsontypes.Time(dbCall.CallDate.Time), + AudioName: dbCall.AudioName, + AudioType: dbCall.AudioType, + AudioBlob: dbCall.AudioBlob, + }, nil +} + +type CallsParams struct { + common.Pagination + Direction *common.SortDirection `json:"dir"` + + Start *jsontypes.Time `json:"start"` + End *jsontypes.Time `json:"end"` + TagsAny []string `json:"tagsAny"` + TagsNot []string `json:"tagsNot"` +} + +func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) { + db := database.FromCtx(ctx) + + offset, perPage := p.Pagination.OffsetPerPage(100) + par := database.ListCallsPParams{ + Start: p.Start.PGTypeTSTZ(), + End: p.End.PGTypeTSTZ(), + TagsAny: p.TagsAny, + TagsNot: p.TagsNot, + Offset: offset, + PerPage: perPage, + Direction: p.Direction.DirString(common.DirAsc), + } + + var count int64 + txErr := db.InTx(ctx, func(db database.Store) error { + var err error + count, err = db.ListCallsCount(ctx, database.ListCallsCountParams{ + Start: par.Start, + End: par.End, + TagsAny: par.TagsAny, + TagsNot: par.TagsNot, + }) + if err != nil { + return err + } + + rows, err = db.ListCallsP(ctx, par) + return err + }, pgx.TxOptions{}) + if txErr != nil { + return nil, 0, txErr + } + + return rows, int(count), err +} diff --git a/pkg/database/calls.sql.go b/pkg/database/calls.sql.go index 6531c75..04f2faa 100644 --- a/pkg/database/calls.sql.go +++ b/pkg/database/calls.sql.go @@ -147,6 +147,7 @@ WITH to_sweep AS ( WHERE call_id IN (SELECT id FROM to_sweep) ` +// This is used to sweep calls that are part of an incident prior to pruning a partition. func (q *Queries) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) { result, err := q.db.Exec(ctx, cleanupSweptCalls, rangeStart, rangeEnd) if err != nil { @@ -189,6 +190,125 @@ func (q *Queries) GetDatabaseSize(ctx context.Context) (string, error) { return pg_size_pretty, err } +const listCallsCount = `-- name: ListCallsCount :one +SELECT +COUNT(*) +FROM calls c +JOIN talkgroups tgs ON c.talkgroup = tgs.tgid AND c.system = tgs.system_id +WHERE +CASE WHEN $1::TIMESTAMPTZ IS NOT NULL THEN + c.call_date >= $1 ELSE TRUE END AND +CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN + c.call_date <= $2 ELSE TRUE END AND +CASE WHEN $3::TEXT[] IS NOT NULL THEN + tgs.tags @> ARRAY[$3] ELSE TRUE END AND +CASE WHEN $4::TEXT[] IS NOT NULL THEN + (NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END +` + +type ListCallsCountParams struct { + Start pgtype.Timestamptz `json:"start"` + End pgtype.Timestamptz `json:"end"` + TagsAny []string `json:"tags_any"` + TagsNot []string `json:"tags_not"` +} + +func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) { + row := q.db.QueryRow(ctx, listCallsCount, + arg.Start, + arg.End, + arg.TagsAny, + arg.TagsNot, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listCallsP = `-- name: ListCallsP :many +SELECT +c.id, +c.call_date, +c.duration, +tgs.system_id, +tgs.tgid, +sys.name system_name, +tgs.name tg_name +FROM calls c +JOIN talkgroups tgs ON c.talkgroup = tgs.tgid AND c.system = tgs.system_id +JOIN systems sys ON sys.id = tgs.system_id +WHERE +CASE WHEN $1::TIMESTAMPTZ IS NOT NULL THEN + c.call_date >= $1 ELSE TRUE END AND +CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN + c.call_date <= $2 ELSE TRUE END AND +CASE WHEN $3::TEXT[] IS NOT NULL THEN + tgs.tags @> ARRAY[$3] ELSE TRUE END AND +CASE WHEN $4::TEXT[] IS NOT NULL THEN + (NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END +ORDER BY +CASE WHEN $5::TEXT = 'asc' THEN c.call_date END ASC, +CASE WHEN $5 = 'desc' THEN c.call_date END DESC +OFFSET $6 ROWS +FETCH NEXT $7 ROWS ONLY +` + +type ListCallsPParams struct { + Start pgtype.Timestamptz `json:"start"` + End pgtype.Timestamptz `json:"end"` + TagsAny []string `json:"tags_any"` + TagsNot []string `json:"tags_not"` + Direction string `json:"direction"` + Offset int32 `json:"offset"` + PerPage int32 `json:"per_page"` +} + +type ListCallsPRow struct { + ID uuid.UUID `json:"id"` + CallDate pgtype.Timestamptz `json:"call_date"` + Duration *int32 `json:"duration"` + SystemID int32 `json:"system_id"` + TGID int32 `json:"tgid"` + SystemName string `json:"system_name"` + TGName *string `json:"tg_name"` +} + +func (q *Queries) ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListCallsPRow, error) { + rows, err := q.db.Query(ctx, listCallsP, + arg.Start, + arg.End, + arg.TagsAny, + arg.TagsNot, + arg.Direction, + arg.Offset, + arg.PerPage, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCallsPRow + for rows.Next() { + var i ListCallsPRow + if err := rows.Scan( + &i.ID, + &i.CallDate, + &i.Duration, + &i.SystemID, + &i.TGID, + &i.SystemName, + &i.TGName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setCallTranscript = `-- name: SetCallTranscript :exec UPDATE calls SET transcript = $2 WHERE id = $1 ` diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index aec1750..07111c4 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -2384,6 +2384,122 @@ func (_c *Store_InTx_Call) RunAndReturn(run func(context.Context, func(database. return _c } +// ListCallsCount provides a mock function with given fields: ctx, arg +func (_m *Store) ListCallsCount(ctx context.Context, arg database.ListCallsCountParams) (int64, error) { + ret := _m.Called(ctx, arg) + + if len(ret) == 0 { + panic("no return value specified for ListCallsCount") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, database.ListCallsCountParams) (int64, error)); ok { + return rf(ctx, arg) + } + if rf, ok := ret.Get(0).(func(context.Context, database.ListCallsCountParams) int64); ok { + r0 = rf(ctx, arg) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, database.ListCallsCountParams) error); ok { + r1 = rf(ctx, arg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_ListCallsCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCallsCount' +type Store_ListCallsCount_Call struct { + *mock.Call +} + +// ListCallsCount is a helper method to define mock.On call +// - ctx context.Context +// - arg database.ListCallsCountParams +func (_e *Store_Expecter) ListCallsCount(ctx interface{}, arg interface{}) *Store_ListCallsCount_Call { + return &Store_ListCallsCount_Call{Call: _e.mock.On("ListCallsCount", ctx, arg)} +} + +func (_c *Store_ListCallsCount_Call) Run(run func(ctx context.Context, arg database.ListCallsCountParams)) *Store_ListCallsCount_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(database.ListCallsCountParams)) + }) + return _c +} + +func (_c *Store_ListCallsCount_Call) Return(_a0 int64, _a1 error) *Store_ListCallsCount_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_ListCallsCount_Call) RunAndReturn(run func(context.Context, database.ListCallsCountParams) (int64, error)) *Store_ListCallsCount_Call { + _c.Call.Return(run) + return _c +} + +// ListCallsP provides a mock function with given fields: ctx, arg +func (_m *Store) ListCallsP(ctx context.Context, arg database.ListCallsPParams) ([]database.ListCallsPRow, error) { + ret := _m.Called(ctx, arg) + + if len(ret) == 0 { + panic("no return value specified for ListCallsP") + } + + var r0 []database.ListCallsPRow + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, database.ListCallsPParams) ([]database.ListCallsPRow, error)); ok { + return rf(ctx, arg) + } + if rf, ok := ret.Get(0).(func(context.Context, database.ListCallsPParams) []database.ListCallsPRow); ok { + r0 = rf(ctx, arg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.ListCallsPRow) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, database.ListCallsPParams) error); ok { + r1 = rf(ctx, arg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_ListCallsP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCallsP' +type Store_ListCallsP_Call struct { + *mock.Call +} + +// ListCallsP is a helper method to define mock.On call +// - ctx context.Context +// - arg database.ListCallsPParams +func (_e *Store_Expecter) ListCallsP(ctx interface{}, arg interface{}) *Store_ListCallsP_Call { + return &Store_ListCallsP_Call{Call: _e.mock.On("ListCallsP", ctx, arg)} +} + +func (_c *Store_ListCallsP_Call) Run(run func(ctx context.Context, arg database.ListCallsPParams)) *Store_ListCallsP_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(database.ListCallsPParams)) + }) + return _c +} + +func (_c *Store_ListCallsP_Call) Return(_a0 []database.ListCallsPRow, _a1 error) *Store_ListCallsP_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_ListCallsP_Call) RunAndReturn(run func(context.Context, database.ListCallsPParams) ([]database.ListCallsPRow, error)) *Store_ListCallsP_Call { + _c.Call.Return(run) + return _c +} + // RestoreTalkgroupVersion provides a mock function with given fields: ctx, versionIds func (_m *Store) RestoreTalkgroupVersion(ctx context.Context, versionIds int) (database.Talkgroup, error) { ret := _m.Called(ctx, versionIds) diff --git a/pkg/database/partman/partman.go b/pkg/database/partman/partman.go index ba63ea8..db9f3ca 100644 --- a/pkg/database/partman/partman.go +++ b/pkg/database/partman/partman.go @@ -257,6 +257,7 @@ func (pm *partman) prunePartition(ctx context.Context, tx database.Store, p Part end := pgtype.Timestamptz{Time: e, Valid: true} fullPartName := pm.fullTableName(p.PartitionName()) + // sweep calls that are referenced by an incident into swept_calls swept, err := tx.SweepCalls(ctx, start, end) if err != nil { return err diff --git a/pkg/database/querier.go b/pkg/database/querier.go index 522d62b..3606fb1 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -15,6 +15,7 @@ type Querier interface { AddAlert(ctx context.Context, arg AddAlertParams) error AddCall(ctx context.Context, arg AddCallParams) error AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) + // This is used to sweep calls that are part of an incident prior to pruning a partition. CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error) CreateSystem(ctx context.Context, iD int, name string) error @@ -45,6 +46,8 @@ type Querier interface { GetUserByUID(ctx context.Context, id int) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error) GetUsers(ctx context.Context) ([]User, error) + ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) + ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListCallsPRow, error) RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error) SetAppPrefs(ctx context.Context, appName string, prefs []byte, uid int) error SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index c003174..e28d492 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -8,6 +8,8 @@ import ( "path/filepath" "dynatron.me/x/stillbox/internal/common" + "dynatron.me/x/stillbox/internal/forms" + "dynatron.me/x/stillbox/pkg/calls/callstore" "dynatron.me/x/stillbox/pkg/database" "github.com/go-chi/chi/v5" @@ -28,13 +30,15 @@ type callsAPI struct { func (ca *callsAPI) Subrouter() http.Handler { r := chi.NewMux() - r.Get(`/{call:[a-f0-9-]+}`, ca.get) - r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.get) + r.Get(`/{call:[a-f0-9-]+}`, ca.getAudio) + r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudio) + + r.Post(`/list`, ca.listCalls) return r } -func (ca *callsAPI) get(w http.ResponseWriter, r *http.Request) { +func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { p := struct { CallID *uuid.UUID `param:"call"` Download *string `param:"download"` @@ -52,9 +56,9 @@ func (ca *callsAPI) get(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - db := database.FromCtx(ctx) + calls := callstore.FromCtx(ctx) - call, err := db.GetCallAudioByID(ctx, *p.CallID) + call, err := calls.CallAudio(ctx, *p.CallID) if err != nil { wErr(w, r, autoError(err)) return @@ -77,7 +81,7 @@ func (ca *callsAPI) get(w http.ResponseWriter, r *http.Request) { } if call.AudioName == nil { - call.AudioName = common.PtrTo(call.CallDate.Time.Format(fileNameDateFmt)) + call.AudioName = common.PtrTo(call.CallDate.Time().Format(fileNameDateFmt)) } disposition := "inline" @@ -91,3 +95,31 @@ func (ca *callsAPI) get(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(call.AudioBlob) } + +func (ca *callsAPI) listCalls(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + cSt := callstore.FromCtx(ctx) + + var par callstore.CallsParams + err := forms.Unmarshal(r, &par, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + calls, count, err := cSt.Calls(ctx, par) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + res := struct { + Calls []database.ListCallsPRow `json:"calls"` + Count int `json:"count"` + }{ + Calls: calls, + Count: count, + } + + respond(w, r, res) +} diff --git a/pkg/talkgroups/tgstore/store.go b/pkg/talkgroups/tgstore/store.go index 4bb75ac..6a2a47f 100644 --- a/pkg/talkgroups/tgstore/store.go +++ b/pkg/talkgroups/tgstore/store.go @@ -106,7 +106,7 @@ func WithPagination(p *Pagination, defPerPage int, totalDest *int) Option { func (p *Pagination) SortDir() (string, error) { order := TGOrderTGID - dir := TGDirAsc + dir := common.DirAsc if p != nil { if p.OrderBy != nil { @@ -136,7 +136,6 @@ func WithFilter(f *string) Option { } type TGOrder string -type TGDirection string const ( TGOrderID TGOrder = "id" @@ -144,25 +143,8 @@ const ( TGOrderGroup TGOrder = "group" TGOrderName TGOrder = "name" TGOrderAlpha TGOrder = "alpha" - - TGDirAsc TGDirection = "asc" - TGDirDesc TGDirection = "desc" ) -func (t *TGDirection) IsValid() bool { - if t == nil { - return true - } - - switch *t { - case TGDirAsc, TGDirDesc: - return true - } - - return false - -} - func (t *TGOrder) IsValid() bool { if t == nil { return true @@ -179,8 +161,8 @@ func (t *TGOrder) IsValid() bool { type Pagination struct { common.Pagination - OrderBy *TGOrder `json:"orderBy"` - Direction *TGDirection `json:"dir"` + OrderBy *TGOrder `json:"orderBy"` + Direction *common.SortDirection `json:"dir"` } type storeCtxKey string diff --git a/sql/postgres/migrations/001_initial.up.sql b/sql/postgres/migrations/001_initial.up.sql index 0b58df5..c766ae0 100644 --- a/sql/postgres/migrations/001_initial.up.sql +++ b/sql/postgres/migrations/001_initial.up.sql @@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS talkgroups( weight REAL NOT NULL DEFAULT 1.0, learned BOOLEAN NOT NULL DEFAULT FALSE, ignored BOOLEAN NOT NULL DEFAULT FALSE, - UNIQUE (system_id, tgid) + UNIQUE (system_id, tgid, learned) ); CREATE INDEX talkgroups_system_tgid_idx ON talkgroups (system_id, tgid); diff --git a/sql/postgres/queries/calls.sql b/sql/postgres/queries/calls.sql index a6f8283..c2b01c5 100644 --- a/sql/postgres/queries/calls.sql +++ b/sql/postgres/queries/calls.sql @@ -70,6 +70,7 @@ WITH to_sweep AS ( ) INSERT INTO swept_calls SELECT * FROM to_sweep; -- name: CleanupSweptCalls :execrows +-- This is used to sweep calls that are part of an incident prior to pruning a partition. WITH to_sweep AS ( SELECT id FROM calls JOIN incidents_calls ic ON ic.call_id = calls.id @@ -79,3 +80,47 @@ WITH to_sweep AS ( swept_call_id = call_id, calls_tbl_id = NULL WHERE call_id IN (SELECT id FROM to_sweep); + +-- name: ListCallsP :many +SELECT +c.id, +c.call_date, +c.duration, +tgs.system_id, +tgs.tgid, +sys.name system_name, +tgs.name tg_name +FROM calls c +JOIN talkgroups tgs ON c.talkgroup = tgs.tgid AND c.system = tgs.system_id +JOIN systems sys ON sys.id = tgs.system_id +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 AND +CASE WHEN sqlc.narg('tags_any')::TEXT[] IS NOT NULL THEN + tgs.tags @> ARRAY[@tags_any] ELSE TRUE END AND +CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN + (NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END +ORDER BY +CASE WHEN @direction::TEXT = 'asc' THEN c.call_date END ASC, +CASE WHEN @direction = 'desc' THEN c.call_date END DESC +OFFSET sqlc.arg('offset') ROWS +FETCH NEXT sqlc.arg('per_page') ROWS ONLY +; + +-- name: ListCallsCount :one +SELECT +COUNT(*) +FROM calls c +JOIN talkgroups tgs ON c.talkgroup = tgs.tgid AND c.system = tgs.system_id +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 AND +CASE WHEN sqlc.narg('tags_any')::TEXT[] IS NOT NULL THEN + tgs.tags @> ARRAY[@tags_any] ELSE TRUE END AND +CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN + (NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END +;