diff --git a/README.md b/README.md index 54a3c19..2b5c5ca 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,77 @@ # stillbox -A Golang scanner call server. +*"When they say stillbox, you know it's real."* -**NOTE** +A Golang scanner call server and Angular frontend. Basically a rewrite of [rdio-scanner](https://github.com/chuot/rdio-scanner) +with a cleaner *backend* (not frontend, so far; I am not a frontend guy and it shows) and a more opinionated featureset. + +Primary differences: + + - [x] Backend written as if Go is actually a typed language + + I never would have started this project if existing projects were this way, as modifying them would have been far easier. + + - [x] No directory watch source, for now + + This is not a feature I need personally, but would be simple to implement as another [source](pkg/sources/). + + - [x] Only supports Postgres DB right now. May add SQLite someday. + + Most all database access is abstracted using a repository architecture. The calls table is also partitioned, with configurable intervals and retention. + + - [x] Filter calls by duration in search + + This feature was a major impetus for even starting the project. + + - [x] Both REST and WebSocket APIs + + REST is used for the admin interface, and WebSockets/protobuf are used for listener apps, including *Calls* (both Flutter and terminal versions). + + - [x] Uses protobuf instead of JSON for the WebSocket API (no slices of integers for call audio!) + + Another thing that originally spawned the project. + + - [x] Talkgroup activity alerting + + We use [go-trending](https://github.com/codesuki/go-trending) to implement this. + + - [x] "Native" flutter client (Calls) for Android/iOS/macOS/Linux/Windows (in progress) + + This client, as of this writing, works for listening linearly only. More functionality to come. Currently available [here](https://github.com/amigan/calls). + + - [x] RadioReference talkgroup import, SDRtrunk talkgroup playlist export + + Just copy and paste from RadioReference. You can also have your talkgroup labels in SDRtrunk! + + - [x] Incidents functionality (group past calls together and retain them) + + Keep track of interesting past calls and link them to your friends. Calls linked to an incident are even swept away before retention pruning is done! + + - [x] No premiumization nags or advertising of any kind + + Another thing that drove me to start this project. It's either open source, or it isn't, folks. + + - [x] 3-clause BSD license + + But that doesn't mean I don't want your improvements so that I may incorporate them! + +## Note If this message is still here, the database schema *initial migration* and protobuf definitions are **still subject to change**. Once `stillbox` is actually usable (but not necessarily feature-complete), I will remove this note, and start using DB migrations and protobuf best practices (i.e. not changing field numbers). + +## License and Copyright + +© 2024, Daniel Ponte + +Licensed under the 3-clause BSD license. See LICENSE for details. + +## Credits + +Thanks to, among others: +* rdio-scanner for the original inspiration +* [go-trending](https://github.com/codesuki/go-trending) and [go-time-series](https://github.com/codesuki/go-time-series) +* [isoweek](https://github.com/snabb/isoweek) +* [minimp3](https://github.com/tosone/minimp3) diff --git a/client/stillbox/src/app/calls/calls.component.ts b/client/stillbox/src/app/calls/calls.component.ts index f8f18c8..030f217 100644 --- a/client/stillbox/src/app/calls/calls.component.ts +++ b/client/stillbox/src/app/calls/calls.component.ts @@ -26,11 +26,9 @@ import { ReactiveFormsModule, } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; -import { MatTimepickerModule } from '@angular/material/timepicker'; -import { MatDatepickerModule } from '@angular/material/datepicker'; import { debounceTime } from 'rxjs/operators'; import { ToolbarContextService } from '../navigation/toolbar-context.service'; -import { MatSelect, MatSelectModule } from '@angular/material/select'; +import { MatSelectModule } from '@angular/material/select'; @Pipe({ name: 'grabDate', @@ -55,7 +53,7 @@ export class TimePipe implements PipeTransform { return timestamp.toLocaleTimeString(navigator.language, { hour: '2-digit', minute: '2-digit', - hourCycle: 'h24', + hourCycle: 'h23', }); } } @@ -137,8 +135,6 @@ const reqPageSize = 200; ReactiveFormsModule, FormsModule, MatInputModule, - MatDatepickerModule, - MatTimepickerModule, MatCheckboxModule, CommonModule, MatProgressSpinnerModule, @@ -166,6 +162,7 @@ export class CallsComponent { 'duration', ]; curPage = { pageIndex: 0, pageSize: 0 }; + curLen = 0; currentSet!: CallRecord[]; currentServerPage = 0; // page is never 0, forces load isLoading = true; @@ -198,7 +195,7 @@ export class CallsComponent { isAllSelected() { const numSelected = this.selection.selected.length; - const numRows = this.curPage.pageSize; + const numRows = this.curLen; return numSelected === numRows; } @@ -274,7 +271,9 @@ export class CallsComponent { this.pageWindow = pageStart % reqPageSize; if (serverPage == this.currentServerPage && !force && this.currentSet) { this.callsResult.next( - this.callsResult ? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize) : [], + this.callsResult + ? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize) + : [], ); } else { this.currentServerPage = serverPage; @@ -332,6 +331,11 @@ export class CallsComponent { ); }), ); + this.subscriptions.add( + this.callsResult.subscribe((cr) => { + this.curLen = cr.length; + }), + ); } resetFilter() { diff --git a/client/stillbox/src/app/incidents.ts b/client/stillbox/src/app/incidents.ts new file mode 100644 index 0000000..c817fea --- /dev/null +++ b/client/stillbox/src/app/incidents.ts @@ -0,0 +1,17 @@ +import { CallRecord } from './calls'; + +export interface IncidentCall extends CallRecord { + notes: Object | null; +} + +export interface IncidentRecord { + id: string; + name: string | null; + description: string | null; + startTime: Date | null; + endTime: Date | null; + location: Object | null; + metadata: Object | null; + calls: IncidentCall[] | null; + callCount: number | null; +} diff --git a/client/stillbox/src/app/incidents/incident/incident.component.html b/client/stillbox/src/app/incidents/incident/incident.component.html new file mode 100644 index 0000000..5afe3dc --- /dev/null +++ b/client/stillbox/src/app/incidents/incident/incident.component.html @@ -0,0 +1 @@ +

incident works!

