Merge pull request 'Complete incidents functionality' (#97) from incidentsEditRecord into trunk

Reviewed-on: #97
This commit is contained in:
amigan 2025-01-05 22:11:46 -05:00
commit a5fc6825b1
42 changed files with 1099 additions and 223 deletions

View file

@ -14,8 +14,13 @@ buildpprof:
client/stillbox/dist:
cd client/stillbox && npm install && ng build -c production
web:
cd client/stillbox && npm install && ng build -c production
web: web-install web-build
web-build:
cd client/stillbox && ng build -c production
web-install:
cd client/stillbox && npm install
clean:
rm -rf client/calls/ client/stillbox/dist/ client/stillbox/node_modules/

View file

@ -61,6 +61,10 @@
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"index": {
"input": "src/index.dev_nocsp.html",
"output": "index.html"
},
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
@ -73,6 +77,9 @@
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "src/proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "stillbox:build:production"

View file

@ -43,14 +43,6 @@ export const routes: Routes = [
),
data: { title: 'Export Talkgroups' },
},
{
path: 'talkgroups/:sys/:tg',
loadComponent: () =>
import(
'./talkgroups/talkgroup-record/talkgroup-record.component'
).then((m) => m.TalkgroupRecordComponent),
data: { title: 'Edit Talkgroup' },
},
{
path: 'calls',
loadComponent: () =>
@ -65,6 +57,14 @@ export const routes: Routes = [
),
data: { title: 'Incidents' },
},
{
path: 'incidents/:id',
loadComponent: () =>
import('./incidents/incident/incident.component').then(
(m) => m.IncidentComponent,
),
data: { title: 'View Incident' },
},
{
path: 'alerts',
loadComponent: () =>

View file

@ -83,6 +83,20 @@
<button class="sbButton" (click)="refresh()">
<mat-icon>refresh</mat-icon>
</button>
<button
[ngClass]="{ sbButton: true, hidden: !selection.hasValue() }"
[matMenuTriggerFor]="callMenu"
>
<mat-icon>playlist_add</mat-icon>
</button>
<mat-menu #callMenu="matMenu">
<button mat-menu-item (click)="addToNewInc($event)">
Add to new incident
</button>
<button mat-menu-item (click)="addToExistingInc($event)">
Add to existing incident
</button>
</mat-menu>
</div>
</form>
</div>
@ -109,9 +123,7 @@
<ng-container matColumnDef="play">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let call">
<a class="playButton" (click)="playAudio($event, call)"
><mat-icon>play_arrow</mat-icon></a
>
<call-player [call]="call"></call-player>
</td>
</ng-container>
<ng-container matColumnDef="download">

View file

@ -1,4 +1,10 @@
import { Component, Pipe, PipeTransform, ViewChild } from '@angular/core';
import {
Component,
inject,
Pipe,
PipeTransform,
ViewChild,
} from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
@ -29,6 +35,19 @@ import { MatInputModule } from '@angular/material/input';
import { debounceTime } from 'rxjs/operators';
import { ToolbarContextService } from '../navigation/toolbar-context.service';
import { MatSelectModule } from '@angular/material/select';
import { CallPlayerComponent } from './player/call-player/call-player.component';
import { MatMenuModule } from '@angular/material/menu';
import { MatDialog } from '@angular/material/dialog';
import {
EditDialogData,
IncidentEditDialogComponent,
} from '../incidents/incident/incident.component';
import {
CallIncidentParams,
IncidentsService,
} from '../incidents/incidents.service';
import { IncidentRecord } from '../incidents';
import { SelectIncidentDialogComponent } from '../incidents/select-incident-dialog/select-incident-dialog.component';
@Pipe({
name: 'grabDate',
@ -139,6 +158,8 @@ const reqPageSize = 200;
CommonModule,
MatProgressSpinnerModule,
MatSelectModule,
CallPlayerComponent,
MatMenuModule,
],
templateUrl: './calls.component.html',
styleUrl: './calls.component.scss',
@ -147,6 +168,7 @@ export class CallsComponent {
callsResult = new BehaviorSubject(new Array<CallRecord>(0));
@ViewChild('paginator') paginator!: MatPaginator;
count = 0;
dialog = inject(MatDialog);
page = 0;
perPage = 25;
pageSizeOptions = [25, 50, 75, 100, 200];
@ -189,6 +211,7 @@ export class CallsComponent {
private prefsSvc: PrefsService,
public tcSvc: ToolbarContextService,
public tgSvc: TalkgroupService,
public incSvc: IncidentsService,
) {
this.tcSvc.showFilterButton();
}
@ -243,13 +266,6 @@ export class CallsComponent {
return now.toISOString().slice(0, 16);
}
playAudio(ev: Event, call: CallRecord) {
let au = new Audio();
au.src = this.callsSvc.callAudioURL(call.id);
au.load();
au.play();
}
setPage(p: PageEvent, force?: boolean) {
this.selection.clear();
this.curPage = p;
@ -343,4 +359,48 @@ export class CallsComponent {
this.form.controls['start'].setValue(this.lTime(new Date()));
this.form.controls['duration'].setValue(0);
}
addToNewInc(ev: Event) {
const dialogRef = this.dialog.open(IncidentEditDialogComponent, {
data: <EditDialogData>{
incID: '',
new: true,
},
});
dialogRef.afterClosed().subscribe((res: IncidentRecord) => {
this.incSvc
.addRemoveCalls(res.id, <CallIncidentParams>{
add: this.selection.selected.map((s) => s.id),
})
.subscribe({
next: () => {
this.selection.clear();
},
error: (err) => {
alert(err);
},
});
});
}
addToExistingInc(ev: Event) {
const dialogRef = this.dialog.open(SelectIncidentDialogComponent);
dialogRef.afterClosed().subscribe((res: string) => {
if (!res) {
return;
}
this.incSvc
.addRemoveCalls(res, <CallIncidentParams>{
add: this.selection.selected.map((s) => s.id),
})
.subscribe({
next: () => {
this.selection.clear();
},
error: (err) => {
alert(err);
},
});
});
}
}

View file

@ -0,0 +1,9 @@
@if (playing) {
<a class="playButton" (click)="stopAudio($event)"
><mat-icon>stop</mat-icon></a
>
} @else {
<a class="playButton" (click)="playAudio($event)"
><mat-icon>play_arrow</mat-icon></a
>
}

View file

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

View file

@ -0,0 +1,40 @@
import { Component, Input } from '@angular/core';
import { CallsService } from '../../calls.service';
import { CallRecord } from '../../../calls';
import { MatIconModule } from '@angular/material/icon';
import { fromEvent, Observable, Subscription } from 'rxjs';
@Component({
selector: 'call-player',
imports: [MatIconModule],
templateUrl: './call-player.component.html',
styleUrl: './call-player.component.scss',
})
export class CallPlayerComponent {
@Input() call!: CallRecord;
playing = false;
playSub!: Subscription;
au!: HTMLAudioElement;
constructor(private callsSvc: CallsService) {}
stopAudio(ev: Event) {
this.au.pause();
this.playing = false;
}
playAudio(ev: Event) {
this.au = new Audio();
this.playSub = fromEvent(this.au, 'ended').subscribe((ev) => {
this.playing = false;
this.playSub.unsubscribe();
});
this.playing = true;
this.au.src = this.callsSvc.callAudioURL(this.call.id);
this.au.load();
this.au.play().then(null, (reason) => {
this.playing = false;
alert(reason);
});
}
}

View file

@ -0,0 +1,56 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content>
<div class="incRecord">
@let inc = inc$ | async;
@if (inc) {
<form id="incForm" [formGroup]="form" (ngSubmit)="save()">
<div>
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput name="name" type="text" formControlName="name" />
</mat-form-field>
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
<mat-label>Start</mat-label>
<input
matInput
type="datetime-local"
name="start"
placeholder="Start time"
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 time"
formControlName="end"
/>
</mat-form-field>
<mat-form-field subscriptSizing="dynamic" class="descBox">
<mat-label>Description</mat-label>
<textarea
matInput
name="description"
placeholder="Description"
formControlName="description"
rows="8"
cols="200"
>{{ inc.description }}</textarea
>
</mat-form-field>
</div>
</form>
} @else {
<div class="spinner">
<mat-spinner></mat-spinner>
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button type="button" (click)="cancel()">Cancel</button>
<button mat-button form="incForm" type="submit" cdkFocusInitial>Save</button>
</mat-dialog-actions>

View file

@ -1 +1,98 @@
<p>incident works!</p>
@let inc = inc$ | async;
<mat-card class="incident" appearance="outlined">
<div class="cardHdr">
<h1>{{ inc?.name }}</h1>
<button mat-icon-button (click)="editIncident(incID)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button [matMenuTriggerFor]="moreMenu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #moreMenu="matMenu">
<button class="deleteItem" mat-menu-item (click)="deleteIncident(incID)">
Delete
</button>
</mat-menu>
</div>
<div class="inc-heading">
<div class="field field-start field-label">Start</div>
<div class="field field-data field-start">
{{ inc?.startTime | fmtDate }}
</div>
<div class="field field-end field-label">End</div>
<div class="field field-data field-end">{{ inc?.endTime | fmtDate }}</div>
</div>
<div class="inc-description">
{{ inc?.description }}
</div>
<table class="callsTable" mat-table [dataSource]="callsResult">
<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="play">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let call">
<call-player [call]="call"></call-player>
</td>
</ng-container>
<ng-container matColumnDef="download">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let call">
<a [href]="call | audioDownloadURL"><mat-icon>download</mat-icon></a>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let call">
{{ call.call_date | grabDate }}
</td>
</ng-container>
<ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time</th>
<td [title]="call.call_date" mat-cell *matCellDef="let call">
{{ call.call_date | time }}
</td>
</ng-container>
<ng-container matColumnDef="system">
<th mat-header-cell *matHeaderCellDef>System</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "system" | async }}
</td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Group</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "group" | async }}
</td>
</ng-container>
<ng-container matColumnDef="talkgroup">
<th mat-header-cell *matHeaderCellDef>Talkgroup</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "alpha" | async }}
</td>
</ng-container>
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef class="durationHdr">Duration</th>
<td mat-cell *matCellDef="let call" class="duration">
{{ call.duration | fixedPoint: 1000 : 2 }}s
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
</table>
</mat-card>

