incidents UI initial #95

Merged
amigan merged 29 commits from incidentsUi into trunk 2024-12-30 16:55:02 -05:00
46 changed files with 2847 additions and 223 deletions

View file

@ -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 <dan AT dynatron DOT me>
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)

View file

@ -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 = <PageEvent>{ 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() {

View file

@ -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;
}

View file

@ -0,0 +1 @@
<p>incident works!</p>

View file

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IncidentComponent } from './incident.component';
describe('IncidentComponent', () => {
let component: IncidentComponent;
let fixture: ComponentFixture<IncidentComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IncidentComponent],
}).compileComponents();
fixture = TestBed.createComponent(IncidentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -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 {}

View file

@ -1 +1,126 @@
<p>incidents works!</p>
<div [ngClass]="{ hidden: (tcSvc.filterPanel | async) }" class="toolbar">
<form [formGroup]="form">
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
<mat-label>Start</mat-label>
<input
matInput
type="datetime-local"
name="start"
placeholder="Start date"
formControlName="start"
/>
</mat-form-field>
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
<mat-label>End</mat-label>
<input
matInput
type="datetime-local"
name="end"
placeholder="End date"
formControlName="end"
/>
</mat-form-field>
<mat-form-field class="filterBox" subscriptSizing="dynamic">
<mat-label>Filter</mat-label>
<input
matInput
name="filter"
type="text"
autocomplete="off"
formControlName="filter"
/>
<button
class="clearBtn"
*ngIf="form.controls['filter'].value"
matSuffix
mat-icon-button
(click)="form.controls['filter'].setValue('')"
>
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<div class="toolbarButtons">
<button class="sbButton" (click)="resetFilter()">
<mat-icon class="material-symbols-outlined">reset_settings</mat-icon>
</button>
<button class="sbButton" (click)="refresh()">
<mat-icon>refresh</mat-icon>
</button>
</div>
</form>
</div>
<div class="tabContainer" *ngIf="!isLoading; else spinner">
<table class="incsTable" mat-table [dataSource]="incsResult">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
>
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="startTime">
<th mat-header-cell *matHeaderCellDef>Start</th>
<td mat-cell *matCellDef="let incident">
{{ incident.startTime | fmtDate }}
</td>
</ng-container>
<ng-container matColumnDef="endTime">
<th mat-header-cell *matHeaderCellDef>End</th>
<td [title]="incident.incident_date" mat-cell *matCellDef="let incident">
{{ incident.endTime | fmtDate }}
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let incident">
{{ incident.name }}
</td>
</ng-container>
<ng-container matColumnDef="numCalls">
<th mat-header-cell *matHeaderCellDef>Calls</th>
<td mat-cell *matCellDef="let incident" class="callCount">
{{ incident.callCount }}
</td>
</ng-container>
<ng-container matColumnDef="edit">
<th mat-header-cell *matHeaderCellDef>Edit</th>
<td mat-cell *matCellDef="let incident">
<a routerLink="/incidents/{{ incident.id }}"
><mat-icon>edit</mat-icon>
</a>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
</table>
</div>
<div class="pagFoot">
<mat-paginator
#paginator
class="paginator"
(page)="setPage($event)"
[length]="count"
showFirstLastButtons="true"
[pageSize]="curPage.pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="curPage.pageIndex"
aria-label="Select page"
>
</mat-paginator>
</div>
<ng-template #spinner>
<div class="spinner">
<mat-spinner></mat-spinner>
</div>
</ng-template>

View file

@ -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;
}

View file

@ -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<IncidentRecord>(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 = <PageEvent>{ pageIndex: 0, pageSize: 0 };
currentSet!: IncidentRecord[];
currentServerPage = 0; // page is never 0, forces load
isLoading = true;
selection = new SelectionModel<IncidentRecord>(true, []);
form = new FormGroup({
start: new FormControl(null),
end: new FormControl(null),
filter: new FormControl(''),
});
subscriptions = new Subscription();
pageWindow = 0;
fetchIncidents = new BehaviorSubject<IncidentsListParams>(
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 <PageEvent>{
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(<PageEvent>{
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();
}
}

View file

@ -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();
});
});

View file

@ -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<IncidentsPaginated> {
return this.http.post<IncidentsPaginated>('/api/incident/', p);
}
createIncident(inp: IncidentRecord): Observable<IncidentRecord> {
return this.http.post<IncidentRecord>('/api/incident/new', inp);
}
addRemoveCalls(id: string, inp: CallIncidentParams): Observable<void> {
return this.http.post<void>('/api/incident/' + id + '/calls', inp);
}
deleteIncident(id: string): Observable<void> {
return this.http.delete<void>('/api/incident/' + id);
}
updateIncident(id: string, inp: IncidentRecord): Observable<IncidentRecord> {
return this.http.patch<IncidentRecord>('/api/incident/' + id, inp);
}
}

View file

@ -233,5 +233,6 @@ input {
.spinner {
display: flex;
margin-top: 40px;
margin-bottom: 40px;
justify-content: center;
}

View file

@ -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:

View file

@ -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"`

View file

@ -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)

22
internal/forms/testdata/uuid1.http vendored Normal file
View file

@ -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

View file

@ -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) {

View file

@ -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 {

View file

@ -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}

View file

@ -0,0 +1,9 @@
package jsontypes
import (
"encoding/json"
)
type Location struct {
json.RawMessage
}

57
internal/jsontypes/url.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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,
}

View file

@ -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{})

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -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)

View file

@ -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 {

View file

@ -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

25
pkg/incidents/incident.go Normal file
View file

@ -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"`
}

View file

@ -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)
}

View file

@ -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 (

View file

@ -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 {

View file

@ -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(&params, 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)
}

237
pkg/rest/incidents.go Normal file
View file

@ -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)
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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"`

50
pkg/store/store.go Normal file
View file

@ -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
}

View file

@ -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
);

View file

@ -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

View file

@ -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;

View file

@ -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