diff --git a/client/stillbox/src/app/incidents/incident/incident.component.scss b/client/stillbox/src/app/incidents/incident/incident.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/stillbox/src/app/incidents/incident/incident.component.spec.ts b/client/stillbox/src/app/incidents/incident/incident.component.spec.ts new file mode 100644 index 0000000..320229b --- /dev/null +++ b/client/stillbox/src/app/incidents/incident/incident.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IncidentComponent } from './incident.component'; + +describe('IncidentComponent', () => { + let component: IncidentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IncidentComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(IncidentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/incidents/incident/incident.component.ts b/client/stillbox/src/app/incidents/incident/incident.component.ts new file mode 100644 index 0000000..27d0508 --- /dev/null +++ b/client/stillbox/src/app/incidents/incident/incident.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-incident', + imports: [], + templateUrl: './incident.component.html', + styleUrl: './incident.component.scss', +}) +export class IncidentComponent {} diff --git a/client/stillbox/src/app/incidents/incidents.component.html b/client/stillbox/src/app/incidents/incidents.component.html index 2fbdfba..a222d15 100644 --- a/client/stillbox/src/app/incidents/incidents.component.html +++ b/client/stillbox/src/app/incidents/incidents.component.html @@ -1 +1,126 @@ -

incidents works!

+
+
+ + Start + + + + End + + + + Filter + + + +
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Start + {{ incident.startTime | fmtDate }} + End + {{ incident.endTime | fmtDate }} + Name + {{ incident.name }} + Calls + {{ incident.callCount }} + Edit + edit + +
+
+
+ + +
+ +
+ +
+
diff --git a/client/stillbox/src/app/incidents/incidents.component.scss b/client/stillbox/src/app/incidents/incidents.component.scss index e69de29..9c1fc7c 100644 --- a/client/stillbox/src/app/incidents/incidents.component.scss +++ b/client/stillbox/src/app/incidents/incidents.component.scss @@ -0,0 +1,63 @@ +.timeFilterBox { + flex: 0 0 240px; +} + +table, +.incsTable { + width: 100%; +} + +.callCount { + text-align: right; +} + +.mat-column-numCalls { + width: 50px; +} + +.tabContainer { + max-height: calc( + ( + 100vh - var(--mat-mat-maginator-container-size, 56px) - 2 * + (var(--mat-toolbar-standard-height, 64px)) + ) + 7px + ); + overflow: auto; +} + +.toolbarButtons button, +.toolbarButtons form, +.toolbarButtons form button { + justify-content: flex-end; + align-content: center; +} + +tr.mat-mdc-row { + height: 2.3rem !important; + font-size: 12pt; +} + +.mdc-text-field__input::-webkit-calendar-picker-indicator { + display: block !important; +} + +@media screen and (max-width: 768px) { + .tabFootContainer { + padding: 0; + } +} + +.toolbar, +.toolbar form { + display: flex; + flex-flow: row wrap; +} + +form { + flex: 1 0; + display: flex; +} + +.filterBox { + flex: 1 1 300px; +} diff --git a/client/stillbox/src/app/incidents/incidents.component.ts b/client/stillbox/src/app/incidents/incidents.component.ts index b2bd671..06624aa 100644 --- a/client/stillbox/src/app/incidents/incidents.component.ts +++ b/client/stillbox/src/app/incidents/incidents.component.ts @@ -1,9 +1,248 @@ -import { Component } from '@angular/core'; +import { Component, Pipe, PipeTransform, ViewChild } from '@angular/core'; +import { CommonModule, AsyncPipe } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { + MatPaginator, + MatPaginatorModule, + PageEvent, +} from '@angular/material/paginator'; +import { PrefsService } from '../prefs/prefs.service'; +import { MatIconModule } from '@angular/material/icon'; +import { SelectionModel } from '@angular/cdk/collections'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { IncidentsListParams, IncidentsService } from './incidents.service'; +import { IncidentRecord } from '../incidents'; + +import { TalkgroupService } from '../talkgroups/talkgroups.service'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { MatInputModule } from '@angular/material/input'; +import { debounceTime } from 'rxjs/operators'; +import { ToolbarContextService } from '../navigation/toolbar-context.service'; + +@Pipe({ + name: 'fmtDate', + standalone: true, + pure: true, +}) +export class DatePipe implements PipeTransform { + transform(ts: string, args?: any): string { + if (!ts) { + return '\u2014'; + } + const timestamp = new Date(ts); + return ( + timestamp.getMonth() + + 1 + + '/' + + timestamp.getDate() + + ' ' + + timestamp.toLocaleTimeString(navigator.language, { + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }) + ); + } +} + +const reqPageSize = 200; @Component({ selector: 'app-incidents', - imports: [], + imports: [ + MatIconModule, + DatePipe, + MatPaginatorModule, + MatTableModule, + AsyncPipe, + MatFormFieldModule, + ReactiveFormsModule, + FormsModule, + MatInputModule, + MatCheckboxModule, + RouterLink, + CommonModule, + MatProgressSpinnerModule, + ], templateUrl: './incidents.component.html', styleUrl: './incidents.component.scss', }) -export class IncidentsComponent {} +export class IncidentsComponent { + incsResult = new BehaviorSubject(new Array(0)); + @ViewChild('paginator') paginator!: MatPaginator; + count = 0; + curLen = 0; + page = 0; + perPage = 25; + pageSizeOptions = [25, 50, 75, 100, 200]; + columns = ['select', 'startTime', 'endTime', 'name', 'numCalls', 'edit']; + curPage = { pageIndex: 0, pageSize: 0 }; + currentSet!: IncidentRecord[]; + currentServerPage = 0; // page is never 0, forces load + isLoading = true; + + selection = new SelectionModel(true, []); + + form = new FormGroup({ + start: new FormControl(null), + end: new FormControl(null), + filter: new FormControl(''), + }); + + subscriptions = new Subscription(); + pageWindow = 0; + fetchIncidents = new BehaviorSubject( + this.buildParams(this.curPage, this.curPage.pageIndex), + ); + + constructor( + private incidentsSvc: IncidentsService, + private prefsSvc: PrefsService, + public tcSvc: ToolbarContextService, + public tgSvc: TalkgroupService, + ) { + this.tcSvc.showFilterButton(); + } + + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.curLen; + return numSelected === numRows; + } + + buildParams(p: PageEvent, serverPage: number): IncidentsListParams { + const par: IncidentsListParams = { + start: + this.form.controls['start'].value != null + ? new Date(this.form.controls['start'].value!) + : null, + page: serverPage, + perPage: reqPageSize, + end: + this.form.controls['end'].value != null + ? new Date(this.form.controls['end'].value!) + : null, + dir: 'desc', + filter: + this.form.controls['filter'].value != '' + ? this.form.controls['filter'].value + : null, + }; + + return par; + } + + masterToggle() { + this.isAllSelected() + ? this.selection.clear() + : this.incsResult.value.forEach((row) => this.selection.select(row)); + } + + lTime(now: Date): string { + now.setDate(new Date().getDate() - 7); + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + return now.toISOString().slice(0, 16); + } + + setPage(p: PageEvent, force?: boolean) { + this.selection.clear(); + this.curPage = p; + if (p && p!.pageSize != this.perPage) { + this.perPage = p!.pageSize; + this.prefsSvc.set('incidentsPerPage', p!.pageSize); + } + this.getIncidents(p, force); + } + + refresh() { + this.selection.clear(); + this.getIncidents(this.curPage, true); + } + + getIncidents(p: PageEvent, force?: boolean) { + const pageStart = p.pageIndex * p.pageSize; + const serverPage = Math.floor(pageStart / reqPageSize) + 1; + this.pageWindow = pageStart % reqPageSize; + if (serverPage == this.currentServerPage && !force && this.currentSet) { + this.incsResult.next( + this.incsResult + ? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize) + : [], + ); + } else { + this.currentServerPage = serverPage; + this.fetchIncidents.next(this.buildParams(p, serverPage)); + } + } + + zeroPage(): PageEvent { + return { + pageIndex: 0, + pageSize: this.curPage.pageSize, + }; + } + + ngOnDestroy() { + this.tcSvc.hideFilterButton(); + this.subscriptions.unsubscribe(); + } + + ngOnInit() { + this.form.valueChanges.pipe(debounceTime(300)).subscribe(() => { + this.currentServerPage = 0; + this.setPage(this.zeroPage(), true); + }); + this.subscriptions.add( + this.prefsSvc.get('incidentsPerPage').subscribe((cpp) => { + if (cpp && cpp != this.perPage) { + this.perPage = cpp; + + this.setPage({ + pageIndex: 0, + pageSize: cpp, + }); + } + }), + ); + this.subscriptions.add( + this.fetchIncidents + .pipe( + switchMap((params) => { + return this.incidentsSvc.getIncidents(params); + }), + ) + .subscribe((incidents) => { + this.isLoading = false; + this.count = incidents.count; + this.currentSet = incidents.incidents; + this.incsResult.next( + this.currentSet + ? this.currentSet.slice( + this.pageWindow, + this.pageWindow + this.perPage, + ) + : [], + ); + }), + ); + this.subscriptions.add( + this.incsResult.subscribe((cr) => { + this.curLen = cr.length; + }), + ); + } + + resetFilter() { + this.form.reset(); + } +} diff --git a/client/stillbox/src/app/incidents/incidents.service.spec.ts b/client/stillbox/src/app/incidents/incidents.service.spec.ts new file mode 100644 index 0000000..8c621fb --- /dev/null +++ b/client/stillbox/src/app/incidents/incidents.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { IncidentsService } from './incidents.service'; + +describe('IncidentsService', () => { + let service: IncidentsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(IncidentsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/incidents/incidents.service.ts b/client/stillbox/src/app/incidents/incidents.service.ts new file mode 100644 index 0000000..e432914 --- /dev/null +++ b/client/stillbox/src/app/incidents/incidents.service.ts @@ -0,0 +1,50 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { IncidentRecord } from '../incidents'; +import { Observable } from 'rxjs'; + +export interface IncidentsListParams { + start: Date | null; + end: Date | null; + filter: string | null; + dir: string; + page: number; + perPage: number; +} + +export interface CallIncidentParams { + add: string[] | null; // IDs + notes: Object | null; + remove: string[] | null; // IDs +} + +export interface IncidentsPaginated { + incidents: IncidentRecord[]; + count: number; +} +@Injectable({ + providedIn: 'root', +}) +export class IncidentsService { + constructor(private http: HttpClient) {} + + getIncidents(p: IncidentsListParams): Observable { + return this.http.post('/api/incident/', p); + } + + createIncident(inp: IncidentRecord): Observable { + return this.http.post('/api/incident/new', inp); + } + + addRemoveCalls(id: string, inp: CallIncidentParams): Observable { + return this.http.post('/api/incident/' + id + '/calls', inp); + } + + deleteIncident(id: string): Observable { + return this.http.delete('/api/incident/' + id); + } + + updateIncident(id: string, inp: IncidentRecord): Observable { + return this.http.patch('/api/incident/' + id, inp); + } +} diff --git a/client/stillbox/src/styles.scss b/client/stillbox/src/styles.scss index d0d02e6..bb21ba1 100644 --- a/client/stillbox/src/styles.scss +++ b/client/stillbox/src/styles.scss @@ -233,5 +233,6 @@ input { .spinner { display: flex; margin-top: 40px; + margin-bottom: 40px; justify-content: center; } diff --git a/config.sample.yaml b/config.sample.yaml index 500cb1c..11e365c 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -1,3 +1,6 @@ +# this is used to compose URLs, for example in M3U responses. +# set it to what users access the instance as. +baseURL: "https://stillbox.example.com/" db: connect: 'postgres://postgres:password@localhost:5432/example' partition: diff --git a/internal/common/pagination.go b/internal/common/pagination.go index aacdf7d..09eb7f9 100644 --- a/internal/common/pagination.go +++ b/internal/common/pagination.go @@ -1,5 +1,11 @@ package common +import "errors" + +var ( + ErrPageOutOfRange = errors.New("requested page out of range") +) + type Pagination struct { Page *int `json:"page"` PerPage *int `json:"perPage"` diff --git a/internal/forms/marshal_test.go b/internal/forms/marshal_test.go index 6004218..b4aa217 100644 --- a/internal/forms/marshal_test.go +++ b/internal/forms/marshal_test.go @@ -32,7 +32,7 @@ func call(url string, call *calls.Call) error { var buf bytes.Buffer body := multipart.NewWriter(&buf) - err := forms.Marshal(call, body) + err := forms.Marshal(call, body, forms.WithTag("json")) if err != nil { return fmt.Errorf("relay form parse: %w", err) } @@ -88,6 +88,8 @@ func TestMarshal(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var serr error var called bool + + // setup request handler h := hand(func(w http.ResponseWriter, r *http.Request) { called = true serr = r.ParseMultipartForm(1024 * 1024 * 2) @@ -112,6 +114,7 @@ func TestMarshal(t *testing.T) { }) svr := httptest.NewServer(h) + // perform the request err := call(svr.URL, &tc.call) assert.True(t, called) assert.NoError(t, err) diff --git a/internal/forms/testdata/uuid1.http b/internal/forms/testdata/uuid1.http new file mode 100644 index 0000000..90a43a9 --- /dev/null +++ b/internal/forms/testdata/uuid1.http @@ -0,0 +1,22 @@ +POST /api/incident/8ff93b85-c604-11ef-a555-00e04c0122ba/calls HTTP/1.1 +Connection: Upgrade, HTTP2-Settings +Content-Length: 403 +Host: xenon:3050 +HTTP2-Settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA +Upgrade: h2c +Content-Type: multipart/form-data; boundary=--sdrtrunk-sdrtrunk-sdrtrunk +User-Agent: sdrtrunk + +----sdrtrunk-sdrtrunk-sdrtrunk +Content-Disposition: form-data; name="add" + +[f25ef14b-c5f6-11ef-a555-00e04c0122ba,f25ef14b-c5f6-11ef-a555-06e04c0122ba] +----sdrtrunk-sdrtrunk-sdrtrunk +Content-Disposition: form-data; name="notes" + +{"this":"note"} +----sdrtrunk-sdrtrunk-sdrtrunk +Content-Disposition: form-data; name="single" + +17cedf8e-c60b-11ef-a555-00e04c0122ba +----sdrtrunk-sdrtrunk-sdrtrunk diff --git a/internal/forms/unmarshal.go b/internal/forms/unmarshal.go index 5e2dd11..39730e5 100644 --- a/internal/forms/unmarshal.go +++ b/internal/forms/unmarshal.go @@ -251,7 +251,17 @@ func (o *options) unmIterFields(r *http.Request, destStruct reflect.Value) error continue } - if destFieldType.Kind() == reflect.Ptr { + + if reflect.PointerTo(destFieldType).Implements(textUnmarshaler) { + tum := destFieldVal.Addr().Interface().(encoding.TextUnmarshaler) + err := tum.UnmarshalText([]byte(ff)) + if err != nil { + return err + } + + continue + } + for destFieldType.Kind() == reflect.Ptr { destFieldType = destFieldType.Elem() } if reflect.ValueOf(ff).CanConvert(destFieldType) { diff --git a/internal/forms/unmarshal_test.go b/internal/forms/unmarshal_test.go index b664cb8..da2a988 100644 --- a/internal/forms/unmarshal_test.go +++ b/internal/forms/unmarshal_test.go @@ -2,6 +2,7 @@ package forms_test import ( "bufio" + "encoding/json" "errors" "net/http" "os" @@ -19,6 +20,7 @@ import ( "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/xport" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -33,7 +35,6 @@ type callUploadRequest struct { Key string `form:"key"` Patches []int `form:"patches"` Source int `form:"source"` - Sources []int `form:"sources"` System int `form:"system"` SystemLabel string `form:"systemLabel"` Talkgroup int `form:"talkgroup"` @@ -67,6 +68,14 @@ type ptrTestJT struct { ScoreEnd jsontypes.Time `form:"scoreEnd"` } +type CallIncidentParams struct { + Add jsontypes.UUIDs `json:"add"` + Notes json.RawMessage `json:"notes"` + + Remove jsontypes.UUIDs `json:"remove"` + Single jsontypes.UUID `json:"single"` +} + var ( UrlEncTest = urlEncTest{ LookbackDays: 7, @@ -141,6 +150,15 @@ var ( }, }, } + + Cap1 = CallIncidentParams{ + Add: jsontypes.UUIDs{ + jsontypes.UUID(uuid.MustParse("f25ef14b-c5f6-11ef-a555-00e04c0122ba")), + jsontypes.UUID(uuid.MustParse("f25ef14b-c5f6-11ef-a555-06e04c0122ba")), + }, + Single: jsontypes.UUID(uuid.MustParse("17cedf8e-c60b-11ef-a555-00e04c0122ba")), + Notes: []byte(`{"this":"note"}`), + } ) func makeRequest(fixture string) *http.Request { @@ -269,6 +287,13 @@ func TestUnmarshal(t *testing.T) { expect: &ExpJob1, opts: []forms.Option{forms.WithAcceptBlank(), forms.WithOmitEmpty()}, }, + { + name: "uuid and json raw message", + r: makeRequest("uuid1.http"), + dest: &CallIncidentParams{}, + expect: &Cap1, + opts: []forms.Option{forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()}, + }, } for _, tc := range tests { diff --git a/internal/jsontypes/jsontime.go b/internal/jsontypes/jsontime.go index 6658c40..b54dacb 100644 --- a/internal/jsontypes/jsontime.go +++ b/internal/jsontypes/jsontime.go @@ -28,6 +28,14 @@ func (t *Time) UnmarshalYAML(n *yaml.Node) error { return nil } +func TimePtrFromTSTZ(t pgtype.Timestamptz) *Time { + if t.Valid { + return (*Time)(&t.Time) + } + + return nil +} + func (t *Time) PGTypeTSTZ() pgtype.Timestamptz { if t == nil { return pgtype.Timestamptz{Valid: false} diff --git a/internal/jsontypes/location.go b/internal/jsontypes/location.go new file mode 100644 index 0000000..cbd506c --- /dev/null +++ b/internal/jsontypes/location.go @@ -0,0 +1,9 @@ +package jsontypes + +import ( + "encoding/json" +) + +type Location struct { + json.RawMessage +} diff --git a/internal/jsontypes/url.go b/internal/jsontypes/url.go new file mode 100644 index 0000000..1304bb7 --- /dev/null +++ b/internal/jsontypes/url.go @@ -0,0 +1,57 @@ +package jsontypes + +import ( + "encoding/json" + "net/url" + + "gopkg.in/yaml.v3" +) + +type URL url.URL + +func (u *URL) URL() url.URL { + return url.URL(*u) +} + +func (u *URL) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + + ur, err := url.Parse(s) + if err != nil { + return err + } + + *u = URL(*ur) + return nil +} + +func (u *URL) UnmarshalYAML(n *yaml.Node) error { + var s string + + err := n.Decode(&s) + if err != nil { + return err + } + + ur, err := url.Parse(s) + if err != nil { + return err + } + + *u = URL(*ur) + return nil +} + +func (u *URL) UnmarshalText(t []byte) error { + ur, err := url.Parse(string(t)) + if err != nil { + return err + } + + *u = URL(*ur) + return nil +} diff --git a/internal/jsontypes/uuid.go b/internal/jsontypes/uuid.go new file mode 100644 index 0000000..2a9dcf5 --- /dev/null +++ b/internal/jsontypes/uuid.go @@ -0,0 +1,70 @@ +package jsontypes + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type UUID uuid.UUID +type UUIDs []UUID + +func (u *UUIDs) UUIDs() []uuid.UUID { + r := make([]uuid.UUID, 0, len(*u)) + + for _, v := range *u { + r = append(r, v.UUID()) + } + + return r +} + +func (u *UUIDs) UnmarshalJSON(b []byte) error { + var ss []string + err := json.Unmarshal(b, &ss) + if err != nil { + return err + } + + usl := make([]UUID, 0, len(ss)) + for _, s := range ss { + uu, err := uuid.Parse(s) + if err != nil { + return err + } + + usl = append(usl, UUID(uu)) + } + + *u = usl + + return nil +} + +func (u UUID) UUID() uuid.UUID { + return uuid.UUID(u) +} + +func (u *UUID) MarshalJSON() ([]byte, error) { + return []byte(`"` + u.UUID().String() + `"`), nil +} + +func (u *UUID) UnmarshalJSON(b []byte) error { + id, err := uuid.Parse(string(b[:])) + if err != nil { + return err + } + *u = UUID(id) + return nil +} + +func (u *UUID) UnmarshalText(t []byte) error { + var gu uuid.UUID + err := gu.UnmarshalText(t) + if err != nil { + return err + } + *u = UUID(gu) + + return nil +} diff --git a/pkg/calls/call.go b/pkg/calls/call.go index dcb77a3..5c784cb 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -42,26 +42,25 @@ type CallAudio struct { } type Call struct { - ID uuid.UUID `form:"-"` - Audio []byte `form:"audio" filenameField:"AudioName"` - AudioName string `form:"audioName"` - AudioType string `form:"audioType"` - Duration CallDuration `form:"-"` - DateTime time.Time `form:"dateTime"` - Frequencies []int `form:"frequencies"` - Frequency int `form:"frequency"` - Patches []int `form:"patches"` - Source int `form:"source"` - Sources []int `form:"sources"` - System int `form:"system"` - Submitter *auth.UserID `form:"-"` - SystemLabel string `form:"systemLabel"` - Talkgroup int `form:"talkgroup"` - TalkgroupGroup *string `form:"talkgroupGroup"` - TalkgroupLabel *string `form:"talkgroupLabel"` - TGAlphaTag *string `form:"talkgroupTag"` // not 1:1 + ID uuid.UUID `json:"-"` + Audio []byte `json:"audio,omitempty" filenameField:"AudioName"` + AudioName string `json:"audioName,omitempty"` + AudioType string `json:"audioType,omitempty"` + Duration CallDuration `json:"-"` + DateTime time.Time `json:"dateTime,omitempty"` + Frequencies []int `json:"frequencies,omitempty"` + Frequency int `json:"frequency,omitempty"` + Patches []int `json:"patches,omitempty"` + Source int `json:"source,omitempty"` + System int `json:"system,omitempty"` + Submitter *auth.UserID `json:"-,omitempty"` + SystemLabel string `json:"systemLabel,omitempty"` + Talkgroup int `json:"talkgroup,omitempty"` + TalkgroupGroup *string `json:"talkgroupGroup,omitempty"` + TalkgroupLabel *string `json:"talkgroupLabel,omitempty"` + TGAlphaTag *string `json:"talkgroupTag,omitempty"` - shouldStore bool `form:"-"` + shouldStore bool `json:"-"` } func (c *Call) String() string { @@ -114,7 +113,6 @@ func (c *Call) ToPB() *pb.Call { Frequency: int64(c.Frequency), Frequencies: toInt64Slice(c.Frequencies), Patches: toInt32Slice(c.Patches), - Sources: toInt32Slice(c.Sources), Duration: c.Duration.MsInt32Ptr(), Audio: c.Audio, } diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go index 5a8543f..10c1c9d 100644 --- a/pkg/calls/callstore/store.go +++ b/pkg/calls/callstore/store.go @@ -26,7 +26,7 @@ type Store interface { type store struct { } -func New() *store { +func NewStore() *store { return new(store) } @@ -41,7 +41,7 @@ func CtxWithStore(ctx context.Context, s Store) context.Context { func FromCtx(ctx context.Context) Store { s, ok := ctx.Value(StoreCtxKey).(Store) if !ok { - return New() + return NewStore() } return s @@ -103,16 +103,21 @@ func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListC 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, - TGFilter: par.TGFilter, + Start: par.Start, + End: par.End, + TagsAny: par.TagsAny, + TagsNot: par.TagsNot, + TGFilter: par.TGFilter, + LongerThan: par.LongerThan, }) if err != nil { return err } + if offset > int32(count) { + return common.ErrPageOutOfRange + } + rows, err = db.ListCallsP(ctx, par) return err }, pgx.TxOptions{}) diff --git a/pkg/config/config.go b/pkg/config/config.go index 0a10370..bbea959 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,16 +16,17 @@ type Configuration struct { } type Config struct { - DB DB `yaml:"db"` - CORS CORS `yaml:"cors"` - Auth Auth `yaml:"auth"` - Alerting Alerting `yaml:"alerting"` - Log []Logger `yaml:"log"` - Listen string `yaml:"listen"` - Public bool `yaml:"public"` - RateLimit RateLimit `yaml:"rateLimit"` - Notify Notify `yaml:"notify"` - Relay []Relay `yaml:"relay"` + BaseURL jsontypes.URL `yaml:"baseURL"` + DB DB `yaml:"db"` + CORS CORS `yaml:"cors"` + Auth Auth `yaml:"auth"` + Alerting Alerting `yaml:"alerting"` + Log []Logger `yaml:"log"` + Listen string `yaml:"listen"` + Public bool `yaml:"public"` + RateLimit RateLimit `yaml:"rateLimit"` + Notify Notify `yaml:"notify"` + Relay []Relay `yaml:"relay"` } type Auth struct { diff --git a/pkg/config/parse.go b/pkg/config/parse.go index e8ba75f..065fac5 100644 --- a/pkg/config/parse.go +++ b/pkg/config/parse.go @@ -62,7 +62,7 @@ func (c *Configuration) read() error { }) if err != nil { - return fmt.Errorf("unmarshal err: %w", err) + return fmt.Errorf("config: %w", err) } return nil diff --git a/pkg/database/calls.sql.go b/pkg/database/calls.sql.go index 7cf94e9..1129b5e 100644 --- a/pkg/database/calls.sql.go +++ b/pkg/database/calls.sql.go @@ -157,7 +157,21 @@ func (q *Queries) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Times } const getCallAudioByID = `-- name: GetCallAudioByID :one -SELECT call_date, audio_name, audio_type, audio_blob FROM calls WHERE id = $1 +SELECT + c.call_date, + c.audio_name, + c.audio_type, + c.audio_blob +FROM calls c +WHERE c.id = $1 +UNION +SELECT + sc.call_date, + sc.audio_name, + sc.audio_type, + sc.audio_blob +FROM swept_calls sc +WHERE sc.id = $1 ` type GetCallAudioByIDRow struct { diff --git a/pkg/database/incidents.sql.go b/pkg/database/incidents.sql.go new file mode 100644 index 0000000..a49f5aa --- /dev/null +++ b/pkg/database/incidents.sql.go @@ -0,0 +1,396 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: incidents.sql + +package database + +import ( + "context" + + "dynatron.me/x/stillbox/internal/jsontypes" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const addToIncident = `-- name: AddToIncident :exec +WITH inp AS ( +SELECT + UNNEST($2::UUID[]) id, + UNNEST($3::JSONB[]) notes +) INSERT INTO incidents_calls( + incident_id, + call_id, + calls_tbl_id, + call_date, + notes +) +SELECT + $1::UUID, + inp.id, + inp.id, + c.call_date, + inp.notes +FROM inp +JOIN calls c ON c.id = inp.id +` + +func (q *Queries) AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) error { + _, err := q.db.Exec(ctx, addToIncident, incidentID, callIds, notes) + return err +} + +const createIncident = `-- name: CreateIncident :one +INSERT INTO incidents ( + id, + name, + description, + start_time, + end_time, + location, + metadata +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) +RETURNING id, name, description, start_time, end_time, location, metadata +` + +type CreateIncidentParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + StartTime pgtype.Timestamptz `json:"start_time"` + EndTime pgtype.Timestamptz `json:"end_time"` + Location []byte `json:"location"` + Metadata jsontypes.Metadata `json:"metadata"` +} + +func (q *Queries) CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error) { + row := q.db.QueryRow(ctx, createIncident, + arg.ID, + arg.Name, + arg.Description, + arg.StartTime, + arg.EndTime, + arg.Location, + arg.Metadata, + ) + var i Incident + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.StartTime, + &i.EndTime, + &i.Location, + &i.Metadata, + ) + return i, err +} + +const deleteIncident = `-- name: DeleteIncident :exec +DELETE FROM incidents CASCADE WHERE id = $1 +` + +func (q *Queries) DeleteIncident(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteIncident, id) + return err +} + +const getIncident = `-- name: GetIncident :one +SELECT + i.id, + i.name, + i.description, + i.start_time, + i.end_time, + i.location, + i.metadata +FROM incidents i +WHERE i.id = $1 +` + +func (q *Queries) GetIncident(ctx context.Context, id uuid.UUID) (Incident, error) { + row := q.db.QueryRow(ctx, getIncident, id) + var i Incident + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.StartTime, + &i.EndTime, + &i.Location, + &i.Metadata, + ) + return i, err +} + +const getIncidentCalls = `-- name: GetIncidentCalls :many +SELECT ic.call_id, ic.call_date, ic.notes, c.submitter, c.system, c.talkgroup, c.audio_name, c.duration, c.audio_type, c.audio_url, c.frequency, c.frequencies, c.patches, c.source, c.transcript +FROM incidents_calls ic, LATERAL ( + SELECT + ca.submitter, + ca.system, + ca.talkgroup, + ca.audio_name, + ca.duration, + ca.audio_type, + ca.audio_url, + ca.frequency, + ca.frequencies, + ca.patches, + ca.source, + ca.transcript + FROM calls ca WHERE ca.id = ic.calls_tbl_id AND ca.call_date = ic.call_date + UNION + SELECT + sc.submitter, + sc.system, + sc.talkgroup, + sc.audio_name, + sc.duration, + sc.audio_type, + sc.audio_url, + sc.frequency, + sc.frequencies, + sc.patches, + sc.source, + sc.transcript + FROM swept_calls sc WHERE sc.id = ic.swept_call_id +) c +WHERE ic.incident_id = $1 +` + +type GetIncidentCallsRow struct { + CallID uuid.UUID `json:"call_id"` + CallDate pgtype.Timestamptz `json:"call_date"` + Notes []byte `json:"notes"` + Submitter *int32 `json:"submitter"` + System int `json:"system"` + Talkgroup int `json:"talkgroup"` + AudioName *string `json:"audio_name"` + Duration *int32 `json:"duration"` + AudioType *string `json:"audio_type"` + AudioUrl *string `json:"audio_url"` + Frequency int `json:"frequency"` + Frequencies []int `json:"frequencies"` + Patches []int `json:"patches"` + Source int `json:"source"` + Transcript *string `json:"transcript"` +} + +func (q *Queries) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error) { + rows, err := q.db.Query(ctx, getIncidentCalls, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetIncidentCallsRow + for rows.Next() { + var i GetIncidentCallsRow + if err := rows.Scan( + &i.CallID, + &i.CallDate, + &i.Notes, + &i.Submitter, + &i.System, + &i.Talkgroup, + &i.AudioName, + &i.Duration, + &i.AudioType, + &i.AudioUrl, + &i.Frequency, + &i.Frequencies, + &i.Patches, + &i.Source, + &i.Transcript, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listIncidentsCount = `-- name: ListIncidentsCount :one +SELECT COUNT(*) +FROM incidents i +WHERE +CASE WHEN $1::TIMESTAMPTZ IS NOT NULL THEN + i.start_time >= $1 ELSE TRUE END AND +CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN + i.start_time <= $2 ELSE TRUE END AND +(CASE WHEN $3::TEXT IS NOT NULL THEN ( + i.name ILIKE '%' || $3 || '%' OR + i.description ILIKE '%' || $3 || '%' + ) ELSE TRUE END) +` + +func (q *Queries) ListIncidentsCount(ctx context.Context, start pgtype.Timestamptz, end pgtype.Timestamptz, filter *string) (int64, error) { + row := q.db.QueryRow(ctx, listIncidentsCount, start, end, filter) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listIncidentsP = `-- name: ListIncidentsP :many +SELECT + i.id, + i.name, + i.description, + i.start_time, + i.end_time, + i.location, + i.metadata, + COUNT(ic.incident_id) calls_count +FROM incidents i +LEFT JOIN incidents_calls ic ON i.id = ic.incident_id +WHERE +CASE WHEN $1::TIMESTAMPTZ IS NOT NULL THEN + i.start_time >= $1 ELSE TRUE END AND +CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN + i.start_time <= $2 ELSE TRUE END AND +(CASE WHEN $3::TEXT IS NOT NULL THEN ( + i.name ILIKE '%' || $3 || '%' OR + i.description ILIKE '%' || $3 || '%' + ) ELSE TRUE END) +GROUP BY i.id +ORDER BY +CASE WHEN $4::TEXT = 'asc' THEN i.start_time END ASC, +CASE WHEN $4::TEXT = 'desc' THEN i.start_time END DESC +OFFSET $5 ROWS +FETCH NEXT $6 ROWS ONLY +` + +type ListIncidentsPParams struct { + Start pgtype.Timestamptz `json:"start"` + End pgtype.Timestamptz `json:"end"` + Filter *string `json:"filter"` + Direction string `json:"direction"` + Offset int32 `json:"offset"` + PerPage int32 `json:"per_page"` +} + +type ListIncidentsPRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + StartTime pgtype.Timestamptz `json:"start_time"` + EndTime pgtype.Timestamptz `json:"end_time"` + Location []byte `json:"location"` + Metadata jsontypes.Metadata `json:"metadata"` + CallsCount int64 `json:"calls_count"` +} + +func (q *Queries) ListIncidentsP(ctx context.Context, arg ListIncidentsPParams) ([]ListIncidentsPRow, error) { + rows, err := q.db.Query(ctx, listIncidentsP, + arg.Start, + arg.End, + arg.Filter, + arg.Direction, + arg.Offset, + arg.PerPage, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListIncidentsPRow + for rows.Next() { + var i ListIncidentsPRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.StartTime, + &i.EndTime, + &i.Location, + &i.Metadata, + &i.CallsCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeFromIncident = `-- name: RemoveFromIncident :exec +DELETE FROM incidents_calls ic +WHERE ic.incident_id = $1 AND ic.call_id = ANY($2::UUID[]) +` + +func (q *Queries) RemoveFromIncident(ctx context.Context, iD uuid.UUID, callIds []uuid.UUID) error { + _, err := q.db.Exec(ctx, removeFromIncident, iD, callIds) + return err +} + +const updateCallIncidentNotes = `-- name: UpdateCallIncidentNotes :exec +UPDATE incidents_Calls +SET notes = $1 +WHERE incident_id = $2 AND call_id = $3 +` + +func (q *Queries) UpdateCallIncidentNotes(ctx context.Context, notes []byte, incidentID uuid.UUID, callID uuid.UUID) error { + _, err := q.db.Exec(ctx, updateCallIncidentNotes, notes, incidentID, callID) + return err +} + +const updateIncident = `-- name: UpdateIncident :one +UPDATE incidents +SET + name = COALESCE($1, name), + description = COALESCE($2, description), + start_time = COALESCE($3, start_time), + end_time = COALESCE($4, end_time), + location = COALESCE($5, location), + metadata = COALESCE($6, metadata) +WHERE + id = $7 +RETURNING id, name, description, start_time, end_time, location, metadata +` + +type UpdateIncidentParams struct { + Name *string `json:"name"` + Description *string `json:"description"` + StartTime pgtype.Timestamptz `json:"start_time"` + EndTime pgtype.Timestamptz `json:"end_time"` + Location []byte `json:"location"` + Metadata jsontypes.Metadata `json:"metadata"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateIncident(ctx context.Context, arg UpdateIncidentParams) (Incident, error) { + row := q.db.QueryRow(ctx, updateIncident, + arg.Name, + arg.Description, + arg.StartTime, + arg.EndTime, + arg.Location, + arg.Metadata, + arg.ID, + ) + var i Incident + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.StartTime, + &i.EndTime, + &i.Location, + &i.Metadata, + ) + return i, err +} diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index 07111c4..b308e41 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -181,6 +181,55 @@ func (_c *Store_AddLearnedTalkgroup_Call) RunAndReturn(run func(context.Context, return _c } +// AddToIncident provides a mock function with given fields: ctx, incidentID, callIds, notes +func (_m *Store) AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) error { + ret := _m.Called(ctx, incidentID, callIds, notes) + + if len(ret) == 0 { + panic("no return value specified for AddToIncident") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, []uuid.UUID, [][]byte) error); ok { + r0 = rf(ctx, incidentID, callIds, notes) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_AddToIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddToIncident' +type Store_AddToIncident_Call struct { + *mock.Call +} + +// AddToIncident is a helper method to define mock.On call +// - ctx context.Context +// - incidentID uuid.UUID +// - callIds []uuid.UUID +// - notes [][]byte +func (_e *Store_Expecter) AddToIncident(ctx interface{}, incidentID interface{}, callIds interface{}, notes interface{}) *Store_AddToIncident_Call { + return &Store_AddToIncident_Call{Call: _e.mock.On("AddToIncident", ctx, incidentID, callIds, notes)} +} + +func (_c *Store_AddToIncident_Call) Run(run func(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte)) *Store_AddToIncident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID), args[2].([]uuid.UUID), args[3].([][]byte)) + }) + return _c +} + +func (_c *Store_AddToIncident_Call) Return(_a0 error) *Store_AddToIncident_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_AddToIncident_Call) RunAndReturn(run func(context.Context, uuid.UUID, []uuid.UUID, [][]byte) error) *Store_AddToIncident_Call { + _c.Call.Return(run) + return _c +} + // BulkSetTalkgroupTags provides a mock function with given fields: ctx, tgs, tags func (_m *Store) BulkSetTalkgroupTags(ctx context.Context, tgs database.TGTuples, tags []string) error { ret := _m.Called(ctx, tgs, tags) @@ -346,6 +395,63 @@ func (_c *Store_CreateAPIKey_Call) RunAndReturn(run func(context.Context, int, p return _c } +// CreateIncident provides a mock function with given fields: ctx, arg +func (_m *Store) CreateIncident(ctx context.Context, arg database.CreateIncidentParams) (database.Incident, error) { + ret := _m.Called(ctx, arg) + + if len(ret) == 0 { + panic("no return value specified for CreateIncident") + } + + var r0 database.Incident + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, database.CreateIncidentParams) (database.Incident, error)); ok { + return rf(ctx, arg) + } + if rf, ok := ret.Get(0).(func(context.Context, database.CreateIncidentParams) database.Incident); ok { + r0 = rf(ctx, arg) + } else { + r0 = ret.Get(0).(database.Incident) + } + + if rf, ok := ret.Get(1).(func(context.Context, database.CreateIncidentParams) error); ok { + r1 = rf(ctx, arg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_CreateIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateIncident' +type Store_CreateIncident_Call struct { + *mock.Call +} + +// CreateIncident is a helper method to define mock.On call +// - ctx context.Context +// - arg database.CreateIncidentParams +func (_e *Store_Expecter) CreateIncident(ctx interface{}, arg interface{}) *Store_CreateIncident_Call { + return &Store_CreateIncident_Call{Call: _e.mock.On("CreateIncident", ctx, arg)} +} + +func (_c *Store_CreateIncident_Call) Run(run func(ctx context.Context, arg database.CreateIncidentParams)) *Store_CreateIncident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(database.CreateIncidentParams)) + }) + return _c +} + +func (_c *Store_CreateIncident_Call) Return(_a0 database.Incident, _a1 error) *Store_CreateIncident_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_CreateIncident_Call) RunAndReturn(run func(context.Context, database.CreateIncidentParams) (database.Incident, error)) *Store_CreateIncident_Call { + _c.Call.Return(run) + return _c +} + // CreatePartition provides a mock function with given fields: ctx, parentTable, partitionName, start, end func (_m *Store) CreatePartition(ctx context.Context, parentTable string, partitionName string, start time.Time, end time.Time) error { ret := _m.Called(ctx, parentTable, partitionName, start, end) @@ -642,6 +748,53 @@ func (_c *Store_DeleteAPIKey_Call) RunAndReturn(run func(context.Context, string return _c } +// DeleteIncident provides a mock function with given fields: ctx, id +func (_m *Store) DeleteIncident(ctx context.Context, id uuid.UUID) error { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for DeleteIncident") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_DeleteIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteIncident' +type Store_DeleteIncident_Call struct { + *mock.Call +} + +// DeleteIncident is a helper method to define mock.On call +// - ctx context.Context +// - id uuid.UUID +func (_e *Store_Expecter) DeleteIncident(ctx interface{}, id interface{}) *Store_DeleteIncident_Call { + return &Store_DeleteIncident_Call{Call: _e.mock.On("DeleteIncident", ctx, id)} +} + +func (_c *Store_DeleteIncident_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_DeleteIncident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID)) + }) + return _c +} + +func (_c *Store_DeleteIncident_Call) Return(_a0 error) *Store_DeleteIncident_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_DeleteIncident_Call) RunAndReturn(run func(context.Context, uuid.UUID) error) *Store_DeleteIncident_Call { + _c.Call.Return(run) + return _c +} + // DeleteSystem provides a mock function with given fields: ctx, id func (_m *Store) DeleteSystem(ctx context.Context, id int) error { ret := _m.Called(ctx, id) @@ -1167,6 +1320,122 @@ func (_c *Store_GetDatabaseSize_Call) RunAndReturn(run func(context.Context) (st return _c } +// GetIncident provides a mock function with given fields: ctx, id +func (_m *Store) GetIncident(ctx context.Context, id uuid.UUID) (database.Incident, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetIncident") + } + + var r0 database.Incident + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (database.Incident, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) database.Incident); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(database.Incident) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIncident' +type Store_GetIncident_Call struct { + *mock.Call +} + +// GetIncident is a helper method to define mock.On call +// - ctx context.Context +// - id uuid.UUID +func (_e *Store_Expecter) GetIncident(ctx interface{}, id interface{}) *Store_GetIncident_Call { + return &Store_GetIncident_Call{Call: _e.mock.On("GetIncident", ctx, id)} +} + +func (_c *Store_GetIncident_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_GetIncident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID)) + }) + return _c +} + +func (_c *Store_GetIncident_Call) Return(_a0 database.Incident, _a1 error) *Store_GetIncident_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetIncident_Call) RunAndReturn(run func(context.Context, uuid.UUID) (database.Incident, error)) *Store_GetIncident_Call { + _c.Call.Return(run) + return _c +} + +// GetIncidentCalls provides a mock function with given fields: ctx, id +func (_m *Store) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]database.GetIncidentCallsRow, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetIncidentCalls") + } + + var r0 []database.GetIncidentCallsRow + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) ([]database.GetIncidentCallsRow, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) []database.GetIncidentCallsRow); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.GetIncidentCallsRow) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetIncidentCalls_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIncidentCalls' +type Store_GetIncidentCalls_Call struct { + *mock.Call +} + +// GetIncidentCalls is a helper method to define mock.On call +// - ctx context.Context +// - id uuid.UUID +func (_e *Store_Expecter) GetIncidentCalls(ctx interface{}, id interface{}) *Store_GetIncidentCalls_Call { + return &Store_GetIncidentCalls_Call{Call: _e.mock.On("GetIncidentCalls", ctx, id)} +} + +func (_c *Store_GetIncidentCalls_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_GetIncidentCalls_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID)) + }) + return _c +} + +func (_c *Store_GetIncidentCalls_Call) Return(_a0 []database.GetIncidentCallsRow, _a1 error) *Store_GetIncidentCalls_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetIncidentCalls_Call) RunAndReturn(run func(context.Context, uuid.UUID) ([]database.GetIncidentCallsRow, error)) *Store_GetIncidentCalls_Call { + _c.Call.Return(run) + return _c +} + // GetSystemName provides a mock function with given fields: ctx, systemID func (_m *Store) GetSystemName(ctx context.Context, systemID int) (string, error) { ret := _m.Called(ctx, systemID) @@ -2500,6 +2769,172 @@ func (_c *Store_ListCallsP_Call) RunAndReturn(run func(context.Context, database return _c } +// ListIncidentsCount provides a mock function with given fields: ctx, start, end, filter +func (_m *Store) ListIncidentsCount(ctx context.Context, start pgtype.Timestamptz, end pgtype.Timestamptz, filter *string) (int64, error) { + ret := _m.Called(ctx, start, end, filter) + + if len(ret) == 0 { + panic("no return value specified for ListIncidentsCount") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, pgtype.Timestamptz, pgtype.Timestamptz, *string) (int64, error)); ok { + return rf(ctx, start, end, filter) + } + if rf, ok := ret.Get(0).(func(context.Context, pgtype.Timestamptz, pgtype.Timestamptz, *string) int64); ok { + r0 = rf(ctx, start, end, filter) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, pgtype.Timestamptz, pgtype.Timestamptz, *string) error); ok { + r1 = rf(ctx, start, end, filter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_ListIncidentsCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListIncidentsCount' +type Store_ListIncidentsCount_Call struct { + *mock.Call +} + +// ListIncidentsCount is a helper method to define mock.On call +// - ctx context.Context +// - start pgtype.Timestamptz +// - end pgtype.Timestamptz +// - filter *string +func (_e *Store_Expecter) ListIncidentsCount(ctx interface{}, start interface{}, end interface{}, filter interface{}) *Store_ListIncidentsCount_Call { + return &Store_ListIncidentsCount_Call{Call: _e.mock.On("ListIncidentsCount", ctx, start, end, filter)} +} + +func (_c *Store_ListIncidentsCount_Call) Run(run func(ctx context.Context, start pgtype.Timestamptz, end pgtype.Timestamptz, filter *string)) *Store_ListIncidentsCount_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(pgtype.Timestamptz), args[2].(pgtype.Timestamptz), args[3].(*string)) + }) + return _c +} + +func (_c *Store_ListIncidentsCount_Call) Return(_a0 int64, _a1 error) *Store_ListIncidentsCount_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_ListIncidentsCount_Call) RunAndReturn(run func(context.Context, pgtype.Timestamptz, pgtype.Timestamptz, *string) (int64, error)) *Store_ListIncidentsCount_Call { + _c.Call.Return(run) + return _c +} + +// ListIncidentsP provides a mock function with given fields: ctx, arg +func (_m *Store) ListIncidentsP(ctx context.Context, arg database.ListIncidentsPParams) ([]database.ListIncidentsPRow, error) { + ret := _m.Called(ctx, arg) + + if len(ret) == 0 { + panic("no return value specified for ListIncidentsP") + } + + var r0 []database.ListIncidentsPRow + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, database.ListIncidentsPParams) ([]database.ListIncidentsPRow, error)); ok { + return rf(ctx, arg) + } + if rf, ok := ret.Get(0).(func(context.Context, database.ListIncidentsPParams) []database.ListIncidentsPRow); ok { + r0 = rf(ctx, arg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.ListIncidentsPRow) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, database.ListIncidentsPParams) error); ok { + r1 = rf(ctx, arg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_ListIncidentsP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListIncidentsP' +type Store_ListIncidentsP_Call struct { + *mock.Call +} + +// ListIncidentsP is a helper method to define mock.On call +// - ctx context.Context +// - arg database.ListIncidentsPParams +func (_e *Store_Expecter) ListIncidentsP(ctx interface{}, arg interface{}) *Store_ListIncidentsP_Call { + return &Store_ListIncidentsP_Call{Call: _e.mock.On("ListIncidentsP", ctx, arg)} +} + +func (_c *Store_ListIncidentsP_Call) Run(run func(ctx context.Context, arg database.ListIncidentsPParams)) *Store_ListIncidentsP_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(database.ListIncidentsPParams)) + }) + return _c +} + +func (_c *Store_ListIncidentsP_Call) Return(_a0 []database.ListIncidentsPRow, _a1 error) *Store_ListIncidentsP_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_ListIncidentsP_Call) RunAndReturn(run func(context.Context, database.ListIncidentsPParams) ([]database.ListIncidentsPRow, error)) *Store_ListIncidentsP_Call { + _c.Call.Return(run) + return _c +} + +// RemoveFromIncident provides a mock function with given fields: ctx, iD, callIds +func (_m *Store) RemoveFromIncident(ctx context.Context, iD uuid.UUID, callIds []uuid.UUID) error { + ret := _m.Called(ctx, iD, callIds) + + if len(ret) == 0 { + panic("no return value specified for RemoveFromIncident") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, []uuid.UUID) error); ok { + r0 = rf(ctx, iD, callIds) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_RemoveFromIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveFromIncident' +type Store_RemoveFromIncident_Call struct { + *mock.Call +} + +// RemoveFromIncident is a helper method to define mock.On call +// - ctx context.Context +// - iD uuid.UUID +// - callIds []uuid.UUID +func (_e *Store_Expecter) RemoveFromIncident(ctx interface{}, iD interface{}, callIds interface{}) *Store_RemoveFromIncident_Call { + return &Store_RemoveFromIncident_Call{Call: _e.mock.On("RemoveFromIncident", ctx, iD, callIds)} +} + +func (_c *Store_RemoveFromIncident_Call) Run(run func(ctx context.Context, iD uuid.UUID, callIds []uuid.UUID)) *Store_RemoveFromIncident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID), args[2].([]uuid.UUID)) + }) + return _c +} + +func (_c *Store_RemoveFromIncident_Call) Return(_a0 error) *Store_RemoveFromIncident_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_RemoveFromIncident_Call) RunAndReturn(run func(context.Context, uuid.UUID, []uuid.UUID) error) *Store_RemoveFromIncident_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) @@ -2859,6 +3294,112 @@ func (_c *Store_SweepCalls_Call) RunAndReturn(run func(context.Context, pgtype.T return _c } +// UpdateCallIncidentNotes provides a mock function with given fields: ctx, notes, incidentID, callID +func (_m *Store) UpdateCallIncidentNotes(ctx context.Context, notes []byte, incidentID uuid.UUID, callID uuid.UUID) error { + ret := _m.Called(ctx, notes, incidentID, callID) + + if len(ret) == 0 { + panic("no return value specified for UpdateCallIncidentNotes") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []byte, uuid.UUID, uuid.UUID) error); ok { + r0 = rf(ctx, notes, incidentID, callID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_UpdateCallIncidentNotes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCallIncidentNotes' +type Store_UpdateCallIncidentNotes_Call struct { + *mock.Call +} + +// UpdateCallIncidentNotes is a helper method to define mock.On call +// - ctx context.Context +// - notes []byte +// - incidentID uuid.UUID +// - callID uuid.UUID +func (_e *Store_Expecter) UpdateCallIncidentNotes(ctx interface{}, notes interface{}, incidentID interface{}, callID interface{}) *Store_UpdateCallIncidentNotes_Call { + return &Store_UpdateCallIncidentNotes_Call{Call: _e.mock.On("UpdateCallIncidentNotes", ctx, notes, incidentID, callID)} +} + +func (_c *Store_UpdateCallIncidentNotes_Call) Run(run func(ctx context.Context, notes []byte, incidentID uuid.UUID, callID uuid.UUID)) *Store_UpdateCallIncidentNotes_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]byte), args[2].(uuid.UUID), args[3].(uuid.UUID)) + }) + return _c +} + +func (_c *Store_UpdateCallIncidentNotes_Call) Return(_a0 error) *Store_UpdateCallIncidentNotes_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_UpdateCallIncidentNotes_Call) RunAndReturn(run func(context.Context, []byte, uuid.UUID, uuid.UUID) error) *Store_UpdateCallIncidentNotes_Call { + _c.Call.Return(run) + return _c +} + +// UpdateIncident provides a mock function with given fields: ctx, arg +func (_m *Store) UpdateIncident(ctx context.Context, arg database.UpdateIncidentParams) (database.Incident, error) { + ret := _m.Called(ctx, arg) + + if len(ret) == 0 { + panic("no return value specified for UpdateIncident") + } + + var r0 database.Incident + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, database.UpdateIncidentParams) (database.Incident, error)); ok { + return rf(ctx, arg) + } + if rf, ok := ret.Get(0).(func(context.Context, database.UpdateIncidentParams) database.Incident); ok { + r0 = rf(ctx, arg) + } else { + r0 = ret.Get(0).(database.Incident) + } + + if rf, ok := ret.Get(1).(func(context.Context, database.UpdateIncidentParams) error); ok { + r1 = rf(ctx, arg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_UpdateIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateIncident' +type Store_UpdateIncident_Call struct { + *mock.Call +} + +// UpdateIncident is a helper method to define mock.On call +// - ctx context.Context +// - arg database.UpdateIncidentParams +func (_e *Store_Expecter) UpdateIncident(ctx interface{}, arg interface{}) *Store_UpdateIncident_Call { + return &Store_UpdateIncident_Call{Call: _e.mock.On("UpdateIncident", ctx, arg)} +} + +func (_c *Store_UpdateIncident_Call) Run(run func(ctx context.Context, arg database.UpdateIncidentParams)) *Store_UpdateIncident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(database.UpdateIncidentParams)) + }) + return _c +} + +func (_c *Store_UpdateIncident_Call) Return(_a0 database.Incident, _a1 error) *Store_UpdateIncident_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_UpdateIncident_Call) RunAndReturn(run func(context.Context, database.UpdateIncidentParams) (database.Incident, error)) *Store_UpdateIncident_Call { + _c.Call.Return(run) + return _c +} + // UpdatePassword provides a mock function with given fields: ctx, username, password func (_m *Store) UpdatePassword(ctx context.Context, username string, password string) error { ret := _m.Called(ctx, username, password) diff --git a/pkg/database/models.go b/pkg/database/models.go index 6a6f56b..2448e92 100644 --- a/pkg/database/models.go +++ b/pkg/database/models.go @@ -56,13 +56,13 @@ type Call struct { } type Incident struct { - ID uuid.UUID `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - StartTime pgtype.Timestamp `json:"start_time,omitempty"` - EndTime pgtype.Timestamp `json:"end_time,omitempty"` - Location []byte `json:"location,omitempty"` - Metadata []byte `json:"metadata,omitempty"` + ID uuid.UUID `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + StartTime pgtype.Timestamptz `json:"start_time,omitempty"` + EndTime pgtype.Timestamptz `json:"end_time,omitempty"` + Location []byte `json:"location,omitempty"` + Metadata jsontypes.Metadata `json:"metadata,omitempty"` } type IncidentsCall struct { diff --git a/pkg/database/querier.go b/pkg/database/querier.go index 3606fb1..f8f15e2 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -15,12 +15,15 @@ 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) + AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) 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) + CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error) CreateSystem(ctx context.Context, iD int, name string) error CreateUser(ctx context.Context, arg CreateUserParams) (User, error) DeleteAPIKey(ctx context.Context, apiKey string) error + DeleteIncident(ctx context.Context, id uuid.UUID) error DeleteSystem(ctx context.Context, id int) error DeleteTalkgroup(ctx context.Context, systemID int32, tGID int32) error DeleteUser(ctx context.Context, username string) error @@ -29,6 +32,8 @@ type Querier interface { GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error) GetDatabaseSize(ctx context.Context) (string, error) + GetIncident(ctx context.Context, id uuid.UUID) (Incident, error) + GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error) GetSystemName(ctx context.Context, systemID int) (string, error) GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error) GetTalkgroupIDsByTags(ctx context.Context, anyTags []string, allTags []string, notTags []string) ([]GetTalkgroupIDsByTagsRow, error) @@ -48,6 +53,9 @@ type Querier interface { GetUsers(ctx context.Context) ([]User, error) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListCallsPRow, error) + ListIncidentsCount(ctx context.Context, start pgtype.Timestamptz, end pgtype.Timestamptz, filter *string) (int64, error) + ListIncidentsP(ctx context.Context, arg ListIncidentsPParams) ([]ListIncidentsPRow, error) + RemoveFromIncident(ctx context.Context, iD uuid.UUID, callIds []uuid.UUID) 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 @@ -55,6 +63,8 @@ type Querier interface { StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults SweepCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) + UpdateCallIncidentNotes(ctx context.Context, notes []byte, incidentID uuid.UUID, callID uuid.UUID) error + UpdateIncident(ctx context.Context, arg UpdateIncidentParams) (Incident, error) UpdatePassword(ctx context.Context, username string, password string) error UpdateTalkgroup(ctx context.Context, arg UpdateTalkgroupParams) (Talkgroup, error) UpsertTalkgroup(ctx context.Context, arg []UpsertTalkgroupParams) *UpsertTalkgroupBatchResults diff --git a/pkg/incidents/incident.go b/pkg/incidents/incident.go new file mode 100644 index 0000000..c2ee068 --- /dev/null +++ b/pkg/incidents/incident.go @@ -0,0 +1,25 @@ +package incidents + +import ( + "encoding/json" + + "dynatron.me/x/stillbox/internal/jsontypes" + "dynatron.me/x/stillbox/pkg/calls" + "github.com/google/uuid" +) + +type Incident struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + StartTime *jsontypes.Time `json:"startTime"` + EndTime *jsontypes.Time `json:"endTime"` + Location jsontypes.Location `json:"location"` + Metadata jsontypes.Metadata `json:"metadata"` + Calls []IncidentCall `json:"calls"` +} + +type IncidentCall struct { + calls.Call + Notes json.RawMessage `json:"notes"` +} diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go new file mode 100644 index 0000000..79785ed --- /dev/null +++ b/pkg/incidents/incstore/store.go @@ -0,0 +1,319 @@ +package incstore + +import ( + "context" + "time" + + "dynatron.me/x/stillbox/internal/common" + "dynatron.me/x/stillbox/internal/jsontypes" + "dynatron.me/x/stillbox/pkg/auth" + "dynatron.me/x/stillbox/pkg/calls" + "dynatron.me/x/stillbox/pkg/database" + "dynatron.me/x/stillbox/pkg/incidents" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type IncidentsParams struct { + common.Pagination + Direction *common.SortDirection `json:"dir"` + Filter *string `json:"filter"` + + Start *jsontypes.Time `json:"start"` + End *jsontypes.Time `json:"end"` +} + +type Store interface { + // CreateIncident creates an incident. + CreateIncident(ctx context.Context, inc incidents.Incident) (*incidents.Incident, error) + + // AddToIncident adds the specified call IDs to an incident. + // If not nil, notes must be valid json. + AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID, addCallIDs []uuid.UUID, notes []byte, removeCallIDs []uuid.UUID) error + + // UpdateNotes updates the notes for a call-incident mapping. + UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID, notes []byte) error + + // Incidents gets incidents matching parameters and pagination. + Incidents(ctx context.Context, p IncidentsParams) (incs []Incident, totalCount int, err error) + + // Incident gets a single incident. + Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) + + // UpdateIncident updates an incident. + UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) + + // DeleteIncident deletes an incident. + DeleteIncident(ctx context.Context, id uuid.UUID) error +} + +type store struct { +} + +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 NewStore() + } + + return s +} + +func NewStore() Store { + return &store{} +} + +func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*incidents.Incident, error) { + db := database.FromCtx(ctx) + var dbInc database.Incident + + id := uuid.New() + + txErr := db.InTx(ctx, func(db database.Store) error { + var err error + dbInc, err = db.CreateIncident(ctx, database.CreateIncidentParams{ + ID: id, + Name: inc.Name, + Description: inc.Description, + StartTime: inc.StartTime.PGTypeTSTZ(), + EndTime: inc.EndTime.PGTypeTSTZ(), + Location: inc.Location.RawMessage, + Metadata: inc.Metadata, + }) + if err != nil { + return err + } + + if len(inc.Calls) > 0 { + callIDs := make([]uuid.UUID, 0, len(inc.Calls)) + notes := make([][]byte, 0, len(inc.Calls)) + hasNote := false + for _, c := range inc.Calls { + callIDs = append(callIDs, c.ID) + if c.Notes != nil { + hasNote = true + } + notes = append(notes, c.Notes) + } + if !hasNote { + notes = nil + } + + err = db.AddToIncident(ctx, dbInc.ID, callIDs, notes) + if err != nil { + return err + } + } + + return nil + }, pgx.TxOptions{}) + if txErr != nil { + return nil, txErr + } + + inc = fromDBIncident(id, dbInc) + + return &inc, nil +} + +func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID, addCallIDs []uuid.UUID, notes []byte, removeCallIDs []uuid.UUID) error { + return database.FromCtx(ctx).InTx(ctx, func(db database.Store) error { + if len(addCallIDs) > 0 { + var noteAr [][]byte + if notes != nil { + noteAr = make([][]byte, len(addCallIDs)) + for i := range addCallIDs { + noteAr[i] = notes + } + } + + err := db.AddToIncident(ctx, incidentID, addCallIDs, noteAr) + if err != nil { + return err + } + } + + if len(removeCallIDs) > 0 { + err := db.RemoveFromIncident(ctx, incidentID, removeCallIDs) + if err != nil { + return err + } + } + + return nil + }, pgx.TxOptions{}) +} + +func (s *store) Incidents(ctx context.Context, p IncidentsParams) (incs []Incident, totalCount int, err error) { + db := database.FromCtx(ctx) + + offset, perPage := p.Pagination.OffsetPerPage(100) + dbParam := database.ListIncidentsPParams{ + Start: p.Start.PGTypeTSTZ(), + End: p.End.PGTypeTSTZ(), + Filter: p.Filter, + Direction: p.Direction.DirString(common.DirAsc), + Offset: offset, + PerPage: perPage, + } + + var count int64 + var rows []database.ListIncidentsPRow + txErr := db.InTx(ctx, func(db database.Store) error { + var err error + count, err = db.ListIncidentsCount(ctx, dbParam.Start, dbParam.End, dbParam.Filter) + if err != nil { + return err + } + + if offset > int32(count) { + return common.ErrPageOutOfRange + } + + rows, err = db.ListIncidentsP(ctx, dbParam) + return err + }, pgx.TxOptions{}) + if txErr != nil { + return nil, 0, txErr + } + + incs = make([]Incident, 0, len(rows)) + for _, v := range rows { + incs = append(incs, fromDBListInPRow(v.ID, v)) + } + + return incs, int(count), err +} + +func fromDBIncident(id uuid.UUID, d database.Incident) incidents.Incident { + return incidents.Incident{ + ID: id, + Name: d.Name, + Description: d.Description, + StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime), + EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime), + Metadata: d.Metadata, + } +} + +type Incident struct { + incidents.Incident + + CallCount int `json:"callCount"` +} + +func fromDBListInPRow(id uuid.UUID, d database.ListIncidentsPRow) Incident { + return Incident{ + Incident: incidents.Incident{ + ID: id, + Name: d.Name, + Description: d.Description, + StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime), + EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime), + Metadata: d.Metadata, + }, + CallCount: int(d.CallsCount), + } +} + +func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall { + r := make([]incidents.IncidentCall, 0, len(d)) + for _, v := range d { + dur := calls.CallDuration(time.Duration(common.ZeroIfNil(v.Duration)) * time.Millisecond) + sub := common.PtrTo(auth.UserID(common.ZeroIfNil(v.Submitter))) + r = append(r, incidents.IncidentCall{ + Call: calls.Call{ + ID: v.CallID, + AudioName: common.ZeroIfNil(v.AudioName), + AudioType: common.ZeroIfNil(v.AudioType), + Duration: dur, + DateTime: v.CallDate.Time, + Frequencies: v.Frequencies, + Frequency: v.Frequency, + Patches: v.Patches, + Source: v.Source, + System: v.System, + Submitter: sub, + Talkgroup: v.Talkgroup, + }, + Notes: v.Notes, + }) + } + + return r +} + +func (s *store) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) { + var r incidents.Incident + txErr := database.FromCtx(ctx).InTx(ctx, func(db database.Store) error { + inc, err := db.GetIncident(ctx, id) + if err != nil { + return err + } + + calls, err := db.GetIncidentCalls(ctx, id) + if err != nil { + return err + } + + r = fromDBIncident(id, inc) + r.Calls = fromDBCalls(calls) + + return nil + }, pgx.TxOptions{}) + if txErr != nil { + return nil, txErr + } + + return &r, nil +} + +type UpdateIncidentParams struct { + Name *string `json:"name"` + Description *string `json:"description"` + StartTime *jsontypes.Time `json:"startTime"` + EndTime *jsontypes.Time `json:"endTime"` + Location []byte `json:"location"` + Metadata jsontypes.Metadata `json:"metadata"` +} + +func (uip UpdateIncidentParams) toDBUIP(id uuid.UUID) database.UpdateIncidentParams { + return database.UpdateIncidentParams{ + ID: id, + Name: uip.Name, + Description: uip.Description, + StartTime: uip.StartTime.PGTypeTSTZ(), + EndTime: uip.EndTime.PGTypeTSTZ(), + Location: uip.Location, + Metadata: uip.Metadata, + } +} + +func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) { + db := database.FromCtx(ctx) + + dbInc, err := db.UpdateIncident(ctx, p.toDBUIP(id)) + if err != nil { + return nil, err + } + + inc := fromDBIncident(id, dbInc) + + return &inc, nil +} + +func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error { + return database.FromCtx(ctx).DeleteIncident(ctx, id) +} + +func (s *store) UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID, notes []byte) error { + return database.FromCtx(ctx).UpdateCallIncidentNotes(ctx, notes, incidentID, callID) +} diff --git a/pkg/pb/stillbox.pb.go b/pkg/pb/stillbox.pb.go index 8d29825..f473933 100644 --- a/pkg/pb/stillbox.pb.go +++ b/pkg/pb/stillbox.pb.go @@ -298,9 +298,8 @@ type Call struct { Frequency int64 `protobuf:"varint,8,opt,name=frequency,proto3" json:"frequency,omitempty"` Frequencies []int64 `protobuf:"varint,9,rep,packed,name=frequencies,proto3" json:"frequencies,omitempty"` Patches []int32 `protobuf:"varint,10,rep,packed,name=patches,proto3" json:"patches,omitempty"` - Sources []int32 `protobuf:"varint,11,rep,packed,name=sources,proto3" json:"sources,omitempty"` - Duration *int32 `protobuf:"varint,12,opt,name=duration,proto3,oneof" json:"duration,omitempty"` - Audio []byte `protobuf:"bytes,13,opt,name=audio,proto3" json:"audio,omitempty"` + Duration *int32 `protobuf:"varint,11,opt,name=duration,proto3,oneof" json:"duration,omitempty"` + Audio []byte `protobuf:"bytes,12,opt,name=audio,proto3" json:"audio,omitempty"` } func (x *Call) Reset() { @@ -405,13 +404,6 @@ func (x *Call) GetPatches() []int32 { return nil } -func (x *Call) GetSources() []int32 { - if x != nil { - return x.Sources - } - return nil -} - func (x *Call) GetDuration() int32 { if x != nil && x.Duration != nil { return *x.Duration @@ -1195,7 +1187,7 @@ var file_stillbox_proto_rawDesc = []byte{ 0x6f, 0x75, 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x06, 0x74, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x42, 0x12, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x5f, 0x69, 0x64, 0x22, 0x91, 0x03, 0x0a, 0x04, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x0e, 0x0a, + 0x64, 0x5f, 0x69, 0x64, 0x22, 0xf7, 0x02, 0x0a, 0x04, 0x43, 0x61, 0x6c, 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, @@ -1214,112 +1206,111 @@ var file_stillbox_proto_rawDesc = []byte{ 0x0a, 0x0b, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0b, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, - 0x05, 0x52, 0x07, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x05, 0x52, 0x07, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x0b, 0x0a, 0x09, 0x5f, - 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x3e, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, - 0x6f, 0x12, 0x35, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x66, 0x6f, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, - 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0a, 0x73, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x22, 0x1d, 0x0a, 0x09, 0x55, 0x73, 0x65, 0x72, - 0x50, 0x6f, 0x70, 0x75, 0x70, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6d, 0x73, 0x67, 0x22, 0x4a, 0x0a, 0x05, 0x45, 0x72, 0x72, 0x6f, 0x72, - 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2b, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, - 0x6f, 0x78, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x22, 0x78, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x37, 0x0a, 0x09, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, - 0x6d, 0x70, 0x52, 0x08, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, - 0x6d, 0x73, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x73, 0x67, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x22, 0xed, 0x01, - 0x0a, 0x07, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, - 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x33, 0x0a, - 0x0c, 0x6c, 0x69, 0x76, 0x65, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x4c, - 0x69, 0x76, 0x65, 0x48, 0x00, 0x52, 0x0b, 0x6c, 0x69, 0x76, 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, - 0x6e, 0x64, 0x12, 0x39, 0x0a, 0x0e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x73, 0x74, 0x69, - 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x48, 0x00, 0x52, 0x0d, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x34, 0x0a, - 0x0a, 0x74, 0x67, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x07, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x64, 0x75, + 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, + 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x61, + 0x75, 0x64, 0x69, 0x6f, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, + 0x6f, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x3e, + 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x35, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, + 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x6e, + 0x66, 0x6f, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x22, 0x1d, + 0x0a, 0x09, 0x55, 0x73, 0x65, 0x72, 0x50, 0x6f, 0x70, 0x75, 0x70, 0x12, 0x10, 0x0a, 0x03, 0x6d, + 0x73, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x73, 0x67, 0x22, 0x4a, 0x0a, + 0x05, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2b, 0x0a, 0x07, + 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, + 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x22, 0x78, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x37, 0x0a, 0x09, 0x64, 0x61, 0x74, + 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, + 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6d, 0x73, 0x67, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x75, + 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x55, 0x72, 0x6c, 0x22, 0xed, 0x01, 0x0a, 0x07, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, + 0x22, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, + 0x88, 0x01, 0x01, 0x12, 0x33, 0x0a, 0x0c, 0x6c, 0x69, 0x76, 0x65, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x73, 0x74, 0x69, 0x6c, + 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x48, 0x00, 0x52, 0x0b, 0x6c, 0x69, 0x76, + 0x65, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x39, 0x0a, 0x0e, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x10, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x48, 0x00, 0x52, 0x0d, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x43, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x0a, 0x74, 0x67, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, + 0x6f, 0x78, 0x2e, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x48, 0x00, 0x52, 0x09, + 0x74, 0x67, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x63, 0x6f, 0x6d, + 0x6d, 0x61, 0x6e, 0x64, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x5f, 0x69, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0d, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x23, 0x0a, 0x02, 0x74, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x54, 0x61, 0x6c, - 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x48, 0x00, 0x52, 0x09, 0x74, 0x67, 0x43, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x42, 0x09, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x42, 0x0d, - 0x0a, 0x0b, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x22, 0xf2, 0x02, - 0x0a, 0x0d, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x12, - 0x23, 0x0a, 0x02, 0x74, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x74, - 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x52, 0x02, 0x74, 0x67, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x79, 0x73, 0x74, 0x65, - 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x19, - 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, - 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x88, 0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x61, 0x6c, 0x70, - 0x68, 0x61, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x08, - 0x61, 0x6c, 0x70, 0x68, 0x61, 0x54, 0x61, 0x67, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, - 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x48, 0x03, - 0x52, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x12, 0x12, - 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, - 0x67, 0x73, 0x12, 0x38, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x48, 0x04, 0x52, - 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x88, 0x01, 0x01, 0x12, 0x18, 0x0a, 0x07, - 0x6c, 0x65, 0x61, 0x72, 0x6e, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x6c, - 0x65, 0x61, 0x72, 0x6e, 0x65, 0x64, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x08, 0x0a, 0x06, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x5f, 0x74, 0x61, 0x67, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x66, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x22, 0x7a, 0x0a, 0x04, 0x4c, 0x69, 0x76, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, - 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x48, 0x00, - 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69, - 0x6c, 0x74, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x73, 0x74, 0x69, - 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x48, 0x01, 0x52, 0x06, - 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x22, 0x41, - 0x0a, 0x09, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, - 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x79, 0x73, - 0x74, 0x65, 0x6d, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, - 0x70, 0x22, 0x83, 0x02, 0x0a, 0x06, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x12, 0x33, 0x0a, 0x0a, - 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x54, 0x61, 0x6c, 0x6b, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x0a, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x73, 0x12, 0x3a, 0x0a, 0x0e, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x5f, - 0x6e, 0x6f, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, - 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x0d, - 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x4e, 0x6f, 0x74, 0x12, 0x2c, 0x0a, - 0x12, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x61, 0x67, 0x73, 0x5f, - 0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x74, 0x61, 0x6c, 0x6b, 0x67, - 0x72, 0x6f, 0x75, 0x70, 0x54, 0x61, 0x67, 0x73, 0x41, 0x6c, 0x6c, 0x12, 0x2c, 0x0a, 0x12, 0x74, - 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x61, 0x67, 0x73, 0x5f, 0x61, 0x6e, - 0x79, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, - 0x75, 0x70, 0x54, 0x61, 0x67, 0x73, 0x41, 0x6e, 0x79, 0x12, 0x2c, 0x0a, 0x12, 0x74, 0x61, 0x6c, - 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x61, 0x67, 0x73, 0x5f, 0x6e, 0x6f, 0x74, 0x18, - 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x54, 0x61, 0x67, 0x73, 0x4e, 0x6f, 0x74, 0x22, 0x08, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x22, 0x92, 0x01, 0x0a, 0x0a, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, - 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4e, 0x61, 0x6d, - 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x62, - 0x75, 0x69, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x62, 0x75, 0x69, 0x6c, - 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x17, 0x0a, - 0x07, 0x64, 0x62, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x64, 0x62, 0x53, 0x69, 0x7a, 0x65, 0x2a, 0x37, 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x0a, 0x4c, 0x53, 0x5f, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, - 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x4c, 0x53, 0x5f, 0x4c, 0x49, 0x56, 0x45, 0x10, 0x01, - 0x12, 0x0d, 0x0a, 0x09, 0x4c, 0x53, 0x5f, 0x50, 0x41, 0x55, 0x53, 0x45, 0x44, 0x10, 0x02, 0x42, - 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x02, 0x74, 0x67, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x79, + 0x73, 0x74, 0x65, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x88, 0x01, 0x01, 0x12, + 0x20, 0x0a, 0x09, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x02, 0x52, 0x08, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x54, 0x61, 0x67, 0x88, 0x01, + 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x05, 0x48, 0x03, 0x52, 0x09, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, + 0x79, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x38, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, + 0x75, 0x63, 0x74, 0x48, 0x04, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x88, + 0x01, 0x01, 0x12, 0x18, 0x0a, 0x07, 0x6c, 0x65, 0x61, 0x72, 0x6e, 0x65, 0x64, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x07, 0x6c, 0x65, 0x61, 0x72, 0x6e, 0x65, 0x64, 0x42, 0x07, 0x0a, 0x05, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x42, + 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x5f, 0x74, 0x61, 0x67, 0x42, 0x0c, 0x0a, + 0x0a, 0x5f, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x0b, 0x0a, 0x09, 0x5f, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x7a, 0x0a, 0x04, 0x4c, 0x69, 0x76, 0x65, + 0x12, 0x2e, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x53, + 0x74, 0x61, 0x74, 0x65, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, + 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x10, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x46, 0x69, 0x6c, 0x74, + 0x65, 0x72, 0x48, 0x01, 0x52, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, + 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x69, + 0x6c, 0x74, 0x65, 0x72, 0x22, 0x41, 0x0a, 0x09, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x61, 0x6c, + 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x61, + 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x22, 0x83, 0x02, 0x0a, 0x06, 0x46, 0x69, 0x6c, 0x74, + 0x65, 0x72, 0x12, 0x33, 0x0a, 0x0a, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, + 0x78, 0x2e, 0x54, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x0a, 0x74, 0x61, 0x6c, + 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x3a, 0x0a, 0x0e, 0x74, 0x61, 0x6c, 0x6b, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x73, 0x5f, 0x6e, 0x6f, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x13, 0x2e, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x62, 0x6f, 0x78, 0x2e, 0x54, 0x61, 0x6c, 0x6b, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x52, 0x0d, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x4e, 0x6f, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x5f, 0x74, 0x61, 0x67, 0x73, 0x5f, 0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x10, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x54, 0x61, 0x67, 0x73, 0x41, 0x6c, + 0x6c, 0x12, 0x2c, 0x0a, 0x12, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, + 0x61, 0x67, 0x73, 0x5f, 0x61, 0x6e, 0x79, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x74, + 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x54, 0x61, 0x67, 0x73, 0x41, 0x6e, 0x79, 0x12, + 0x2c, 0x0a, 0x12, 0x74, 0x61, 0x6c, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x74, 0x61, 0x67, + 0x73, 0x5f, 0x6e, 0x6f, 0x74, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x74, 0x61, 0x6c, + 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x54, 0x61, 0x67, 0x73, 0x4e, 0x6f, 0x74, 0x22, 0x08, 0x0a, + 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x92, 0x01, 0x0a, 0x0a, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, + 0x6f, 0x72, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, + 0x6f, 0x72, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x62, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x62, 0x53, 0x69, 0x7a, 0x65, 0x2a, 0x37, 0x0a, 0x09, + 0x4c, 0x69, 0x76, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x0a, 0x4c, 0x53, 0x5f, + 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x4c, 0x53, 0x5f, + 0x4c, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x4c, 0x53, 0x5f, 0x50, 0x41, 0x55, + 0x53, 0x45, 0x44, 0x10, 0x02, 0x42, 0x06, 0x5a, 0x04, 0x2e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/pb/stillbox.proto b/pkg/pb/stillbox.proto index 815c9b3..01751bd 100644 --- a/pkg/pb/stillbox.proto +++ b/pkg/pb/stillbox.proto @@ -34,9 +34,8 @@ message Call { int64 frequency = 8; repeated int64 frequencies = 9; repeated int32 patches = 10; - repeated int32 sources = 11; - optional int32 duration = 12; - bytes audio = 13; + optional int32 duration = 11; + bytes audio = 12; } message Hello { diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 2532ef4..50bed21 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -3,12 +3,15 @@ package rest import ( "errors" "net/http" + "net/url" + "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/go-viper/mapstructure/v2" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/rs/zerolog/log" ) @@ -18,10 +21,11 @@ type API interface { } type api struct { + baseURL url.URL } -func New() *api { - s := new(api) +func New(baseURL url.URL) *api { + s := &api{baseURL} return s } @@ -32,6 +36,7 @@ func (a *api) Subrouter() http.Handler { r.Mount("/talkgroup", new(talkgroupAPI).Subrouter()) r.Mount("/call", new(callsAPI).Subrouter()) r.Mount("/user", new(usersAPI).Subrouter()) + r.Mount("/incident", newIncidentsAPI(&a.baseURL).Subrouter()) return r } @@ -124,6 +129,7 @@ var statusMapping = map[error]errResponder{ tgstore.ErrReference: constraintErrText, ErrBadUID: unauthErrText, ErrBadAppName: unauthErrText, + common.ErrPageOutOfRange: badRequestErrText, } func autoError(err error) render.Renderer { @@ -173,6 +179,21 @@ func decodeParams(d interface{}, r *http.Request) error { return dec.Decode(m) } +// idOnlyParam checks for a sole URL parameter, id, and writes an errorif this fails. +func idOnlyParam(w http.ResponseWriter, r *http.Request) (uuid.UUID, error) { + params := struct { + ID uuid.UUID `param:"id"` + }{} + + err := decodeParams(¶ms, r) + if err != nil { + wErr(w, r, badRequest(err)) + return uuid.UUID{}, err + } + + return params.ID, nil +} + func respond(w http.ResponseWriter, r *http.Request, v interface{}) { render.DefaultResponder(w, r, v) } diff --git a/pkg/rest/incidents.go b/pkg/rest/incidents.go new file mode 100644 index 0000000..b586345 --- /dev/null +++ b/pkg/rest/incidents.go @@ -0,0 +1,237 @@ +package rest + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "dynatron.me/x/stillbox/internal/common" + "dynatron.me/x/stillbox/internal/forms" + "dynatron.me/x/stillbox/internal/jsontypes" + "dynatron.me/x/stillbox/pkg/incidents" + "dynatron.me/x/stillbox/pkg/incidents/incstore" + "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type incidentsAPI struct { + baseURL *url.URL +} + +func newIncidentsAPI(baseURL *url.URL) API { + return &incidentsAPI{baseURL} +} + +func (ia *incidentsAPI) Subrouter() http.Handler { + r := chi.NewMux() + + r.Get(`/{id:[a-f0-9-]+}`, ia.getIncident) + r.Get(`/{id:[a-f0-9-]+}.m3u`, ia.getCallsM3U) + + r.Post(`/new`, ia.createIncident) + r.Post(`/`, ia.listIncidents) + r.Post(`/{id:[a-f0-9-]+}/calls`, ia.postCalls) + + r.Patch(`/{id:[a-f0-9-]+}`, ia.updateIncident) + + r.Delete(`/{id:[a-f0-9-]+}`, ia.deleteIncident) + + return r +} + +func (ia *incidentsAPI) listIncidents(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + + p := incstore.IncidentsParams{} + err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + res := struct { + Incidents []incstore.Incident `json:"incidents"` + Count int `json:"count"` + }{} + + res.Incidents, res.Count, err = incs.Incidents(ctx, p) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + respond(w, r, res) +} + +func (ia *incidentsAPI) createIncident(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + + p := incidents.Incident{} + err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + inc, err := incs.CreateIncident(ctx, p) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + respond(w, r, inc) +} + +func (ia *incidentsAPI) getIncident(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + + id, err := idOnlyParam(w, r) + if err != nil { + return + } + + inc, err := incs.Incident(ctx, id) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + respond(w, r, inc) +} + +func (ia *incidentsAPI) updateIncident(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + + id, err := idOnlyParam(w, r) + if err != nil { + return + } + + p := incstore.UpdateIncidentParams{} + err = forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + inc, err := incs.UpdateIncident(ctx, id, p) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + respond(w, r, inc) +} + +func (ia *incidentsAPI) deleteIncident(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + + urlParams := struct { + ID uuid.UUID `param:"id"` + }{} + + err := decodeParams(&urlParams, r) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + err = incs.DeleteIncident(ctx, urlParams.ID) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type CallIncidentParams struct { + Add jsontypes.UUIDs `json:"add"` + Notes json.RawMessage `json:"notes"` + + Remove jsontypes.UUIDs `json:"remove"` +} + +func (ia *incidentsAPI) postCalls(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + + id, err := idOnlyParam(w, r) + if err != nil { + return + } + + p := CallIncidentParams{} + err = forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + err = incs.AddRemoveIncidentCalls(ctx, id, p.Add.UUIDs(), p.Notes, p.Remove.UUIDs()) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (ia *incidentsAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + tgst := tgstore.FromCtx(ctx) + + id, err := idOnlyParam(w, r) + if err != nil { + return + } + + inc, err := incs.Incident(ctx, id) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + var b bytes.Buffer + + callUrl := common.PtrTo(*ia.baseURL) + + b.WriteString("#EXTM3U\n\n") + for _, c := range inc.Calls { + tg, err := tgst.TG(ctx, c.TalkgroupTuple()) + if err != nil { + wErr(w, r, autoError(err)) + return + } + var from string + if c.Source != 0 { + from = fmt.Sprintf(" from %d", c.Source) + } + + callUrl.Path = "/api/call/" + c.ID.String() + + fmt.Fprintf(w, "#EXTINF:%d,%s%s (%s)\n%s\n\n", + c.Duration.Seconds(), + tg.StringTag(true), + from, + c.DateTime.Format("15:04 01/02"), + callUrl, + ) + } + + // Not a lot of agreement on which MIME type to use for non-HLS m3u, + // let's hope this is good enough + w.Header().Set("Content-Type", "audio/x-mpegurl") + w.WriteHeader(http.StatusOK) + _, _ = b.WriteTo(w) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index a6e7675..0ea300d 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -8,9 +8,11 @@ import ( "dynatron.me/x/stillbox/pkg/alerting" "dynatron.me/x/stillbox/pkg/auth" + "dynatron.me/x/stillbox/pkg/calls/callstore" "dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database/partman" + "dynatron.me/x/stillbox/pkg/incidents/incstore" "dynatron.me/x/stillbox/pkg/nexus" "dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/rest" @@ -28,22 +30,24 @@ import ( const shutdownTimeout = 5 * time.Second type Server struct { - auth *auth.Auth - conf *config.Configuration - db database.Store - r *chi.Mux - sources sources.Sources - sinks sinks.Sinks - relayer *sinks.RelayManager - nex *nexus.Nexus - logger *Logger - alerter alerting.Alerter - notifier notify.Notifier - hup chan os.Signal - tgs tgstore.Store - rest rest.API - partman partman.PartitionManager - users users.Store + auth *auth.Auth + conf *config.Configuration + db database.Store + r *chi.Mux + sources sources.Sources + sinks sinks.Sinks + relayer *sinks.RelayManager + nex *nexus.Nexus + logger *Logger + alerter alerting.Alerter + notifier notify.Notifier + hup chan os.Signal + tgs tgstore.Store + rest rest.API + partman partman.PartitionManager + users users.Store + calls callstore.Store + incidents incstore.Store } func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { @@ -67,21 +71,23 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { } tgCache := tgstore.NewCache() - api := rest.New() + api := rest.New(cfg.BaseURL.URL()) srv := &Server{ - auth: authenticator, - conf: cfg, - db: db, - r: r, - nex: nexus.New(), - logger: logger, - alerter: alerting.New(cfg.Alerting, tgCache, alerting.WithNotifier(notifier)), - notifier: notifier, - tgs: tgCache, - sinks: sinks.NewSinkManager(), - rest: api, - users: users.NewStore(), + auth: authenticator, + conf: cfg, + db: db, + r: r, + nex: nexus.New(), + logger: logger, + alerter: alerting.New(cfg.Alerting, tgCache, alerting.WithNotifier(notifier)), + notifier: notifier, + tgs: tgCache, + sinks: sinks.NewSinkManager(), + rest: api, + users: users.NewStore(), + calls: callstore.NewStore(), + incidents: incstore.NewStore(), } if cfg.DB.Partition.Enabled { @@ -129,14 +135,22 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { return srv, nil } +func (s *Server) addStoresTo(ctx context.Context) context.Context { + ctx = database.CtxWithDB(ctx, s.db) + ctx = tgstore.CtxWithStore(ctx, s.tgs) + ctx = users.CtxWithStore(ctx, s.users) + ctx = callstore.CtxWithStore(ctx, s.calls) + ctx = incstore.CtxWithStore(ctx, s.incidents) + + return ctx +} + func (s *Server) Go(ctx context.Context) error { defer database.Close(s.db) s.installHupHandler() - ctx = database.CtxWithDB(ctx, s.db) - ctx = tgstore.CtxWithStore(ctx, s.tgs) - ctx = users.CtxWithStore(ctx, s.users) + ctx = s.addStoresTo(ctx) httpSrv := &http.Server{ Addr: s.conf.Listen, diff --git a/pkg/sinks/relay.go b/pkg/sinks/relay.go index a481764..1c75db1 100644 --- a/pkg/sinks/relay.go +++ b/pkg/sinks/relay.go @@ -81,7 +81,7 @@ func (s *Relay) Call(ctx context.Context, call *calls.Call) error { var buf bytes.Buffer body := multipart.NewWriter(&buf) - err := forms.Marshal(call, body) + err := forms.Marshal(call, body, forms.WithTag("json")) if err != nil { return fmt.Errorf("relay form parse: %w", err) } diff --git a/pkg/sources/http.go b/pkg/sources/http.go index d9fe4e0..1c18b39 100644 --- a/pkg/sources/http.go +++ b/pkg/sources/http.go @@ -46,7 +46,6 @@ type CallUploadRequest struct { Key string `form:"key"` Patches []int `form:"patches"` Source int `form:"source"` - Sources []int `form:"sources"` System int `form:"system"` SystemLabel string `form:"systemLabel"` Talkgroup int `form:"talkgroup"` diff --git a/pkg/store/store.go b/pkg/store/store.go new file mode 100644 index 0000000..bb85c85 --- /dev/null +++ b/pkg/store/store.go @@ -0,0 +1,50 @@ +package store + +import ( + "context" + + "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" + "dynatron.me/x/stillbox/pkg/users" +) + +type Store interface { + TG() tgstore.Store + User() users.Store +} + +type store struct { + tg tgstore.Store + user users.Store +} + +func (s *store) TG() tgstore.Store { + return s.tg +} + +func (s *store) User() users.Store { + return s.user +} + +func New() Store { + return &store{ + tg: tgstore.NewCache(), + user: users.NewStore(), + } +} + +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 +} diff --git a/sql/postgres/migrations/001_initial.up.sql b/sql/postgres/migrations/001_initial.up.sql index c766ae0..0a8e2a8 100644 --- a/sql/postgres/migrations/001_initial.up.sql +++ b/sql/postgres/migrations/001_initial.up.sql @@ -104,7 +104,7 @@ CREATE TABLE IF NOT EXISTS calls( ) PARTITION BY RANGE (call_date); CREATE INDEX IF NOT EXISTS calls_transcript_idx ON calls USING GIN (to_tsvector('english', transcript)); -CREATE INDEX IF NOT EXISTS calls_call_date_tg_idx ON calls(system, talkgroup, call_date); +CREATE INDEX IF NOT EXISTS calls_call_date_tg_idx ON calls(call_date, talkgroup, system); CREATE TABLE swept_calls ( id UUID PRIMARY KEY, @@ -142,8 +142,8 @@ CREATE TABLE IF NOT EXISTS incidents( id UUID PRIMARY KEY, name TEXT NOT NULL, description TEXT, - start_time TIMESTAMP, - end_time TIMESTAMP, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, location JSONB, metadata JSONB ); diff --git a/sql/postgres/queries/calls.sql b/sql/postgres/queries/calls.sql index 139d0ff..353d61c 100644 --- a/sql/postgres/queries/calls.sql +++ b/sql/postgres/queries/calls.sql @@ -38,7 +38,22 @@ source ); -- name: GetCallAudioByID :one -SELECT call_date, audio_name, audio_type, audio_blob FROM calls WHERE id = @id; +SELECT + c.call_date, + c.audio_name, + c.audio_type, + c.audio_blob +FROM calls c +WHERE c.id = @id +UNION +SELECT + sc.call_date, + sc.audio_name, + sc.audio_type, + sc.audio_blob +FROM swept_calls sc +WHERE sc.id = @id +; -- name: SetCallTranscript :exec UPDATE calls SET transcript = $2 WHERE id = $1; @@ -61,6 +76,7 @@ VALUES SELECT pg_size_pretty(pg_database_size(current_database())); -- name: SweepCalls :execrows +-- This is used to sweep calls that are part of an incident prior to pruning a partition. WITH to_sweep AS ( SELECT id, submitter, system, talkgroup, calls.call_date, audio_name, audio_blob, duration, audio_type, audio_url, frequency, frequencies, patches, tg_label, tg_alpha_tag, tg_group, source, transcript @@ -70,7 +86,6 @@ 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 diff --git a/sql/postgres/queries/incidents.sql b/sql/postgres/queries/incidents.sql new file mode 100644 index 0000000..16dde5c --- /dev/null +++ b/sql/postgres/queries/incidents.sql @@ -0,0 +1,157 @@ +-- name: AddToIncident :exec +WITH inp AS ( +SELECT + UNNEST(@call_ids::UUID[]) id, + UNNEST(@notes::JSONB[]) notes +) INSERT INTO incidents_calls( + incident_id, + call_id, + calls_tbl_id, + call_date, + notes +) +SELECT + @incident_id::UUID, + inp.id, + inp.id, + c.call_date, + inp.notes +FROM inp +JOIN calls c ON c.id = inp.id +; + +-- name: RemoveFromIncident :exec +DELETE FROM incidents_calls ic +WHERE ic.incident_id = @id AND ic.call_id = ANY(@call_ids::UUID[]); + +-- name: UpdateCallIncidentNotes :exec +UPDATE incidents_Calls +SET notes = @notes +WHERE incident_id = @incident_id AND call_id = @call_id; + +-- name: CreateIncident :one +INSERT INTO incidents ( + id, + name, + description, + start_time, + end_time, + location, + metadata +) VALUES ( + @id, + @name, + sqlc.narg('description'), + sqlc.narg('start_time'), + sqlc.narg('end_time'), + sqlc.narg('location'), + sqlc.narg('metadata') +) +RETURNING *; + + +-- name: ListIncidentsP :many +SELECT + i.id, + i.name, + i.description, + i.start_time, + i.end_time, + i.location, + i.metadata, + COUNT(ic.incident_id) calls_count +FROM incidents i +LEFT JOIN incidents_calls ic ON i.id = ic.incident_id +WHERE +CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN + i.start_time >= sqlc.narg('start') ELSE TRUE END AND +CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN + i.start_time <= sqlc.narg('end') ELSE TRUE END AND +(CASE WHEN sqlc.narg('filter')::TEXT IS NOT NULL THEN ( + i.name ILIKE '%' || @filter || '%' OR + i.description ILIKE '%' || @filter || '%' + ) ELSE TRUE END) +GROUP BY i.id +ORDER BY +CASE WHEN @direction::TEXT = 'asc' THEN i.start_time END ASC, +CASE WHEN @direction::TEXT = 'desc' THEN i.start_time END DESC +OFFSET sqlc.arg('offset') ROWS +FETCH NEXT sqlc.arg('per_page') ROWS ONLY +; + +-- name: ListIncidentsCount :one +SELECT COUNT(*) +FROM incidents i +WHERE +CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN + i.start_time >= sqlc.narg('start') ELSE TRUE END AND +CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN + i.start_time <= sqlc.narg('end') ELSE TRUE END AND +(CASE WHEN sqlc.narg('filter')::TEXT IS NOT NULL THEN ( + i.name ILIKE '%' || @filter || '%' OR + i.description ILIKE '%' || @filter || '%' + ) ELSE TRUE END) +; + +-- name: GetIncidentCalls :many +SELECT ic.call_id, ic.call_date, ic.notes, c.* +FROM incidents_calls ic, LATERAL ( + SELECT + ca.submitter, + ca.system, + ca.talkgroup, + ca.audio_name, + ca.duration, + ca.audio_type, + ca.audio_url, + ca.frequency, + ca.frequencies, + ca.patches, + ca.source, + ca.transcript + FROM calls ca WHERE ca.id = ic.calls_tbl_id AND ca.call_date = ic.call_date + UNION + SELECT + sc.submitter, + sc.system, + sc.talkgroup, + sc.audio_name, + sc.duration, + sc.audio_type, + sc.audio_url, + sc.frequency, + sc.frequencies, + sc.patches, + sc.source, + sc.transcript + FROM swept_calls sc WHERE sc.id = ic.swept_call_id +) c +WHERE ic.incident_id = @id; + +-- name: GetIncident :one +SELECT + i.id, + i.name, + i.description, + i.start_time, + i.end_time, + i.location, + i.metadata +FROM incidents i +WHERE i.id = @id; + +-- name: UpdateIncident :one +UPDATE incidents +SET + name = COALESCE(sqlc.narg('name'), name), + description = COALESCE(sqlc.narg('description'), description), + start_time = COALESCE(sqlc.narg('start_time'), start_time), + end_time = COALESCE(sqlc.narg('end_time'), end_time), + location = COALESCE(sqlc.narg('location'), location), + metadata = COALESCE(sqlc.narg('metadata'), metadata) +WHERE + id = @id +RETURNING *; + +-- name: DeleteIncident :exec +DELETE FROM incidents CASCADE WHERE id = @id; diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 0b51630..a5c0059 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -37,6 +37,11 @@ sql: import: "dynatron.me/x/stillbox/internal/jsontypes" type: "Metadata" nullable: true + - column: "incidents.metadata" + go_type: + import: "dynatron.me/x/stillbox/internal/jsontypes" + type: "Metadata" + nullable: true - column: "pg_catalog.pg_tables.tablename" go_type: string nullable: false