View file

@ -0,0 +1,64 @@
.incident {
margin: 50px 50px 50px 50px;
padding: 50px 50px 50px 50px;
display: flex;
flex-flow: column;
margin-left: auto;
margin-right: auto;
}
@media not screen and (max-width: 768px) {
.incident {
width: 75%;
}
}
.inc-heading {
display: flex;
margin-bottom: 20px;
}
.cardHdr {
display: flex;
flex-flow: row wrap;
margin-bottom: 24px;
}
.field {
flex: 1 1;
width: 5rem;
}
.field-label {
font-weight: bolder;
}
.field-label::after {
content: ":";
}
.cardHdr h1 {
flex: 1 1;
margin: 0;
}
.cardHdr a {
flex: 0 0;
justify-content: flex-end;
align-content: center;
cursor: pointer;
}
form mat-form-field {
width: 60rem;
flex: 0 0;
display: flex;
}
.incRecord {
display: flex;
flex-flow: column nowrap;
justify-content: center;
margin-top: 20px;
margin-left: auto;
margin-right: auto;
}

View file

@ -1,9 +1,231 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { tap } from 'rxjs/operators';
import { CommonModule, Location } from '@angular/common';
import { BehaviorSubject, merge, Subscription } from 'rxjs';
import { Observable } from 'rxjs';
import {
ReactiveFormsModule,
FormGroup,
FormControl,
FormsModule,
} from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { IncidentsService } from '../incidents.service';
import { IncidentCall, IncidentRecord } from '../../incidents';
import { MatCardModule } from '@angular/material/card';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatButtonModule } from '@angular/material/button';
import { SelectionModel } from '@angular/cdk/collections';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import {
FixedPointPipe,
TalkgroupPipe,
TimePipe,
DatePipe,
DownloadURLPipe,
} from '../../calls/calls.component';
import { CallPlayerComponent } from '../../calls/player/call-player/call-player.component';
import { FmtDatePipe } from '../incidents.component';
import { MatMenuModule } from '@angular/material/menu';
export interface EditDialogData {
incID: string;
new: boolean;
}
@Component({
selector: 'app-incident-editor',
imports: [
MatIconModule,
MatFormFieldModule,
ReactiveFormsModule,
FormsModule,
MatInputModule,
MatCheckboxModule,
CommonModule,
MatProgressSpinnerModule,
MatDialogTitle,
MatDialogActions,
MatDialogContent,
MatButtonModule,
],
templateUrl: './incident-editor-dialog.component.html',
styleUrl: './incident.component.scss',
})
export class IncidentEditDialogComponent {
dialogRef = inject(MatDialogRef<IncidentEditDialogComponent>);
data = inject<EditDialogData>(MAT_DIALOG_DATA);
title = this.data.new ? 'New Incident' : 'Edit Incident';
inc$!: Observable<IncidentRecord>;
form = new FormGroup({
name: new FormControl(''),
start: new FormControl(),
end: new FormControl(),
description: new FormControl(''),
});
constructor(private incSvc: IncidentsService) {}
ngOnInit() {
if (!this.data.new) {
this.inc$ = this.incSvc.getIncident(this.data.incID).pipe(
tap((inc) => {
this.form.patchValue(inc);
}),
);
} else {
this.inc$ = new BehaviorSubject(<IncidentRecord>{});
}
}
save() {
let resObs: Observable<IncidentRecord>;
let ir: IncidentRecord = <IncidentRecord>{
name: this.form.controls['name'].dirty
? this.form.controls['name'].value
: null,
startTime: this.form.controls['start'].dirty
? this.form.controls['start'].value
: null,
endTime: this.form.controls['end'].dirty
? this.form.controls['end'].value
: null,
description: this.form.controls['description'].dirty
? this.form.controls['description'].value
: null,
};
if (this.data.new) {
resObs = this.incSvc.createIncident(ir);
} else {
resObs = this.incSvc.updateIncident(this.data.incID, ir);
}
resObs.subscribe({
next: (ok) => {
this.dialogRef.close(ok);
},
error: (er) => {
alert(er);
},
});
}
cancel() {
this.dialogRef.close();
}
}
@Component({
selector: 'app-incident',
imports: [],
imports: [
CommonModule,
ReactiveFormsModule,
FormsModule,
MatInputModule,
MatFormFieldModule,
MatCheckboxModule,
MatIconModule,
MatCardModule,
FixedPointPipe,
TimePipe,
DatePipe,
TalkgroupPipe,
DownloadURLPipe,
CallPlayerComponent,
FmtDatePipe,
MatTableModule,
MatMenuModule,
],
templateUrl: './incident.component.html',
styleUrl: './incident.component.scss',
})
export class IncidentComponent {}
export class IncidentComponent {
incPrime = new BehaviorSubject<IncidentRecord>(<IncidentRecord>{});
inc$!: Observable<IncidentRecord>;
subscriptions: Subscription = new Subscription();
dialog = inject(MatDialog);
incID!: string;
columns = [
'select',
'play',
'download',
'date',
'time',
'system',
'group',
'talkgroup',
'duration',
];
callsResult = new MatTableDataSource<IncidentCall>();
selection = new SelectionModel<IncidentCall>(true, []);
constructor(
private route: ActivatedRoute,
private incSvc: IncidentsService,
private location: Location,
) {}
saveIncName(ev: Event) {}
ngOnInit() {
this.incID = this.route.snapshot.paramMap.get('id')!;
this.inc$ = merge(this.incSvc.getIncident(this.incID), this.incPrime).pipe(
tap((inc) => {
if (inc.calls) {
this.callsResult.data = inc.calls;
}
}),
);
}
editIncident(incID: string) {
const dialogRef = this.dialog.open(IncidentEditDialogComponent, {
data: <EditDialogData>{
incID: incID,
new: false,
},
});
dialogRef.afterClosed().subscribe(this.incPrime);
}
deleteIncident(incID: string) {
if (confirm('Are you sure you want to delete this incident?')) {
this.incSvc.deleteIncident(incID).subscribe({
next: () => {
this.location.back();
},
error: (err) => {
alert(err);
},
});
}
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.callsResult.data.length;
return numSelected === numRows;
}
masterToggle() {
this.isAllSelected()
? this.selection.clear()
: this.callsResult.data.forEach((row) => this.selection.select(row));
}
}

View file

@ -34,12 +34,17 @@ import { ToolbarContextService } from '../navigation/toolbar-context.service';
standalone: true,
pure: true,
})
export class DatePipe implements PipeTransform {
transform(ts: string, args?: any): string {
export class FmtDatePipe implements PipeTransform {
transform(ts: string | Date | null | undefined, args?: any): string {
if (!ts) {
return '\u2014';
}
const timestamp = new Date(ts);
let timestamp: Date;
if (ts instanceof Date) {
timestamp = ts;
} else {
timestamp = new Date(ts);
}
return (
timestamp.getMonth() +
1 +
@ -61,7 +66,7 @@ const reqPageSize = 200;
selector: 'app-incidents',
imports: [
MatIconModule,
DatePipe,
FmtDatePipe,
MatPaginatorModule,
MatTableModule,
AsyncPipe,

View file

@ -47,4 +47,8 @@ export class IncidentsService {
updateIncident(id: string, inp: IncidentRecord): Observable<IncidentRecord> {
return this.http.patch<IncidentRecord>('/api/incident/' + id, inp);
}
getIncident(id: string): Observable<IncidentRecord> {
return this.http.get<IncidentRecord>('/api/incident/' + id);
}
}

View file

@ -0,0 +1,23 @@
<h2 mat-dialog-title>Select Incident</h2>
<mat-dialog-content>
<mat-form-field>
<input
#searchBox
matInput
name="search"
(input)="search(searchBox.value)"
/>
</mat-form-field>
<div class="selList">
<mat-selection-list #incs [(ngModel)]="sel" multiple="false">
@let incidents = incs$ | async;
@for (inc of incidents; track inc) {
<mat-list-option [value]="inc.id">{{ inc.name }}</mat-list-option>
}
</mat-selection-list>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button type="button" (click)="cancel()">Cancel</button>
<button mat-button (click)="save()" cdkFocusInitial>Select</button>
</mat-dialog-actions>

View file

@ -0,0 +1,5 @@
.selList {
overflow: scroll;
width: 480px;
min-height: 200px;
}

View file

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

View file

@ -0,0 +1,68 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, ViewChild } from '@angular/core';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatListModule, MatSelectionList } from '@angular/material/list';
import {
IncidentsListParams,
IncidentsPaginated,
IncidentsService,
} from '../incidents.service';
import {
BehaviorSubject,
combineLatest,
debounceTime,
distinctUntilChanged,
map,
merge,
Observable,
switchMap,
} from 'rxjs';
import { IncidentRecord } from '../../incidents';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-select-incident-dialog',
imports: [
MatListModule,
MatInputModule,
AsyncPipe,
MatDialogModule,
MatButtonModule,
FormsModule,
],
templateUrl: './select-incident-dialog.component.html',
styleUrl: './select-incident-dialog.component.scss',
})
export class SelectIncidentDialogComponent {
dialogRef = inject(MatDialogRef<SelectIncidentDialogComponent>);
allIncs$!: Observable<IncidentRecord[]>;
incs$!: Observable<IncidentRecord[]>;
findStr = new BehaviorSubject<string>('');
sel!: string;
constructor(private incSvc: IncidentsService) {}
ngOnInit() {
this.incs$ = this.findStr.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((sub) =>
this.incSvc.getIncidents(<IncidentsListParams>{ filter: sub }),
),
map((incidents) => incidents.incidents),
);
}
search(term: string) {
this.findStr.next(term);
}
cancel() {
this.dialogRef.close(null);
}
save() {
this.dialogRef.close(this.sel);
}
}

View file

@ -89,6 +89,13 @@ export class Talkgroup {
iconMap(icon: string): string {
return iconMapping[icon]!;
}
tgTuple(): TGID {
return <TGID>{
sys: this.system_id,
tg: this.tgid,
};
}
}
export interface TalkgroupUI extends Talkgroup {

View file

@ -1,5 +1,9 @@
<h2 mat-dialog-title>Edit {{ tgid.sys }}:{{ tgid.tg }}</h2>
<mat-dialog-content>
<div class="tgRecord">
<form *ngIf="tg" [formGroup]="form" (ngSubmit)="submit()">
@let tg = tg$ | async;
@if (tg) {
<form id="tgForm" [formGroup]="form" (ngSubmit)="save()">
<div>
<mat-form-field>
<mat-label>Name</mat-label>
@ -28,7 +32,7 @@
/>
</mat-form-field>
</div>
<div>
<div class="twoRow">
<mat-form-field>
<mat-label>Frequency</mat-label
><input
@ -38,11 +42,14 @@
formControlName="frequency"
/>
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Weight</mat-label
><input matInput name="weight" type="text" formControlName="weight" />
><input
matInput
name="weight"
type="text"
formControlName="weight"
/>
</mat-form-field>
</div>
<div>
@ -95,14 +102,23 @@
</mat-autocomplete>
</mat-form-field>
</div>
<div>
<mat-label>Alert</mat-label>
<mat-checkbox name="alert" formControlName="alert" />
<div class="alert">
<mat-checkbox name="alert" formControlName="alert"
>Alert</mat-checkbox
>
</div>
<div>
Rules:
<div class="alert">
<alert-rule-builder [rules]="tg.alert_config" />
</div>
<button class="sbButton" type="submit">Save</button>
</form>
} @else {
<div class="spinner">
<mat-spinner></mat-spinner>
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button type="button" (click)="cancel()">Cancel</button>
<button mat-button form="tgForm" type="submit" cdkFocusInitial>Save</button>
</mat-dialog-actions>

View file

@ -1,9 +1,24 @@
mat-form-field {
form div {
width: 30rem;
flex: 0 0 30rem;
display: flex;
flex-flow: row nowrap;
}
mat-form-field,
.alert,
alert-rule-builder {
width: 30rem;
margin-right: 5px;
margin-left: 5px;
}
.tgRecord {
display: flex;
flex-flow: column;
width: 31rem;
justify-content: center;
margin-top: 20px;
margin-left: auto;
margin-right: auto;
}

View file

@ -1,11 +1,12 @@
import { Component, computed, inject, ViewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs/operators';
import { debounceTime, tap } from 'rxjs/operators';
import {
Talkgroup,
TalkgroupUpdate,
IconMap,
iconMapping,
TGID,
} from '../../talkgroup';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { TalkgroupService } from '../talkgroups.service';
@ -17,7 +18,7 @@ import {
MatAutocompleteActivatedEvent,
} from '@angular/material/autocomplete';
import { CommonModule } from '@angular/common';
import { BehaviorSubject, catchError, of, Subscription } from 'rxjs';
import { catchError, of, Subscription } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
import { Observable } from 'rxjs';
import {
@ -26,12 +27,21 @@ import {
FormControl,
FormsModule,
} from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { BrowserModule } from '@angular/platform-browser';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'talkgroup-record',
@ -46,11 +56,19 @@ import { MatIconModule } from '@angular/material/icon';
MatChipsModule,
MatIconModule,
MatAutocompleteModule,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatProgressSpinnerModule,
MatButtonModule,
],
templateUrl: './talkgroup-record.component.html',
styleUrl: './talkgroup-record.component.scss',
})
export class TalkgroupRecordComponent {
dialogRef = inject(MatDialogRef<TalkgroupRecordComponent>);
tgid = inject<TGID>(MAT_DIALOG_DATA);
tg$!: Observable<Talkgroup>;
tg!: Talkgroup;
iconMapping: IconMap = iconMapping;
tgService: TalkgroupService = inject(TalkgroupService);
@ -82,10 +100,7 @@ export class TalkgroupRecordComponent {
active: string | null = null;
subscriptions = new Subscription();
constructor(
private route: ActivatedRoute,
private router: Router,
) {
constructor() {
this._allTags = this.tgService.allTags().pipe(shareReplay());
}
@ -138,25 +153,16 @@ export class TalkgroupRecordComponent {
}
ngOnInit() {
const sysId = this.route.snapshot.paramMap.get('sys');
const tgId = this.route.snapshot.paramMap.get('tg');
this.subscriptions.add(
this.tgService
.getTalkgroup(Number(sysId), Number(tgId))
.subscribe((data: Talkgroup) => {
this.tg = data;
this.form.controls['name'].setValue(this.tg.name);
this.form.controls['alpha_tag'].setValue(this.tg.alpha_tag);
this.form.controls['tg_group'].setValue(this.tg.tg_group);
this.form.controls['frequency'].setValue(this.tg.frequency);
this.form.controls['alert'].setValue(this.tg.alert);
this.form.controls['weight'].setValue(this.tg.weight);
this.form.controls['icon'].setValue(this.tg?.metadata?.icon ?? '');
this.tg$ = this.tgService
.getTalkgroup(Number(this.tgid.sys), Number(this.tgid.tg))
.pipe(
tap((tg) => {
this.form.patchValue(tg);
this.form.controls['tagInput'].setValue('');
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
}),
);
this.subscriptions.add(
this._allTags.subscribe((event) => {
this.allTags = event;
@ -168,11 +174,10 @@ export class TalkgroupRecordComponent {
this.subscriptions.unsubscribe();
}
submit() {
save() {
let tgu: TalkgroupUpdate = <TalkgroupUpdate>{
system_id: this.tg.system_id,
tgid: this.tg.tgid,
id: this.tg.id,
system_id: this.tgid.sys,
tgid: this.tgid.tg,
};
if (this.form.controls['name'].dirty) {
tgu.name = this.form.controls['name'].value;
@ -208,6 +213,7 @@ export class TalkgroupRecordComponent {
});
}
}
this.subscriptions.add(
this.tgService
.putTalkgroup(tgu)
.pipe(
@ -215,8 +221,13 @@ export class TalkgroupRecordComponent {
return of(null);
}),
)
.subscribe((event) => {
this.router.navigate(['/talkgroups/']);
});
.subscribe((newTG) => {
this.dialogRef.close(newTG);
}),
);
}
cancel() {
this.dialogRef.close();
}
}

View file

@ -64,8 +64,8 @@
</ng-container>
<ng-container matColumnDef="edit">
<th mat-header-cell *matHeaderCellDef>Edit</th>
<td mat-cell *matCellDef="let tg">
<a routerLink="/talkgroups/{{ tg.system?.id }}/{{ tg.tgid }}"
<td mat-cell *matCellDef="let tg; let i = index">
<a (click)="editTG(i, tg.system?.id, tg.tgid)"
><mat-icon>edit</mat-icon>
</a>
</td>

View file

@ -10,7 +10,7 @@ import {
} from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { TalkgroupService, TalkgroupsPaginated } from '../talkgroups.service';
import { Talkgroup, iconMapping } from '../../talkgroup';
import { TGID, Talkgroup, iconMapping } from '../../talkgroup';
import { ActivatedRoute } from '@angular/router';
import { RouterModule, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common';
@ -27,6 +27,16 @@ import { MatChipsModule } from '@angular/material/chips';
import { SelectionModel } from '@angular/cdk/collections';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { Observable, Subscription } from 'rxjs';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { TalkgroupRecordComponent } from '../talkgroup-record/talkgroup-record.component';
@Pipe({
standalone: true,
@ -59,7 +69,6 @@ export class SanitizeHtmlPipe implements PipeTransform {
selector: 'talkgroup-table',
imports: [
RouterModule,
RouterLink,
MatIconModule,
CommonModule,
IconifyPipe,
@ -104,6 +113,7 @@ export class TalkgroupTableComponent {
@Input() resetPage!: Observable<void>;
@ViewChild('paginator') paginator!: MatPaginator;
suppress = false;
dialog = inject(MatDialog);
constructor(private route: ActivatedRoute) {}
@ -156,4 +166,18 @@ export class TalkgroupTableComponent {
? this.selection.clear()
: this.dataSource.data.forEach((row) => this.selection.select(row));
}
editTG(idx: number, sys: number, tg: number) {
const tgid = <TGID>{ sys: sys, tg: tg };
const dialogRef = this.dialog.open(TalkgroupRecordComponent, {
data: tgid,
});
dialogRef.afterClosed().subscribe((res) => {
if (res !== undefined) {
this.dataSource.data[idx] = res;
this.dataSource.data = this.dataSource.data;
}
});
}
}

View file

@ -46,7 +46,7 @@ export class TalkgroupService {
}
getTalkgroups(): Observable<Talkgroup[]> {
return this.http.get<Talkgroup[]>('/api/talkgroup/');
return this.http.get<Talkgroup[]>('/api/talkgroup/').pipe(shareReplay());
}
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
@ -64,7 +64,7 @@ export class TalkgroupService {
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
let tgid = this.tgKey(tu.system_id, tu.tgid);
this.http
return this.http
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu)
.pipe(
switchMap((tg) => {
@ -78,8 +78,6 @@ export class TalkgroupService {
return tObs;
}),
);
return this._getTalkgroup.get(tgid)!;
}
putTalkgroups(

View file

@ -1,3 +1,3 @@
export const environment = {
baseUrl: 'http://xenon:3050',
baseUrl: '',
};

View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<title>Stillbox</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#1976d2" />
<meta http-equiv="Content-Security-Policy" content="media-src *" />
</head>
<body>
<app-root></app-root>
<noscript
>Please enable JavaScript to continue using this application.</noscript
>
</body>
</html>

View file

@ -0,0 +1,6 @@
{
"/api": {
"target": "http://xenon:3050",
"secure": false
}
}

View file

@ -6,6 +6,7 @@
// Include the common styles for Angular Material. We include this here so that you only
// have to load a single css file for Angular Material in your app.
// Be sure that you only ever include this mixin once!
@include mat.core();
@include mat.elevation-classes();
@include mat.app-background();
@ -222,7 +223,8 @@ body {
width: 100px;
}
input {
input,
textarea {
caret-color: var(--color-dark-fg) !important;
}
@ -236,3 +238,8 @@ input {
margin-bottom: 40px;
justify-content: center;
}
.mat-mdc-menu-item.deleteItem {
color: white;
background-color: red;
}

View file

@ -27,6 +27,9 @@ auth:
# this allows the JWT cookie to be served over plain HTTP only for these Host: header values
allowInsecureFor:
"localhost": true
# this instead changes the meaning of allowInsecureFor to the cookie being marked
# Secure, but SameSite will be set to None
sameSiteNoneForInsecure: false
listen: ':3050'
public: true
log:

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, forms.WithTag("json"))
err := forms.Marshal(call, body, forms.WithTag("relayOut"))
if err != nil {
return fmt.Errorf("relay form parse: %w", err)
}

View file

@ -145,6 +145,16 @@ func (a *Auth) allowInsecureCookie(r *http.Request) bool {
return has && v
}
func (a *Auth) setInsecureCookie(cookie *http.Cookie) {
if a.cfg.SameSiteNoneWhenInsecure {
cookie.Secure = true
cookie.SameSite = http.SameSiteNoneMode
} else {
cookie.Secure = false
cookie.SameSite = http.SameSiteLaxMode
}
}
func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
jwToken, _, err := jwtauth.FromContext(r.Context())
if err != nil {
@ -174,8 +184,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
}
if a.allowInsecureCookie(r) {
cookie.Secure = false
cookie.SameSite = http.SameSiteLaxMode
a.setInsecureCookie(cookie)
}
if cookie.Secure {
@ -236,8 +245,7 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
cookie.Domain = r.Host
if a.allowInsecureCookie(r) {
cookie.Secure = false
cookie.SameSite = http.SameSiteLaxMode
a.setInsecureCookie(cookie)
}
http.SetCookie(w, cookie)
@ -263,8 +271,8 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
cookie.Domain = r.Host
if a.allowInsecureCookie(r) {
cookie.Secure = false
cookie.SameSite = http.SameSiteLaxMode
cookie.Secure = true
cookie.SameSite = http.SameSiteNoneMode
}
http.SetCookie(w, cookie)

View file

@ -1,6 +1,7 @@
package calls
import (
"encoding/json"
"fmt"
"time"
@ -29,6 +30,10 @@ func (d CallDuration) MsInt32Ptr() *int32 {
return &i
}
func (d CallDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.Duration().Milliseconds())
}
func (d CallDuration) Seconds() int32 {
return int32(time.Duration(d).Seconds())
}
@ -41,24 +46,29 @@ type CallAudio struct {
AudioBlob []byte `json:"audioBlob"`
}
// The tags here are snake_case for compatibility with sqlc generated
// struct tags in ListCallsPRow. This allows the heavier-weight calls
// queries/endpoints to render DB output directly to the wire without
// further transformation. relayOut exists for compatibility with http
// source CallUploadRequest as used in the relay sink.
type Call struct {
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"`
ID uuid.UUID `json:"id" relayOut:"id"`
Audio []byte `json:"audio,omitempty" relayOut:"audio,omitempty" filenameField:"AudioName"`
AudioName string `json:"audioName,omitempty" relayOut:"audioName,omitempty"`
AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"`
Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"`
DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"`
Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"`
Frequency int `json:"frequency,omitempty" relayOut:"frequency,omitempty"`
Patches []int `json:"patches,omitempty" relayOut:"patches,omitempty"`
Source int `json:"source,omitempty" relayOut:"source,omitempty"`
System int `json:"system_id,omitempty" relayOut:"system,omitempty"`
Submitter *auth.UserID `json:"submitter,omitempty" relayOut:"submitter,omitempty"`
SystemLabel string `json:"system_name,omitempty" relayOut:"systemLabel,omitempty"`
Talkgroup int `json:"tgid,omitempty" relayOut:"talkgroup,omitempty"`
TalkgroupGroup *string `json:"talkgroupGroup,omitempty" relayOut:"talkgroupGroup,omitempty"`
TalkgroupLabel *string `json:"talkgroupLabel,omitempty" relayOut:"talkgroupLabel,omitempty"`
TGAlphaTag *string `json:"tg_name,omitempty" relayOut:"talkgroupTag,omitempty"`
shouldStore bool `json:"-"`
}

View file

@ -32,6 +32,7 @@ type Config struct {
type Auth struct {
JWTSecret string `yaml:"jwtsecret"`
AllowInsecure map[string]bool `yaml:"allowInsecureFor"`
SameSiteNoneWhenInsecure bool `yaml:"sameSiteNoneForInsecure"`
}
type CORS struct {

View file

@ -147,7 +147,6 @@ WITH to_sweep AS (
WHERE call_id IN (SELECT id FROM to_sweep)
`
// This is used to sweep calls that are part of an incident prior to pruning a partition.
func (q *Queries) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, cleanupSweptCalls, rangeStart, rangeEnd)
if err != nil {
@ -366,6 +365,7 @@ WITH to_sweep AS (
) INSERT INTO swept_calls SELECT id, submitter, system, talkgroup, call_date, audio_name, audio_blob, duration, audio_type, audio_url, frequency, frequencies, patches, tg_label, tg_alpha_tag, tg_group, source, transcript FROM to_sweep
`
// This is used to sweep calls that are part of an incident prior to pruning a partition.
func (q *Queries) SweepCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, sweepCalls, rangeStart, rangeEnd)
if err != nil {

View file

@ -132,7 +132,22 @@ func (q *Queries) GetIncident(ctx context.Context, id uuid.UUID) (Incident, erro
}
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
SELECT
ic.call_id,
ic.call_date,
c.duration,
c.system system_id,
c.talkgroup tgid,
ic.notes,
c.submitter,
c.audio_name,
c.audio_type,
c.audio_url,
c.frequency,
c.frequencies,
c.patches,
c.source,
c.transcript
FROM incidents_calls ic, LATERAL (
SELECT
ca.submitter,
@ -170,12 +185,12 @@ WHERE ic.incident_id = $1
type GetIncidentCallsRow struct {
CallID uuid.UUID `json:"call_id"`
CallDate pgtype.Timestamptz `json:"call_date"`
Duration *int32 `json:"duration"`
SystemID int `json:"system_id"`
TGID int `json:"tgid"`
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"`
@ -197,12 +212,12 @@ func (q *Queries) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetInci
if err := rows.Scan(
&i.CallID,
&i.CallDate,
&i.Duration,
&i.SystemID,
&i.TGID,
&i.Notes,
&i.Submitter,
&i.System,
&i.Talkgroup,
&i.AudioName,
&i.Duration,
&i.AudioType,
&i.AudioUrl,
&i.Frequency,

View file

@ -16,7 +16,6 @@ type Querier interface {
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)
@ -62,6 +61,7 @@ type Querier interface {
SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error
StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error
StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults
// This is used to sweep calls that are part of an incident prior to pruning a partition.
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)

View file

@ -240,9 +240,9 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
Frequency: v.Frequency,
Patches: v.Patches,
Source: v.Source,
System: v.System,
System: v.SystemID,
Submitter: sub,
Talkgroup: v.Talkgroup,
Talkgroup: v.TGID,
},
Notes: v.Notes,
})

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, forms.WithTag("json"))
err := forms.Marshal(call, body, forms.WithTag("relayOut"))
if err != nil {
return fmt.Errorf("relay form parse: %w", err)
}

View file

@ -94,7 +94,22 @@ CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
;
-- name: GetIncidentCalls :many
SELECT ic.call_id, ic.call_date, ic.notes, c.*
SELECT
ic.call_id,
ic.call_date,
c.duration,
c.system system_id,
c.talkgroup tgid,
ic.notes,
c.submitter,
c.audio_name,
c.audio_type,
c.audio_url,
c.frequency,
c.frequencies,
c.patches,
c.source,
c.transcript
FROM incidents_calls ic, LATERAL (
SELECT
ca.submitter,