diff --git a/LICENSE b/LICENSE index a136513..4ba46ff 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024 Daniel Ponte. +Copyright (c) 2024, 2025 Daniel Ponte. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 2b5c5ca..ae3c024 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ protobuf best practices (i.e. not changing field numbers). ## License and Copyright -© 2024, Daniel Ponte +© 2024, 2025 Daniel Ponte Licensed under the 3-clause BSD license. See LICENSE for details. diff --git a/client/stillbox/ngsw-config.json b/client/stillbox/ngsw-config.json index 69edd28..0a011be 100644 --- a/client/stillbox/ngsw-config.json +++ b/client/stillbox/ngsw-config.json @@ -26,5 +26,16 @@ ] } } + ], + "navigationUrls": [ + "/**", + "!/**/*.*", + "!/**/****", + "!/**/****/**", + "!/tgstats", + "!/tgstats/**", + "!/api/**", + "!/share/**", + "!/testnotify" ] } diff --git a/client/stillbox/src/app/app.component.html b/client/stillbox/src/app/app.component.html index 20725bc..3735317 100644 --- a/client/stillbox/src/app/app.component.html +++ b/client/stillbox/src/app/app.component.html @@ -8,7 +8,7 @@
- @if (auth.loggedIn) { + @if (auth.isAuth()) { @@ -19,7 +19,7 @@
- @if (auth.loggedIn) { + @if (auth.isAuth()) { } @else {
diff --git a/client/stillbox/src/app/app.config.ts b/client/stillbox/src/app/app.config.ts index 0014fed..cb21be6 100644 --- a/client/stillbox/src/app/app.config.ts +++ b/client/stillbox/src/app/app.config.ts @@ -29,7 +29,7 @@ export function authIntercept( next: HttpHandlerFn, ): Observable> { let authSvc: AuthService = inject(AuthService); - if (authSvc.loggedIn) { + if (authSvc.isAuth()) { req = req.clone({ setHeaders: { Authorization: `Bearer ${authSvc.getToken()}`, diff --git a/client/stillbox/src/app/app.routes.ts b/client/stillbox/src/app/app.routes.ts index 9898a2e..c5ba17d 100644 --- a/client/stillbox/src/app/app.routes.ts +++ b/client/stillbox/src/app/app.routes.ts @@ -8,6 +8,11 @@ export const routes: Routes = [ loadComponent: () => import('./login/login.component').then((m) => m.LoginComponent), }, + { + path: 's/:id', + loadComponent: () => + import('./share/share.component').then((m) => m.ShareComponent), + }, { path: '', canActivateChild: [AuthGuard], @@ -71,6 +76,12 @@ export const routes: Routes = [ import('./alerts/alerts.component').then((m) => m.AlertsComponent), data: { title: 'Alerts' }, }, + { + path: 'shares', + loadComponent: () => + import('./shares/shares.component').then((m) => m.SharesComponent), + data: { title: 'Shares' }, + }, ], }, ]; diff --git a/client/stillbox/src/app/auth.guard.ts b/client/stillbox/src/app/auth.guard.ts index b9b91c0..d2383ce 100644 --- a/client/stillbox/src/app/auth.guard.ts +++ b/client/stillbox/src/app/auth.guard.ts @@ -5,7 +5,7 @@ import { inject } from '@angular/core'; export const AuthGuard: CanActivateFn = (route, state) => { const router: Router = inject(Router); const authSvc: AuthService = inject(AuthService); - if (localStorage.getItem('jwt') == null) { + if (authSvc.token() === null) { let success = false; authSvc.refresh().subscribe({ next: (event) => { diff --git a/client/stillbox/src/app/calls.ts b/client/stillbox/src/app/calls.ts index ccd51ee..ed581f5 100644 --- a/client/stillbox/src/app/calls.ts +++ b/client/stillbox/src/app/calls.ts @@ -1,8 +1,9 @@ export interface CallRecord { id: string; - call_date: Date; + callDate: Date; + audioURL: string | null; duration: number; - system_id: number; + systemId: number; tgid: number; incidents: number; // in incident } diff --git a/client/stillbox/src/app/calls/call-info/call-info.component.html b/client/stillbox/src/app/calls/call-info/call-info.component.html new file mode 100644 index 0000000..aa51826 --- /dev/null +++ b/client/stillbox/src/app/calls/call-info/call-info.component.html @@ -0,0 +1,15 @@ + +
+

+ {{ call | talkgroup: "alpha" | async }} @ + {{ call.callDate | grabDate }} {{ call.callDate | time: true }} +

+
+
+
Talkgroup
+
{{ call | talkgroup: "name" | async }}
+
Group
+
{{ call | talkgroup: "group" | async }}
+
+ +
diff --git a/client/stillbox/src/app/calls/call-info/call-info.component.scss b/client/stillbox/src/app/calls/call-info/call-info.component.scss new file mode 100644 index 0000000..3cb56ff --- /dev/null +++ b/client/stillbox/src/app/calls/call-info/call-info.component.scss @@ -0,0 +1,63 @@ +.callInfo { + margin: 0px 50px 50px 50px; + padding: 50px 50px 50px 50px; + display: flex; + flex-flow: column; + margin-left: auto; + margin-right: auto; +} + +.call-heading { + display: flex; + margin-bottom: 20px; + flex: 0 0; + flex-wrap: wrap; +} + +.cardHdr { + display: flex; + flex-flow: row wrap; + margin-bottom: 24px; +} + +.field { + flex: 1 0; + padding-left: 10px; + padding-right: 10px; +} + +.field-label { + font-weight: bolder; + flex-grow: 0; + padding-right: 5px; +} +.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; +} + +.callRecord { + display: flex; + flex-flow: column nowrap; + justify-content: center; + margin-top: 20px; + margin-left: auto; + margin-right: auto; +} diff --git a/client/stillbox/src/app/calls/call-info/call-info.component.spec.ts b/client/stillbox/src/app/calls/call-info/call-info.component.spec.ts new file mode 100644 index 0000000..88fbfaa --- /dev/null +++ b/client/stillbox/src/app/calls/call-info/call-info.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CallInfoComponent } from './call-info.component'; + +describe('CallInfoComponent', () => { + let component: CallInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CallInfoComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CallInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/calls/call-info/call-info.component.ts b/client/stillbox/src/app/calls/call-info/call-info.component.ts new file mode 100644 index 0000000..f8668fc --- /dev/null +++ b/client/stillbox/src/app/calls/call-info/call-info.component.ts @@ -0,0 +1,28 @@ +import { Component, Input } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { CallRecord } from '../../calls'; +import { Share } from '../../shares'; +import { DatePipe, SafePipe, TalkgroupPipe, TimePipe } from '../calls.service'; +import { AsyncPipe } from '@angular/common'; + +@Component({ + selector: 'app-call-info', + imports: [ + MatCardModule, + TimePipe, + DatePipe, + TalkgroupPipe, + AsyncPipe, + SafePipe, + ], + templateUrl: './call-info.component.html', + styleUrl: './call-info.component.scss', +}) +export class CallInfoComponent { + @Input() share!: Share; + call!: CallRecord; + + ngOnInit() { + this.call = this.share.sharedItem as CallRecord; + } +} diff --git a/client/stillbox/src/app/calls/calls.component.html b/client/stillbox/src/app/calls/calls.component.html index 7c2891d..3a3972f 100644 --- a/client/stillbox/src/app/calls/calls.component.html +++ b/client/stillbox/src/app/calls/calls.component.html @@ -135,13 +135,13 @@ Date - {{ call.call_date | grabDate }} + {{ call.callDate | grabDate }} Time - - {{ call.call_date | time }} + + {{ call.callDate | time }} diff --git a/client/stillbox/src/app/calls/calls.component.ts b/client/stillbox/src/app/calls/calls.component.ts index c82e9f4..76b50df 100644 --- a/client/stillbox/src/app/calls/calls.component.ts +++ b/client/stillbox/src/app/calls/calls.component.ts @@ -1,10 +1,4 @@ -import { - Component, - inject, - Pipe, - PipeTransform, - ViewChild, -} from '@angular/core'; +import { Component, inject, ViewChild } from '@angular/core'; import { CommonModule, AsyncPipe } from '@angular/common'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTableModule } from '@angular/material/table'; @@ -17,13 +11,20 @@ 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, Observable, Subscription } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; -import { CallsListParams, CallsService } from './calls.service'; +import { BehaviorSubject, Subject, Subscription } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { + CallsListParams, + CallsService, + DatePipe, + DownloadURLPipe, + FixedPointPipe, + TalkgroupPipe, + TimePipe, +} from './calls.service'; import { CallRecord } from '../calls'; import { TalkgroupService } from '../talkgroups/talkgroups.service'; -import { Talkgroup } from '../talkgroup'; import { MatFormFieldModule } from '@angular/material/form-field'; import { FormControl, @@ -49,94 +50,6 @@ import { import { IncidentRecord } from '../incidents'; import { SelectIncidentDialogComponent } from '../incidents/select-incident-dialog/select-incident-dialog.component'; -@Pipe({ - name: 'grabDate', - standalone: true, - pure: true, -}) -export class DatePipe implements PipeTransform { - transform(ts: string, args?: any): string { - const timestamp = new Date(ts); - return timestamp.getMonth() + 1 + '/' + timestamp.getDate(); - } -} - -@Pipe({ - name: 'time', - standalone: true, - pure: true, -}) -export class TimePipe implements PipeTransform { - transform(ts: string, args?: any): string { - const timestamp = new Date(ts); - return timestamp.toLocaleTimeString(navigator.language, { - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', - }); - } -} - -@Pipe({ - name: 'talkgroup', - standalone: true, - pure: true, -}) -export class TalkgroupPipe implements PipeTransform { - constructor(private tgService: TalkgroupService) {} - - transform(call: CallRecord, field: string): Observable { - return this.tgService.getTalkgroup(call.system_id, call.tgid).pipe( - map((tg: Talkgroup) => { - switch (field) { - case 'alpha': { - return tg.alpha_tag ?? call.tgid; - break; - } - case 'group': { - return tg.tg_group ?? '\u2014'; - break; - } - case 'system': { - return tg.system?.name ?? tg.system_id.toString(); - } - default: { - return tg.name ?? '\u2014'; - break; - } - } - }), - ); - } -} - -@Pipe({ - name: 'fixedPoint', - standalone: true, - pure: true, -}) -export class FixedPointPipe implements PipeTransform { - constructor() {} - - transform(quant: number, divisor: number, places: number): string { - const seconds = quant / divisor; - return seconds.toFixed(places); - } -} - -@Pipe({ - name: 'audioDownloadURL', - standalone: true, - pure: true, -}) -export class DownloadURLPipe implements PipeTransform { - constructor(private callsSvc: CallsService) {} - - transform(call: CallRecord, args?: any): string { - return this.callsSvc.callAudioDownloadURL(call.id); - } -} - const reqPageSize = 200; @Component({ selector: 'app-calls', @@ -144,8 +57,8 @@ const reqPageSize = 200; MatIconModule, FixedPointPipe, TalkgroupPipe, - DatePipe, TimePipe, + DatePipe, MatPaginatorModule, MatTableModule, AsyncPipe, @@ -329,6 +242,7 @@ export class CallsComponent { this.subscriptions.add( this.fetchCalls .pipe( + debounceTime(500), switchMap((params) => { return this.callsSvc.getCalls(params); }), diff --git a/client/stillbox/src/app/calls/calls.service.ts b/client/stillbox/src/app/calls/calls.service.ts index 2792b9e..31816ff 100644 --- a/client/stillbox/src/app/calls/calls.service.ts +++ b/client/stillbox/src/app/calls/calls.service.ts @@ -1,8 +1,155 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Pipe, PipeTransform } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { map, Observable } from 'rxjs'; import { CallRecord } from '../calls'; import { environment } from '.././../environments/environment'; +import { TalkgroupService } from '../talkgroups/talkgroups.service'; +import { Talkgroup } from '../talkgroup'; +import { Share } from '../shares'; +import { + DomSanitizer, + SafeHtml, + SafeResourceUrl, + SafeScript, + SafeStyle, + SafeUrl, +} from '@angular/platform-browser'; + +@Pipe({ + name: 'grabDate', + standalone: true, + pure: true, +}) +export class DatePipe implements PipeTransform { + transform(ts: string | Date, args?: any): string { + const timestamp = new Date(ts); + return timestamp.getMonth() + 1 + '/' + timestamp.getDate(); + } +} + +@Pipe({ + name: 'time', + standalone: true, + pure: true, +}) +export class TimePipe implements PipeTransform { + transform(ts: string | Date, haveSecond: boolean = false): string { + const timestamp = new Date(ts); + return timestamp.toLocaleTimeString(navigator.language, { + hour: '2-digit', + minute: '2-digit', + second: haveSecond ? '2-digit' : undefined, + hourCycle: 'h23', + }); + } +} + +@Pipe({ + name: 'talkgroup', + standalone: true, + pure: true, +}) +export class TalkgroupPipe implements PipeTransform { + constructor(private tgService: TalkgroupService) {} + + transform( + call: CallRecord, + field: string, + share: Share | null = null, + ): Observable { + return this.tgService.getTalkgroup(call.systemId, call.tgid).pipe( + map((tg: Talkgroup) => { + switch (field) { + case 'alpha': { + return tg.alphaTag ?? call.tgid; + break; + } + case 'group': { + return tg.tgGroup ?? '\u2014'; + break; + } + case 'system': { + return tg.system?.name ?? tg.systemId.toString(); + } + default: { + return tg.name ?? '\u2014'; + break; + } + } + }), + ); + } +} + +@Pipe({ + name: 'fixedPoint', + standalone: true, + pure: true, +}) +export class FixedPointPipe implements PipeTransform { + constructor() {} + + transform(quant: number, divisor: number, places: number): string { + const seconds = quant / divisor; + return seconds.toFixed(places); + } +} + +/** + * Sanitize HTML + */ +@Pipe({ + name: 'safe', +}) +export class SafePipe implements PipeTransform { + /** + * Pipe Constructor + * + * @param _sanitizer: DomSanitezer + */ + // tslint:disable-next-line + constructor(protected _sanitizer: DomSanitizer) {} + + /** + * Transform + * + * @param value: string + * @param type: string + */ + transform( + value: string, + type: string, + ): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl { + switch (type) { + case 'html': + return this._sanitizer.bypassSecurityTrustHtml(value); + case 'style': + return this._sanitizer.bypassSecurityTrustStyle(value); + case 'script': + return this._sanitizer.bypassSecurityTrustScript(value); + case 'url': + return this._sanitizer.bypassSecurityTrustUrl(value); + case 'resourceUrl': + let res = this._sanitizer.bypassSecurityTrustResourceUrl(value); + return res; + default: + return this._sanitizer.bypassSecurityTrustHtml(value); + } + } +} + +@Pipe({ + name: 'audioDownloadURL', + standalone: true, + pure: true, +}) +export class DownloadURLPipe implements PipeTransform { + constructor(private callsSvc: CallsService) {} + + transform(call: CallRecord, args?: any): string { + return this.callsSvc.callAudioDownloadURL(call.id); + } +} export interface CallsListParams { start: Date | null; diff --git a/client/stillbox/src/app/calls/player/call-player/call-player.component.ts b/client/stillbox/src/app/calls/player/call-player/call-player.component.ts index 2b528bc..18ab151 100644 --- a/client/stillbox/src/app/calls/player/call-player/call-player.component.ts +++ b/client/stillbox/src/app/calls/player/call-player/call-player.component.ts @@ -30,7 +30,11 @@ export class CallPlayerComponent { this.playSub.unsubscribe(); }); this.playing = true; - this.au.src = this.callsSvc.callAudioURL(this.call.id); + if (this.call.audioURL != null) { + this.au.src = this.call.audioURL; + } else { + this.au.src = this.callsSvc.callAudioURL(this.call.id); + } this.au.load(); this.au.play().then(null, (reason) => { this.playing = false; diff --git a/client/stillbox/src/app/incidents/incident/incident.component.html b/client/stillbox/src/app/incidents/incident/incident.component.html index e9b8612..3e18495 100644 --- a/client/stillbox/src/app/incidents/incident/incident.component.html +++ b/client/stillbox/src/app/incidents/incident/incident.component.html @@ -3,21 +3,32 @@

{{ inc?.name }} - playlist_play

- - - - - + + + + + }
Start
@@ -64,25 +75,25 @@ Date - {{ call.call_date | grabDate }} + {{ call.callDate | grabDate }} Time - - {{ call.call_date | time }} + + {{ call.callDate | time }} System - {{ call | talkgroup: "system" | async }} + {{ call | talkgroup: "system" : share | async }} Group - {{ call | talkgroup: "group" | async }} + {{ call | talkgroup: "group" : share | async }} diff --git a/client/stillbox/src/app/incidents/incident/incident.component.scss b/client/stillbox/src/app/incidents/incident/incident.component.scss index 08e0820..3ac603c 100644 --- a/client/stillbox/src/app/incidents/incident/incident.component.scss +++ b/client/stillbox/src/app/incidents/incident/incident.component.scss @@ -1,5 +1,5 @@ .incident { - margin: 50px 50px 50px 50px; + margin: 0px 50px 50px 50px; padding: 50px 50px 50px 50px; display: flex; flex-flow: column; @@ -7,12 +7,6 @@ margin-right: auto; } -@media not screen and (max-width: 768px) { - .incident { - width: 75%; - } -} - .inc-heading { display: flex; margin-bottom: 20px; diff --git a/client/stillbox/src/app/incidents/incident/incident.component.ts b/client/stillbox/src/app/incidents/incident/incident.component.ts index 944fe04..a868c8a 100644 --- a/client/stillbox/src/app/incidents/incident/incident.component.ts +++ b/client/stillbox/src/app/incidents/incident/incident.component.ts @@ -1,7 +1,7 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, Input } from '@angular/core'; import { tap } from 'rxjs/operators'; import { CommonModule, Location } from '@angular/common'; -import { BehaviorSubject, merge, Subscription } from 'rxjs'; +import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs'; import { Observable } from 'rxjs'; import { ReactiveFormsModule, @@ -35,10 +35,13 @@ import { TimePipe, DatePipe, DownloadURLPipe, -} from '../../calls/calls.component'; +} from '../../calls/calls.service'; import { CallPlayerComponent } from '../../calls/player/call-player/call-player.component'; import { FmtDatePipe } from '../incidents.component'; import { MatMenuModule } from '@angular/material/menu'; +import { Share } from '../../shares'; +import { ShareService } from '../../share/share.service'; +import { TalkgroupService } from '../../talkgroups/talkgroups.service'; export interface EditDialogData { incID: string; @@ -151,8 +154,9 @@ export class IncidentEditDialogComponent { styleUrl: './incident.component.scss', }) export class IncidentComponent { - incPrime = new BehaviorSubject({}); + incPrime = new Subject(); inc$!: Observable; + @Input() share?: Share; subscriptions: Subscription = new Subscription(); dialog = inject(MatDialog); incID!: string; @@ -174,15 +178,30 @@ export class IncidentComponent { private route: ActivatedRoute, private incSvc: IncidentsService, private location: Location, + private tgSvc: TalkgroupService, ) {} saveIncName(ev: Event) {} ngOnInit() { - this.incID = this.route.snapshot.paramMap.get('id')!; - this.inc$ = merge(this.incSvc.getIncident(this.incID), this.incPrime).pipe( + if (this.share) { + this.tgSvc.setShare(this.share); + } + let incOb: Observable; + if (this.route.component === this.constructor) { + // loaded by route + this.incID = this.route.snapshot.paramMap.get('id')!; + incOb = this.incSvc.getIncident(this.incID); + } else { + if (!this.share) { + return; + } + this.incID = (this.share.sharedItem as IncidentRecord).id; + incOb = new BehaviorSubject(this.share.sharedItem as IncidentRecord); + } + this.inc$ = merge(incOb, this.incPrime).pipe( tap((inc) => { - if (inc.calls) { + if (inc && inc.calls) { this.callsResult.data = inc.calls; } }), diff --git a/client/stillbox/src/app/login/auth.service.ts b/client/stillbox/src/app/login/auth.service.ts index 0ec7b97..474c3ff 100644 --- a/client/stillbox/src/app/login/auth.service.ts +++ b/client/stillbox/src/app/login/auth.service.ts @@ -1,56 +1,95 @@ -import { Injectable } from '@angular/core'; +import { + Injectable, + signal, + computed, + effect, + inject, + DestroyRef, +} from '@angular/core'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { Router } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import { tap } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; export class Jwt { constructor(public jwt: string) {} } +type AuthState = { + user: string | null; + token: string | null; + is_auth: boolean; +}; + @Injectable({ providedIn: 'root', }) export class AuthService { - loggedIn: boolean = false; + private _accessTokenKey = 'jwt'; + private _storedToken = localStorage.getItem(this._accessTokenKey); + destroyed = inject(DestroyRef); + + private _state = signal({ + user: null, + token: this._storedToken, + is_auth: this._storedToken !== null, + }); + loginFailed = signal(false); + token = computed(() => this._state().token); + isAuth = computed(() => this._state().is_auth); + user = computed(() => this._state().user); constructor( private http: HttpClient, private _router: Router, ) { - let ssJWT = localStorage.getItem('jwt'); - if (ssJWT) { - this.loggedIn = true; - } + effect(() => { + const token = this.token(); + if (token !== null) { + localStorage.setItem(this._accessTokenKey, token); + } else { + localStorage.removeItem(this._accessTokenKey); + } + }); } - login(username: string, password: string): Observable> { + login(username: string, password: string) { return this.http - .post( - '/api/login', - { username: username, password: password }, - { observe: 'response' }, - ) - .pipe( - tap((event) => { - if (event.status == 200) { - localStorage.setItem('jwt', event.body?.jwt.toString() ?? ''); - this.loggedIn = true; - this._router.navigateByUrl('/home'); - } - }), - ); + .post('/api/login', { username: username, password: password }) + .pipe(takeUntilDestroyed(this.destroyed)) + .subscribe({ + next: (res) => { + let state = { + user: username, + token: res.jwt, + is_auth: true, + }; + this._state.set(state); + this.loginFailed.update(() => false); + this._router.navigateByUrl('/'); + }, + error: (err) => { + this.loginFailed.update(() => true); + }, + }); + } + + _clearState() { + this._state.set({ + is_auth: false, + token: null, + }); } logout() { - this.http - .get('/api/logout', { withCredentials: true, observe: 'response' }) - .subscribe((event) => { - if (event.status == 200) { - this.loggedIn = false; - } - }); - localStorage.removeItem('jwt'); - this.loggedIn = false; + this.http.get('/api/logout', { withCredentials: true }).subscribe({ + next: (event) => { + this._clearState(); + }, + error: (err) => { + this._clearState(); + }, + }); this._router.navigateByUrl('/login'); } @@ -60,14 +99,20 @@ export class AuthService { .pipe( tap((event) => { if (event.status == 200) { - localStorage.setItem('jwt', event.body?.jwt.toString() ?? ''); - this.loggedIn = true; + let ost = this._state(); + let tok = event.body?.jwt.toString(); + let state = { + user: ost.user, + token: tok ? tok : null, + is_auth: true, + }; + this._state.set(state); } }), ); } getToken(): string | null { - return localStorage.getItem('jwt'); + return localStorage.getItem(this._accessTokenKey); } } diff --git a/client/stillbox/src/app/login/login.component.html b/client/stillbox/src/app/login/login.component.html index 293daed..1646e47 100644 --- a/client/stillbox/src/app/login/login.component.html +++ b/client/stillbox/src/app/login/login.component.html @@ -28,7 +28,7 @@
- @if (failed) { + @if (failed()) {
Login Failed!
diff --git a/client/stillbox/src/app/login/login.component.ts b/client/stillbox/src/app/login/login.component.ts index 5a8c5bc..a7adc76 100644 --- a/client/stillbox/src/app/login/login.component.ts +++ b/client/stillbox/src/app/login/login.component.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AuthService } from '../login/auth.service'; import { catchError, of, Subscription } from 'rxjs'; @@ -17,31 +17,9 @@ export class LoginComponent { router: Router = inject(Router); username: string = ''; password: string = ''; - failed: boolean = false; - private subscriptions = new Subscription(); + failed = this.apiService.loginFailed; onSubmit() { - this.failed = false; - this.subscriptions.add( - this.apiService - .login(this.username, this.password) - .pipe( - catchError(() => { - this.failed = true; - return of(null); - }), - ) - .subscribe((event) => { - if (event?.status == 200) { - this.router.navigateByUrl('/'); - } else { - this.failed = true; - } - }), - ); - } - - ngOnDestroy() { - this.subscriptions.unsubscribe(); + this.apiService.login(this.username, this.password); } } diff --git a/client/stillbox/src/app/navigation/navigation.component.ts b/client/stillbox/src/app/navigation/navigation.component.ts index 9c58f8a..8923bf9 100644 --- a/client/stillbox/src/app/navigation/navigation.component.ts +++ b/client/stillbox/src/app/navigation/navigation.component.ts @@ -101,6 +101,11 @@ export class NavigationComponent { url: '/alerts', icon: 'notifications', }, + { + name: 'Shares', + url: '/shares', + icon: 'share', + }, ]; toggleFilterPanel() { diff --git a/client/stillbox/src/app/share/share.component.html b/client/stillbox/src/app/share/share.component.html new file mode 100644 index 0000000..da8af0f --- /dev/null +++ b/client/stillbox/src/app/share/share.component.html @@ -0,0 +1,17 @@ +@let sh = share | async; +@switch (sh?.type) { + @case ("incident") { + + } + @case ("call") { + + } + @case (null) { +
+ +
+ } + @default { +

Share type {{ sh?.type }} unknown

+ } +} diff --git a/client/stillbox/src/app/share/share.component.scss b/client/stillbox/src/app/share/share.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/stillbox/src/app/share/share.component.spec.ts b/client/stillbox/src/app/share/share.component.spec.ts new file mode 100644 index 0000000..7a7fe1e --- /dev/null +++ b/client/stillbox/src/app/share/share.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ShareComponent } from './share.component'; + +describe('ShareComponent', () => { + let component: ShareComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ShareComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ShareComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/share/share.component.ts b/client/stillbox/src/app/share/share.component.ts new file mode 100644 index 0000000..7c0a118 --- /dev/null +++ b/client/stillbox/src/app/share/share.component.ts @@ -0,0 +1,41 @@ +import { Component } from '@angular/core'; +import { ShareService } from './share.service'; +import { Share } from '../shares'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, Subscription, switchMap } from 'rxjs'; +import { IncidentComponent } from '../incidents/incident/incident.component'; +import { AsyncPipe } from '@angular/common'; +import { CallInfoComponent } from '../calls/call-info/call-info.component'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +@Component({ + selector: 'app-share', + imports: [ + AsyncPipe, + IncidentComponent, + CallInfoComponent, + MatProgressSpinnerModule, + ], + templateUrl: './share.component.html', + styleUrl: './share.component.scss', +}) +export class ShareComponent { + shareID!: string; + share!: Observable; + constructor( + private route: ActivatedRoute, + private shareSvc: ShareService, + ) {} + + ngOnInit() { + let shareParam = this.route.snapshot.paramMap.get('id'); + if (shareParam == null) { + // TODO: error + return; + } + + this.shareID = shareParam; + + this.share = this.shareSvc.getShare(this.shareID); + } +} diff --git a/client/stillbox/src/app/share/share.service.spec.ts b/client/stillbox/src/app/share/share.service.spec.ts new file mode 100644 index 0000000..37145a5 --- /dev/null +++ b/client/stillbox/src/app/share/share.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ShareService } from './share.service'; + +describe('ShareService', () => { + let service: ShareService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ShareService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/share/share.service.ts b/client/stillbox/src/app/share/share.service.ts new file mode 100644 index 0000000..4e43b8b --- /dev/null +++ b/client/stillbox/src/app/share/share.service.ts @@ -0,0 +1,81 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { map, Observable, switchMap } from 'rxjs'; +import { IncidentRecord } from '../incidents'; +import { CallRecord } from '../calls'; +import { Share, ShareType } from '../shares'; +import { ActivatedRoute, Router } from '@angular/router'; + +export interface ShareRecord { + id: string; + entityType: string; + entityDate: Date; + owner: string; + entityID: string; + expiration: Date | null; +} + +export interface ShareListParams { + page: number | null; + perPage: number | null; + dir: string | null; +} + +export interface Shares { + shares: ShareRecord[]; + totalCount: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class ShareService { + constructor( + private http: HttpClient, + private router: Router, + private route: ActivatedRoute, + ) {} + + inShare(): string | null { + if (this.router.url.startsWith('/s/')) { + return this.route.snapshot.paramMap.get('id'); + } + + return null; + } + + getShare(id: string): Observable { + return this.http.get(`/share/${id}`); + } + + deleteShare(id: string): Observable { + return this.http.delete(`/api/share/${id}`); + } + + getShares(p: ShareListParams): Observable { + return this.http.post('/api/share/', p); + } + + getSharedItem(s: Observable): Observable { + return s.pipe( + map((res) => { + switch (res.type) { + case 'call': + return res.sharedItem; + case 'incident': + return res.sharedItem; + } + + return null; + }), + ); + } + + getCallAudio(s: Observable): Observable { + return s.pipe( + switchMap((res) => { + return this.http.get(res.audioURL!); + }), + ); + } +} diff --git a/client/stillbox/src/app/shares.ts b/client/stillbox/src/app/shares.ts new file mode 100644 index 0000000..8902f74 --- /dev/null +++ b/client/stillbox/src/app/shares.ts @@ -0,0 +1,8 @@ +import { IncidentRecord } from './incidents'; +import { CallRecord } from './calls'; +export type ShareType = IncidentRecord | CallRecord | null; +export interface Share { + id: string; + type: string; + sharedItem: ShareType; +} diff --git a/client/stillbox/src/app/shares/shares.component.html b/client/stillbox/src/app/shares/shares.component.html new file mode 100644 index 0000000..d1e5fc7 --- /dev/null +++ b/client/stillbox/src/app/shares/shares.component.html @@ -0,0 +1,82 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + @switch (share.entityType) { + @case ("incident") { + newspaper + } + @case ("call") { + campaign + } + } + Link + link + Date + {{ share.entityDate | fmtDate }} + Owner + {{ share.owner }} + Delete + + delete + +
+
+
+ + +
+ +
+ +
+
diff --git a/client/stillbox/src/app/shares/shares.component.scss b/client/stillbox/src/app/shares/shares.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/stillbox/src/app/shares/shares.component.spec.ts b/client/stillbox/src/app/shares/shares.component.spec.ts new file mode 100644 index 0000000..107f8aa --- /dev/null +++ b/client/stillbox/src/app/shares/shares.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharesComponent } from './shares.component'; + +describe('SharesComponent', () => { + let component: SharesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SharesComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SharesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/stillbox/src/app/shares/shares.component.ts b/client/stillbox/src/app/shares/shares.component.ts new file mode 100644 index 0000000..c95a668 --- /dev/null +++ b/client/stillbox/src/app/shares/shares.component.ts @@ -0,0 +1,192 @@ +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 { 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'; +import { + ShareListParams, + ShareRecord, + ShareService, +} from '../share/share.service'; +import { FmtDatePipe } from '../incidents/incidents.component'; + +const reqPageSize = 200; + +@Component({ + selector: 'app-shares', + imports: [ + MatIconModule, + MatPaginatorModule, + MatTableModule, + MatFormFieldModule, + ReactiveFormsModule, + FormsModule, + FmtDatePipe, + MatInputModule, + MatCheckboxModule, + CommonModule, + MatProgressSpinnerModule, + ], + templateUrl: './shares.component.html', + styleUrl: './shares.component.scss', +}) +export class SharesComponent { + sharesResult = new BehaviorSubject(new Array(0)); + selection = new SelectionModel(true, []); + + @ViewChild('paginator') paginator!: MatPaginator; + count = 0; + curLen = 0; + page = 0; + perPage = 25; + pageSizeOptions = [25, 50, 75, 100, 200]; + columns = ['select', 'type', 'link', 'date', 'owner', 'delete']; + curPage = { pageIndex: 0, pageSize: 0 }; + currentSet!: ShareRecord[]; + currentServerPage = 0; // page is never 0, forces load + isLoading = true; + subscriptions = new Subscription(); + pageWindow = 0; + fetchIncidents = new BehaviorSubject( + this.buildParams(this.curPage, this.curPage.pageIndex), + ); + + constructor(private sharesSvc: ShareService) {} + + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.curLen; + return numSelected === numRows; + } + + buildParams(p: PageEvent, serverPage: number): ShareListParams { + const par: ShareListParams = { + page: serverPage, + perPage: reqPageSize, + dir: 'asc', + }; + + return par; + } + + masterToggle() { + this.isAllSelected() + ? this.selection.clear() + : this.sharesResult.value.forEach((row) => this.selection.select(row)); + } + + setPage(p: PageEvent, force?: boolean) { + this.selection.clear(); + this.curPage = p; + if (p && p!.pageSize != this.perPage) { + this.perPage = p!.pageSize; + } + this.getShares(p, force); + } + + refresh() { + this.selection.clear(); + this.getShares(this.curPage, true); + } + + getShares(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.sharesResult.next( + this.sharesResult + ? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize) + : [], + ); + } else { + this.currentServerPage = serverPage; + this.fetchIncidents.next(this.buildParams(p, serverPage)); + } + } + + zeroPage(): PageEvent { + return { + pageIndex: 0, + pageSize: this.curPage.pageSize, + }; + } + + ngOnDestroy() { + this.subscriptions.unsubscribe(); + } + + ngOnInit() { + let cpp = 25; + this.perPage = cpp; + + this.setPage({ + pageIndex: 0, + pageSize: cpp, + }); + this.subscriptions.add( + this.fetchIncidents + .pipe( + switchMap((params) => { + return this.sharesSvc.getShares(params); + }), + ) + .subscribe((shares) => { + this.isLoading = false; + this.count = shares.totalCount; + this.currentSet = shares.shares; + this.sharesResult.next( + this.currentSet + ? this.currentSet.slice( + this.pageWindow, + this.pageWindow + this.perPage, + ) + : [], + ); + }), + ); + this.subscriptions.add( + this.sharesResult.subscribe((cr) => { + this.curLen = cr.length; + }), + ); + } + + deleteShare(shareID: string) { + if (confirm('Are you sure you want to delete this share?')) { + this.sharesSvc.deleteShare(shareID).subscribe({ + next: () => { + this.fetchIncidents.next( + this.buildParams(this.curPage, this.curPage.pageIndex), + ); + }, + error: (err) => { + alert(err); + }, + }); + } + } +} diff --git a/client/stillbox/src/app/talkgroup.ts b/client/stillbox/src/app/talkgroup.ts index 7c8b2f7..d1e6a79 100644 --- a/client/stillbox/src/app/talkgroup.ts +++ b/client/stillbox/src/app/talkgroup.ts @@ -58,43 +58,39 @@ export const iconMapping: IconMap = { export class Talkgroup { id!: number; - system_id!: number; + systemId!: number; tgid!: number; name!: string; - alpha_tag!: string; - tg_group!: string; + alphaTag!: string; + tgGroup!: string; frequency!: number; metadata!: Metadata | null; tags!: string[]; alert!: boolean; system?: System; - alert_rules!: AlertRule[]; + alertRules!: AlertRule[]; weight!: number; learned?: boolean; icon?: string; iconSvg?: string; constructor( id: number, - system_id: number, + systemId: number, tgid: number, name: string, - alpha_tag: string, - tg_group: string, + alphaTag: string, + tgGroup: string, frequency: number, metadata: Metadata | null, tags: string[], alert: boolean, - alert_rules: AlertRule[], + alertRules: AlertRule[], weight: number, system?: System, learned?: boolean, icon?: string, ) { this.iconSvg = this.iconMap(this.metadata?.icon!); - this.alert_rules = this.alert_rules.map((x) => - Object.assign(new AlertRule(), x), - ); - console.log(this.alert_rules); } iconMap(icon: string): string { @@ -103,7 +99,7 @@ export class Talkgroup { tgTuple(): TGID { return { - sys: this.system_id, + sys: this.systemId, tg: this.tgid, }; } @@ -115,15 +111,15 @@ export interface TalkgroupUI extends Talkgroup { export interface TalkgroupUpdate { id: number; - system_id: number; + systemId: number; tgid: number; name: string | null; - alpha_tag: string | null; - tg_group: string | null; + alphaTag: string | null; + tgGroup: string | null; frequency: number | null; metadata: Object | null; tags: string[] | null; alert: boolean | null; - alert_rules: AlertRule[] | null; + alertRules: AlertRule[] | null; weight: number | null; } diff --git a/client/stillbox/src/app/talkgroups/import/import.component.html b/client/stillbox/src/app/talkgroups/import/import.component.html index bc3c8d1..4aec0f5 100644 --- a/client/stillbox/src/app/talkgroups/import/import.component.html +++ b/client/stillbox/src/app/talkgroups/import/import.component.html @@ -55,8 +55,8 @@ {{ tg.system?.name }} {{ tg.system?.id }} - {{ tg.tg_group }} - {{ tg.alpha_tag }} + {{ tg.tgGroup }} + {{ tg.alphaTag }} {{ tg.name }} {{ tg.tgid }} {{ tg?.metadata?.encrypted ? "E" : "" }} diff --git a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html index 9e8a867..25ead7a 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html +++ b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html @@ -15,9 +15,9 @@ Alpha Tag
@@ -26,9 +26,9 @@ Group
@@ -108,7 +108,7 @@ >
- +
} @else { diff --git a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts index ae0937c..967fc50 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts +++ b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.ts @@ -84,8 +84,8 @@ export class TalkgroupRecordComponent { readonly _allTags: Observable; form = new FormGroup({ name: new FormControl(''), - alpha_tag: new FormControl(''), - tg_group: new FormControl(''), + alphaTag: new FormControl(''), + tgGroup: new FormControl(''), frequency: new FormControl(0), alert: new FormControl(false), weight: new FormControl(0.0), @@ -158,9 +158,8 @@ export class TalkgroupRecordComponent { .getTalkgroup(Number(this.tgid.sys), Number(this.tgid.tg)) .pipe( tap((tg) => { - console.log('tap run'); - tg.alert_rules = tg.alert_rules - ? tg.alert_rules.map((x) => Object.assign(new AlertRule(), x)) + tg.alertRules = tg.alertRules + ? tg.alertRules.map((x) => Object.assign(new AlertRule(), x)) : []; this.form.patchValue(tg); this.form.controls['tagInput'].setValue(''); @@ -181,17 +180,17 @@ export class TalkgroupRecordComponent { save() { let tgu: TalkgroupUpdate = { - system_id: this.tgid.sys, + systemId: this.tgid.sys, tgid: this.tgid.tg, }; if (this.form.controls['name'].dirty) { tgu.name = this.form.controls['name'].value; } - if (this.form.controls['alpha_tag'].dirty) { - tgu.alpha_tag = this.form.controls['alpha_tag'].value; + if (this.form.controls['alphaTag'].dirty) { + tgu.alphaTag = this.form.controls['alphaTag'].value; } - if (this.form.controls['tg_group'].dirty) { - tgu.tg_group = this.form.controls['tg_group'].value; + if (this.form.controls['tgGroup'].dirty) { + tgu.tgGroup = this.form.controls['tgGroup'].value; } if (this.form.controls['frequency'].dirty) { tgu.frequency = this.form.controls['frequency'].value; diff --git a/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html b/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html index 780f1b3..31a8635 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html +++ b/client/stillbox/src/app/talkgroups/talkgroup-table/talkgroup-table.component.html @@ -34,7 +34,7 @@ Group - {{ tg.tg_group }} + {{ tg.tgGroup }} Name @@ -42,7 +42,7 @@ Alpha Tag - {{ tg.alpha_tag }} + {{ tg.alphaTag }} TG ID diff --git a/client/stillbox/src/app/talkgroups/talkgroups.service.ts b/client/stillbox/src/app/talkgroups/talkgroups.service.ts index 0896b64..fee62a3 100644 --- a/client/stillbox/src/app/talkgroups/talkgroups.service.ts +++ b/client/stillbox/src/app/talkgroups/talkgroups.service.ts @@ -2,14 +2,17 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { BehaviorSubject, - concatMap, Observable, ReplaySubject, shareReplay, + Subject, Subscription, switchMap, } from 'rxjs'; import { Talkgroup, TalkgroupUpdate, TGID } from '../talkgroup'; +import { Share } from '../shares'; +import { ShareService } from '../share/share.service'; +import { AuthService } from '../login/auth.service'; export interface Pagination { page: number; @@ -29,30 +32,57 @@ export class TalkgroupService { private readonly _getTalkgroup = new Map>(); private tgs$: Observable; private tags$!: Observable; - private fetchAll = new BehaviorSubject<'fetch'>('fetch'); + private fetchAll = new ReplaySubject(); private subscriptions = new Subscription(); - constructor(private http: HttpClient) { - this.tgs$ = this.fetchAll.pipe(switchMap(() => this.getTalkgroups())); + constructor( + private http: HttpClient, + private shareSvc: ShareService, + private authSvc: AuthService, + ) { + this.tgs$ = this.fetchAll.pipe( + switchMap((share) => this.getTalkgroups(share)), + shareReplay(), + ); this.tags$ = this.fetchAll.pipe( switchMap(() => this.getAllTags()), shareReplay(), ); + let sh = this.shareSvc.inShare(); + if (sh) { + this.shareSvc.getShare(sh).subscribe(this.fetchAll); + } else { + if (this.authSvc.isAuth()) { + this.fetchAll.next(null); + } + } this.fillTgMap(); } + setShare(share: Share | null) { + if (!this.authSvc.isAuth() && share !== null) { + this.fetchAll.next(share); + } + } + ngOnDestroy() { this.subscriptions.unsubscribe(); } getAllTags(): Observable { - return this.http.get('/api/talkgroup/tags').pipe(shareReplay()); + return this.http.get('/api/talkgroup/tags'); } - getTalkgroups(): Observable { - return this.http.get('/api/talkgroup/').pipe(shareReplay()); + getTalkgroups(share: Share | null): Observable { + return this.http.get( + share ? `/share/${share.id}/talkgroups` : '/api/talkgroup/', + ); } - getTalkgroup(sys: number, tg: number): Observable { + getTalkgroup( + sys: number, + tg: number, + share: Share | null = null, + ): Observable { const key = this.tgKey(sys, tg); if (!this._getTalkgroup.get(key)) { let rs = new ReplaySubject(); @@ -62,10 +92,10 @@ export class TalkgroupService { } putTalkgroup(tu: TalkgroupUpdate): Observable { - let tgid = this.tgKey(tu.system_id, tu.tgid); + let tgid = this.tgKey(tu.systemId, tu.tgid); return this.http - .put(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu) + .put(`/api/talkgroup/${tu.systemId}/${tu.tgid}`, tu) .pipe( switchMap((tg) => { let tObs = this._getTalkgroup.get(tgid); @@ -100,7 +130,7 @@ export class TalkgroupService { this.subscriptions.add( this.tgs$.subscribe((tgs) => { tgs.forEach((tg) => { - let tgid = this.tgKey(tg.system_id, tg.tgid); + let tgid = this.tgKey(tg.systemId, tg.tgid); const rs = this._getTalkgroup.get(tgid); if (rs) { (rs as ReplaySubject).next(tg); diff --git a/client/stillbox/src/proxy.conf.json b/client/stillbox/src/proxy.conf.json index 96af567..0a8777d 100644 --- a/client/stillbox/src/proxy.conf.json +++ b/client/stillbox/src/proxy.conf.json @@ -1,5 +1,9 @@ { - "/api": { + "/api/": { + "target": "http://xenon:3050", + "secure": false + }, + "/share/": { "target": "http://xenon:3050", "secure": false } diff --git a/cmd/calls/main.go b/cmd/calls/main.go index 9be38b3..3f73c2d 100644 --- a/cmd/calls/main.go +++ b/cmd/calls/main.go @@ -1,9 +1,11 @@ package main import ( + "bufio" "encoding/json" "errors" "flag" + "fmt" "log" "net/http" "net/http/cookiejar" @@ -11,10 +13,12 @@ import ( "os" "os/signal" "strings" + "syscall" "time" "dynatron.me/x/stillbox/internal/version" "dynatron.me/x/stillbox/pkg/pb" + "golang.org/x/term" "github.com/gorilla/websocket" "google.golang.org/protobuf/proto" @@ -37,6 +41,31 @@ func userAgent(h http.Header) { h.Set("User-Agent", uaString) } +func getCreds() { + rdr := bufio.NewReader(os.Stdin) + if username == nil || *username == "" { + fmt.Print("Username: ") + un, err := rdr.ReadString('\n') + if err != nil { + panic(err) + } + + username = &un + } + + if password == nil || *password == "" { + fmt.Print("Password: ") + bytePass, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + panic(err) + } + + pS := string(bytePass) + pS = strings.Trim(pS, "\n") + password = &pS + } +} + func main() { flag.Parse() log.SetFlags(0) @@ -53,6 +82,8 @@ func main() { signal.Notify(interrupt, os.Interrupt) play := NewPlayer() + getCreds() + loginForm := url.Values{} loginForm.Add("username", *username) loginForm.Add("password", *password) diff --git a/go.mod b/go.mod index a766b05..6491122 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.2 require ( dynatron.me/x/go-minimp3 v0.0.0-20240805171536-7ea857e216d6 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de + github.com/el-mike/restrict/v2 v2.0.0 github.com/go-audio/wav v1.1.0 github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/cors v1.2.1 @@ -21,6 +22,8 @@ require ( github.com/knadh/koanf/providers/env v1.0.0 github.com/knadh/koanf/providers/file v1.1.2 github.com/knadh/koanf/v2 v2.1.2 + github.com/lestrrat-go/jwx/v2 v2.1.3 + github.com/matoous/go-nanoid v1.5.1 github.com/nikoksr/notify v1.1.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 @@ -28,7 +31,7 @@ require ( github.com/urfave/cli/v2 v2.27.5 golang.org/x/crypto v0.29.0 golang.org/x/sync v0.9.0 - golang.org/x/term v0.26.0 + golang.org/x/term v0.28.0 google.golang.org/protobuf v1.35.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -39,7 +42,6 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/el-mike/restrict/v2 v2.0.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-audio/audio v1.0.0 // indirect github.com/go-audio/riff v1.0.0 // indirect @@ -55,9 +57,7 @@ require ( github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/jwx/v2 v2.1.3 // indirect github.com/lestrrat-go/option v1.0.1 // indirect - github.com/matoous/go-nanoid v1.5.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -71,6 +71,6 @@ require ( golang.org/x/exp/shiny v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/image v0.22.0 // indirect golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f // indirect - golang.org/x/sys v0.27.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 26398c9..4df4dff 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,6 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= -github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= -github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/casbin/casbin/v2 v2.103.0 h1:dHElatNXNrr8XcseUov0ZSiWjauwmZZE6YMV3eU1yic= -github.com/casbin/casbin/v2 v2.103.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco= -github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= -github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -69,7 +63,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -117,8 +110,6 @@ github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCG github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc= -github.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y= github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo= github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= @@ -176,7 +167,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -196,7 +186,6 @@ go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HY go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -208,24 +197,20 @@ golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4 golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f h1:23H/YlmTHfmmvpZ+ajKZL0qLz0+IwFOIqQA0mQbmLeM= golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f/go.mod h1:UbSUP4uu/C9hw9R2CkojhXlAxvayHjBdU9aRvE+c1To= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/alerting/alerting.go b/pkg/alerting/alerting.go index e580aed..769781b 100644 --- a/pkg/alerting/alerting.go +++ b/pkg/alerting/alerting.go @@ -38,6 +38,7 @@ type Alerter interface { Enabled() bool Go(context.Context) + HUP(*config.Config) stats } @@ -101,9 +102,7 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte tgCache: tgCache, } - if cfg.Renotify != nil { - as.renotify = cfg.Renotify.Duration() - } + as.reload() for _, opt := range opts { opt(as) @@ -122,6 +121,21 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte return as } +func (as *alerter) reload() { + if as.cfg.Renotify != nil { + as.renotify = as.cfg.Renotify.Duration() + } +} + +func (as *alerter) HUP(cfg *config.Config) { + as.Lock() + defer as.Unlock() + + log.Debug().Msg("reloading alert config") + as.cfg = cfg.Alerting + as.reload() +} + // Go is the alerting loop. It does not start a goroutine. func (as *alerter) Go(ctx context.Context) { ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"}) @@ -166,7 +180,12 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]al for _, s := range as.scores { origScore := s.Score tgr, err := as.tgCache.TG(ctx, s.ID) - if err != nil || !tgr.Talkgroup.Alert { + if err != nil { + log.Error().Err(err).Msg("alerting eval tg get") + continue + } + + if !tgr.Talkgroup.Alert { continue } @@ -376,3 +395,4 @@ func (*noopAlerter) SinkType() string { return "noopA func (*noopAlerter) Call(_ context.Context, _ *calls.Call) error { return nil } func (*noopAlerter) Go(_ context.Context) {} func (*noopAlerter) Enabled() bool { return false } +func (*noopAlerter) HUP(_ *config.Config) {} diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index 896918c..46a45a7 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -34,8 +34,8 @@ type jwtAuth interface { // InstallVerifyMiddleware installs the JWT verifier middleware to the provided chi Router. VerifyMiddleware() func(http.Handler) http.Handler - // InstallAuthMiddleware installs the JWT authenticator middleware to the provided chi Router. - AuthMiddleware() func(http.Handler) http.Handler + // SubjectMiddleware sets the request context subject from JWT or public. + SubjectMiddleware(requireAuth bool) func(http.Handler) http.Handler // PublicRoutes installs the auth route to the provided chi Router. PublicRoutes(chi.Router) @@ -84,22 +84,39 @@ func TokenFromCookie(r *http.Request) string { return cookie.Value } -func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler { +func (a *Auth) PublicSubjectMiddleware() func(http.Handler) http.Handler { + return a.SubjectMiddleware(false) +} + +func (a *Auth) AuthorizedSubjectMiddleware() func(http.Handler) http.Handler { + return a.SubjectMiddleware(true) +} + +func (a *Auth) SubjectMiddleware(requireToken bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { hfn := func(w http.ResponseWriter, r *http.Request) { token, _, err := jwtauth.FromContext(r.Context()) - if err != nil { + if err != nil && requireToken { http.Error(w, err.Error(), http.StatusUnauthorized) return } - if token != nil && jwt.Validate(token, a.jwt.ValidateOptions()...) == nil { - ctx := r.Context() + ctx := r.Context() + + if token != nil { + err := jwt.Validate(token, a.jwt.ValidateOptions()...) + if err != nil { + err = jwtauth.ErrorReason(err) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + username := token.Subject() sub, err := users.FromCtx(ctx).GetUser(ctx, username) if err != nil { + log.Error().Str("username", username).Err(err).Msg("subject middleware get subject") http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } @@ -111,8 +128,9 @@ func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler { return } - // Token is authenticated, pass it through - next.ServeHTTP(w, r) + // Public subject + ctx = entities.CtxWithSubject(ctx, entities.NewPublicSubject(r)) + next.ServeHTTP(w, r.WithContext(ctx)) } return http.HandlerFunc(hfn) } @@ -211,7 +229,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) { } if cookie.Secure { - cookie.Domain = r.Host + cookie.Domain = strings.Split(r.Host, ":")[0] } http.SetCookie(w, cookie) @@ -271,7 +289,7 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) { MaxAge: 60 * 60 * 24 * 30, // one month } - cookie.Domain = r.Host + cookie.Domain = strings.Split(r.Host, ":")[0] if a.allowInsecureCookie(r) { a.setInsecureCookie(cookie) } @@ -297,7 +315,7 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) { MaxAge: -1, } - cookie.Domain = r.Host + cookie.Domain = strings.Split(r.Host, ":")[0] if a.allowInsecureCookie(r) { cookie.Secure = true cookie.SameSite = http.SameSiteNoneMode diff --git a/pkg/calls/call.go b/pkg/calls/call.go index 3e7ab4c..1639327 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -3,9 +3,11 @@ package calls import ( "encoding/json" "fmt" + "net/url" "time" "dynatron.me/x/stillbox/internal/audio" + "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/rbac/entities" @@ -22,6 +24,13 @@ func (d CallDuration) Duration() time.Duration { return time.Duration(d) } +func (d CallDuration) ColonFormat() string { + dur := d.Duration().Round(time.Second) + m := dur / time.Minute + s := dur / time.Second + return fmt.Sprintf("%d:%02d", m, s) +} + func (d CallDuration) MsInt32Ptr() *int32 { if time.Duration(d) == 0 { return nil @@ -59,18 +68,19 @@ type Call struct { AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"` AudioURL *string `json:"audioURL,omitempty" relayOut:"audioURL,omitempty"` Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"` - DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"` + DateTime time.Time `json:"callDate,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"` + System int `json:"systemId,omitempty" relayOut:"system,omitempty"` Submitter *users.UserID `json:"submitter,omitempty" relayOut:"submitter,omitempty"` - SystemLabel string `json:"system_name,omitempty" relayOut:"systemLabel,omitempty"` + SystemLabel string `json:"systemName,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"` + TGAlphaTag *string `json:"tgAlphaTag,omitempty" relayOut:"talkgroupTag,omitempty"` + Transcript *string `json:"transcript" relayOut:"transcript,omitempty"` shouldStore bool `json:"-"` } @@ -87,6 +97,15 @@ func (c *Call) ShouldStore() bool { return c.shouldStore } +func (c *Call) SetShareURL(baseURL url.URL, shareID string) { + if c.AudioURL != nil { + return + } + + baseURL.Path = fmt.Sprintf("/share/%s/call", shareID) + c.AudioURL = common.PtrTo(baseURL.String()) +} + func Make(call *Call, dontStore bool) (*Call, error) { err := call.computeLength() if err != nil { diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go index 85aed7a..297ddbb 100644 --- a/pkg/calls/callstore/store.go +++ b/pkg/calls/callstore/store.go @@ -74,6 +74,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams { AudioName: common.NilIfZero(call.AudioName), AudioBlob: call.Audio, AudioType: common.NilIfZero(call.AudioType), + AudioUrl: call.AudioURL, Duration: call.Duration.MsInt32Ptr(), Frequency: call.Frequency, Frequencies: call.Frequencies, @@ -145,7 +146,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, } func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) { - _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead)) + _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -178,6 +179,7 @@ func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) { TalkgroupLabel: c.TGLabel, TalkgroupGroup: c.TGGroup, TGAlphaTag: c.TGAlphaTag, + Transcript: c.Transcript, }, nil } diff --git a/pkg/database/batch.go b/pkg/database/batch.go index 3001e0d..e7cf721 100644 --- a/pkg/database/batch.go +++ b/pkg/database/batch.go @@ -57,7 +57,7 @@ type StoreTGVersionBatchResults struct { type StoreTGVersionParams struct { Submitter *int32 `json:"submitter"` - SystemID int32 `json:"system_id"` + SystemID int32 `json:"systemId"` TGID int32 `json:"tgid"` } @@ -135,16 +135,16 @@ type UpsertTalkgroupBatchResults struct { } type UpsertTalkgroupParams struct { - SystemID int32 `json:"system_id"` + SystemID int32 `json:"systemId"` TGID int32 `json:"tgid"` Name *string `json:"name"` - AlphaTag *string `json:"alpha_tag"` - TGGroup *string `json:"tg_group"` + AlphaTag *string `json:"alphaTag"` + TGGroup *string `json:"tgGroup"` Frequency *int32 `json:"frequency"` Metadata jsontypes.Metadata `json:"metadata"` Tags []string `json:"tags"` Alert interface{} `json:"alert"` - AlertRules rules.AlertRules `json:"alert_rules"` + AlertRules rules.AlertRules `json:"alertRules"` Weight pgtype.Numeric `json:"weight"` Learned *bool `json:"learned"` } diff --git a/pkg/database/calls.sql.go b/pkg/database/calls.sql.go index f86c2df..0d8c2d0 100644 --- a/pkg/database/calls.sql.go +++ b/pkg/database/calls.sql.go @@ -30,10 +30,10 @@ VALUES type AddAlertParams struct { Time pgtype.Timestamptz `json:"time"` TGID int `json:"tgid"` - SystemID int `json:"system_id"` + SystemID int `json:"systemId"` Weight *float32 `json:"weight"` Score *float32 `json:"score"` - OrigScore *float32 `json:"orig_score"` + OrigScore *float32 `json:"origScore"` Notified bool `json:"notified"` Metadata []byte `json:"metadata"` } @@ -97,18 +97,18 @@ type AddCallParams struct { Submitter *int32 `json:"submitter"` System int `json:"system"` Talkgroup int `json:"talkgroup"` - CallDate pgtype.Timestamptz `json:"call_date"` - AudioName *string `json:"audio_name"` - AudioBlob []byte `json:"audio_blob"` - AudioType *string `json:"audio_type"` - AudioUrl *string `json:"audio_url"` + CallDate pgtype.Timestamptz `json:"callDate"` + AudioName *string `json:"audioName"` + AudioBlob []byte `json:"audioBlob"` + AudioType *string `json:"audioType"` + AudioUrl *string `json:"audioUrl"` Duration *int32 `json:"duration"` Frequency int `json:"frequency"` Frequencies []int `json:"frequencies"` Patches []int `json:"patches"` - TGLabel *string `json:"tg_label"` - TGAlphaTag *string `json:"tg_alpha_tag"` - TGGroup *string `json:"tg_group"` + TGLabel *string `json:"tgLabel"` + TGAlphaTag *string `json:"tgAlphaTag"` + TGGroup *string `json:"tgGroup"` Source int `json:"source"` } @@ -181,7 +181,8 @@ SELECT tg_label, tg_alpha_tag, tg_group, - source + source, + transcript FROM calls WHERE id = $1 ` @@ -191,18 +192,19 @@ type GetCallRow struct { Submitter *int32 `json:"submitter"` System int `json:"system"` Talkgroup int `json:"talkgroup"` - CallDate pgtype.Timestamptz `json:"call_date"` - AudioName *string `json:"audio_name"` - AudioType *string `json:"audio_type"` - AudioUrl *string `json:"audio_url"` + CallDate pgtype.Timestamptz `json:"callDate"` + AudioName *string `json:"audioName"` + AudioType *string `json:"audioType"` + AudioUrl *string `json:"audioUrl"` Duration *int32 `json:"duration"` Frequency int `json:"frequency"` Frequencies []int `json:"frequencies"` Patches []int `json:"patches"` - TGLabel *string `json:"tg_label"` - TGAlphaTag *string `json:"tg_alpha_tag"` - TGGroup *string `json:"tg_group"` + TGLabel *string `json:"tgLabel"` + TGAlphaTag *string `json:"tgAlphaTag"` + TGGroup *string `json:"tgGroup"` Source int `json:"source"` + Transcript *string `json:"transcript"` } func (q *Queries) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) { @@ -225,6 +227,7 @@ func (q *Queries) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) &i.TGAlphaTag, &i.TGGroup, &i.Source, + &i.Transcript, ) return i, err } @@ -248,10 +251,10 @@ WHERE sc.id = $1 ` type GetCallAudioByIDRow struct { - CallDate pgtype.Timestamptz `json:"call_date"` - AudioName *string `json:"audio_name"` - AudioType *string `json:"audio_type"` - AudioBlob []byte `json:"audio_blob"` + CallDate pgtype.Timestamptz `json:"callDate"` + AudioName *string `json:"audioName"` + AudioType *string `json:"audioType"` + AudioBlob []byte `json:"audioBlob"` } func (q *Queries) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error) { @@ -315,10 +318,10 @@ CASE WHEN $4::TEXT[] IS NOT NULL THEN type ListCallsCountParams struct { Start pgtype.Timestamptz `json:"start"` End pgtype.Timestamptz `json:"end"` - TagsAny []string `json:"tags_any"` - TagsNot []string `json:"tags_not"` - TGFilter *string `json:"tg_filter"` - LongerThan pgtype.Numeric `json:"longer_than"` + TagsAny []string `json:"tagsAny"` + TagsNot []string `json:"tagsNot"` + TGFilter *string `json:"tgFilter"` + LongerThan pgtype.Numeric `json:"longerThan"` } func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) { @@ -375,20 +378,20 @@ FETCH NEXT $9 ROWS ONLY type ListCallsPParams struct { Start pgtype.Timestamptz `json:"start"` End pgtype.Timestamptz `json:"end"` - TagsAny []string `json:"tags_any"` - TagsNot []string `json:"tags_not"` - TGFilter *string `json:"tg_filter"` - LongerThan pgtype.Numeric `json:"longer_than"` + TagsAny []string `json:"tagsAny"` + TagsNot []string `json:"tagsNot"` + TGFilter *string `json:"tgFilter"` + LongerThan pgtype.Numeric `json:"longerThan"` Direction string `json:"direction"` Offset int32 `json:"offset"` - PerPage int32 `json:"per_page"` + PerPage int32 `json:"perPage"` } type ListCallsPRow struct { ID uuid.UUID `json:"id"` - CallDate pgtype.Timestamptz `json:"call_date"` + CallDate pgtype.Timestamptz `json:"callDate"` Duration *int32 `json:"duration"` - SystemID int `json:"system_id"` + SystemID int `json:"systemId"` TGID int `json:"tgid"` Incidents int64 `json:"incidents"` } diff --git a/pkg/database/incidents.sql.go b/pkg/database/incidents.sql.go index b233955..6e1491f 100644 --- a/pkg/database/incidents.sql.go +++ b/pkg/database/incidents.sql.go @@ -61,6 +61,7 @@ INSERT INTO incidents ( name, owner, description, + created_at, start_time, end_time, location, @@ -70,12 +71,13 @@ INSERT INTO incidents ( $2, $3, $4, + NOW(), $5, $6, $7, $8 ) -RETURNING id, name, owner, description, start_time, end_time, location, metadata +RETURNING id, name, owner, description, created_at, start_time, end_time, location, metadata ` type CreateIncidentParams struct { @@ -83,8 +85,8 @@ type CreateIncidentParams struct { Name string `json:"name"` Owner int `json:"owner"` Description *string `json:"description"` - StartTime pgtype.Timestamptz `json:"start_time"` - EndTime pgtype.Timestamptz `json:"end_time"` + StartTime pgtype.Timestamptz `json:"startTime"` + EndTime pgtype.Timestamptz `json:"endTime"` Location []byte `json:"location"` Metadata jsontypes.Metadata `json:"metadata"` } @@ -106,6 +108,7 @@ func (q *Queries) CreateIncident(ctx context.Context, arg CreateIncidentParams) &i.Name, &i.Owner, &i.Description, + &i.CreatedAt, &i.StartTime, &i.EndTime, &i.Location, @@ -129,6 +132,7 @@ SELECT i.name, i.owner, i.description, + i.created_at, i.start_time, i.end_time, i.location, @@ -145,6 +149,7 @@ func (q *Queries) GetIncident(ctx context.Context, id uuid.UUID) (Incident, erro &i.Name, &i.Owner, &i.Description, + &i.CreatedAt, &i.StartTime, &i.EndTime, &i.Location, @@ -206,16 +211,16 @@ ORDER BY ic.call_date ASC ` type GetIncidentCallsRow struct { - CallID uuid.UUID `json:"call_id"` - CallDate pgtype.Timestamptz `json:"call_date"` + CallID uuid.UUID `json:"callId"` + CallDate pgtype.Timestamptz `json:"callDate"` Duration *int32 `json:"duration"` - SystemID int `json:"system_id"` + SystemID int `json:"systemId"` TGID int `json:"tgid"` Notes []byte `json:"notes"` Submitter *int32 `json:"submitter"` - AudioName *string `json:"audio_name"` - AudioType *string `json:"audio_type"` - AudioUrl *string `json:"audio_url"` + AudioName *string `json:"audioName"` + AudioType *string `json:"audioType"` + AudioUrl *string `json:"audioUrl"` Frequency int `json:"frequency"` Frequencies []int `json:"frequencies"` Patches []int `json:"patches"` @@ -297,6 +302,7 @@ SELECT i.name, i.owner, i.description, + i.created_at, i.start_time, i.end_time, i.location, @@ -327,7 +333,7 @@ type ListIncidentsPParams struct { Filter *string `json:"filter"` Direction string `json:"direction"` Offset int32 `json:"offset"` - PerPage int32 `json:"per_page"` + PerPage int32 `json:"perPage"` } type ListIncidentsPRow struct { @@ -335,11 +341,12 @@ type ListIncidentsPRow struct { Name string `json:"name"` Owner int `json:"owner"` Description *string `json:"description"` - StartTime pgtype.Timestamptz `json:"start_time"` - EndTime pgtype.Timestamptz `json:"end_time"` + CreatedAt pgtype.Timestamptz `json:"createdAt"` + StartTime pgtype.Timestamptz `json:"startTime"` + EndTime pgtype.Timestamptz `json:"endTime"` Location []byte `json:"location"` Metadata jsontypes.Metadata `json:"metadata"` - CallsCount int64 `json:"calls_count"` + CallsCount int64 `json:"callsCount"` } func (q *Queries) ListIncidentsP(ctx context.Context, arg ListIncidentsPParams) ([]ListIncidentsPRow, error) { @@ -363,6 +370,7 @@ func (q *Queries) ListIncidentsP(ctx context.Context, arg ListIncidentsPParams) &i.Name, &i.Owner, &i.Description, + &i.CreatedAt, &i.StartTime, &i.EndTime, &i.Location, @@ -411,14 +419,14 @@ SET metadata = COALESCE($6, metadata) WHERE id = $7 -RETURNING id, name, owner, description, start_time, end_time, location, metadata +RETURNING id, name, owner, description, created_at, 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"` + StartTime pgtype.Timestamptz `json:"startTime"` + EndTime pgtype.Timestamptz `json:"endTime"` Location []byte `json:"location"` Metadata jsontypes.Metadata `json:"metadata"` ID uuid.UUID `json:"id"` @@ -440,6 +448,7 @@ func (q *Queries) UpdateIncident(ctx context.Context, arg UpdateIncidentParams) &i.Name, &i.Owner, &i.Description, + &i.CreatedAt, &i.StartTime, &i.EndTime, &i.Location, diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index ad4ad0a..be3d593 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -1924,6 +1924,122 @@ func (_c *Store_GetShare_Call) RunAndReturn(run func(context.Context, string) (d return _c } +// GetSharesP provides a mock function with given fields: ctx, arg +func (_m *Store) GetSharesP(ctx context.Context, arg database.GetSharesPParams) ([]database.GetSharesPRow, error) { + ret := _m.Called(ctx, arg) + + if len(ret) == 0 { + panic("no return value specified for GetSharesP") + } + + var r0 []database.GetSharesPRow + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, database.GetSharesPParams) ([]database.GetSharesPRow, error)); ok { + return rf(ctx, arg) + } + if rf, ok := ret.Get(0).(func(context.Context, database.GetSharesPParams) []database.GetSharesPRow); ok { + r0 = rf(ctx, arg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.GetSharesPRow) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, database.GetSharesPParams) error); ok { + r1 = rf(ctx, arg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetSharesP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSharesP' +type Store_GetSharesP_Call struct { + *mock.Call +} + +// GetSharesP is a helper method to define mock.On call +// - ctx context.Context +// - arg database.GetSharesPParams +func (_e *Store_Expecter) GetSharesP(ctx interface{}, arg interface{}) *Store_GetSharesP_Call { + return &Store_GetSharesP_Call{Call: _e.mock.On("GetSharesP", ctx, arg)} +} + +func (_c *Store_GetSharesP_Call) Run(run func(ctx context.Context, arg database.GetSharesPParams)) *Store_GetSharesP_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(database.GetSharesPParams)) + }) + return _c +} + +func (_c *Store_GetSharesP_Call) Return(_a0 []database.GetSharesPRow, _a1 error) *Store_GetSharesP_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetSharesP_Call) RunAndReturn(run func(context.Context, database.GetSharesPParams) ([]database.GetSharesPRow, error)) *Store_GetSharesP_Call { + _c.Call.Return(run) + return _c +} + +// GetSharesPCount provides a mock function with given fields: ctx, owner +func (_m *Store) GetSharesPCount(ctx context.Context, owner *int32) (int64, error) { + ret := _m.Called(ctx, owner) + + if len(ret) == 0 { + panic("no return value specified for GetSharesPCount") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *int32) (int64, error)); ok { + return rf(ctx, owner) + } + if rf, ok := ret.Get(0).(func(context.Context, *int32) int64); ok { + r0 = rf(ctx, owner) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, *int32) error); ok { + r1 = rf(ctx, owner) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetSharesPCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSharesPCount' +type Store_GetSharesPCount_Call struct { + *mock.Call +} + +// GetSharesPCount is a helper method to define mock.On call +// - ctx context.Context +// - owner *int32 +func (_e *Store_Expecter) GetSharesPCount(ctx interface{}, owner interface{}) *Store_GetSharesPCount_Call { + return &Store_GetSharesPCount_Call{Call: _e.mock.On("GetSharesPCount", ctx, owner)} +} + +func (_c *Store_GetSharesPCount_Call) Run(run func(ctx context.Context, owner *int32)) *Store_GetSharesPCount_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*int32)) + }) + return _c +} + +func (_c *Store_GetSharesPCount_Call) Return(_a0 int64, _a1 error) *Store_GetSharesPCount_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetSharesPCount_Call) RunAndReturn(run func(context.Context, *int32) (int64, error)) *Store_GetSharesPCount_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) diff --git a/pkg/database/models.go b/pkg/database/models.go index 585f860..0d80a21 100644 --- a/pkg/database/models.go +++ b/pkg/database/models.go @@ -17,10 +17,10 @@ type Alert struct { ID int `json:"id,omitempty"` Time pgtype.Timestamptz `json:"time,omitempty"` TGID int `json:"tgid,omitempty"` - SystemID int `json:"system_id,omitempty"` + SystemID int `json:"systemId,omitempty"` Weight *float32 `json:"weight,omitempty"` Score *float32 `json:"score,omitempty"` - OrigScore *float32 `json:"orig_score,omitempty"` + OrigScore *float32 `json:"origScore,omitempty"` Notified bool `json:"notified,omitempty"` Metadata []byte `json:"metadata,omitempty"` } @@ -28,10 +28,10 @@ type Alert struct { type ApiKey struct { ID int `json:"id,omitempty"` Owner int `json:"owner,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` Expires pgtype.Timestamp `json:"expires,omitempty"` Disabled *bool `json:"disabled,omitempty"` - ApiKey string `json:"api_key,omitempty"` + ApiKey string `json:"apiKey,omitempty"` } type Call struct { @@ -39,18 +39,18 @@ type Call struct { Submitter *int32 `json:"submitter,omitempty"` System int `json:"system,omitempty"` Talkgroup int `json:"talkgroup,omitempty"` - CallDate pgtype.Timestamptz `json:"call_date,omitempty"` - AudioName *string `json:"audio_name,omitempty"` - AudioBlob []byte `json:"audio_blob,omitempty"` + CallDate pgtype.Timestamptz `json:"callDate,omitempty"` + AudioName *string `json:"audioName,omitempty"` + AudioBlob []byte `json:"audioBlob,omitempty"` Duration *int32 `json:"duration,omitempty"` - AudioType *string `json:"audio_type,omitempty"` - AudioUrl *string `json:"audio_url,omitempty"` + AudioType *string `json:"audioType,omitempty"` + AudioUrl *string `json:"audioUrl,omitempty"` Frequency int `json:"frequency,omitempty"` Frequencies []int `json:"frequencies,omitempty"` Patches []int `json:"patches,omitempty"` - TGLabel *string `json:"tg_label,omitempty"` - TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` + TGLabel *string `json:"tgLabel,omitempty"` + TGAlphaTag *string `json:"tgAlphaTag,omitempty"` + TGGroup *string `json:"tgGroup,omitempty"` Source int `json:"source,omitempty"` Transcript *string `json:"transcript,omitempty"` } @@ -60,32 +60,33 @@ type Incident struct { Name string `json:"name,omitempty"` Owner int `json:"owner,omitempty"` Description *string `json:"description,omitempty"` - StartTime pgtype.Timestamptz `json:"start_time,omitempty"` - EndTime pgtype.Timestamptz `json:"end_time,omitempty"` + CreatedAt pgtype.Timestamptz `json:"createdAt,omitempty"` + StartTime pgtype.Timestamptz `json:"startTime,omitempty"` + EndTime pgtype.Timestamptz `json:"endTime,omitempty"` Location []byte `json:"location,omitempty"` Metadata jsontypes.Metadata `json:"metadata,omitempty"` } type IncidentsCall struct { - IncidentID uuid.UUID `json:"incident_id,omitempty"` - CallID uuid.UUID `json:"call_id,omitempty"` - CallsTblID pgtype.UUID `json:"calls_tbl_id,omitempty"` - SweptCallID pgtype.UUID `json:"swept_call_id,omitempty"` - CallDate pgtype.Timestamptz `json:"call_date,omitempty"` + IncidentID uuid.UUID `json:"incidentId,omitempty"` + CallID uuid.UUID `json:"callId,omitempty"` + CallsTblID pgtype.UUID `json:"callsTblId,omitempty"` + SweptCallID pgtype.UUID `json:"sweptCallId,omitempty"` + CallDate pgtype.Timestamptz `json:"callDate,omitempty"` Notes []byte `json:"notes,omitempty"` } type Setting struct { Name string `json:"name,omitempty"` - UpdatedBy *int32 `json:"updated_by,omitempty"` + UpdatedBy *int32 `json:"updatedBy,omitempty"` Value []byte `json:"value,omitempty"` } type Share struct { ID string `json:"id,omitempty"` - EntityType string `json:"entity_type,omitempty"` - EntityID uuid.UUID `json:"entity_id,omitempty"` - EntityDate pgtype.Timestamptz `json:"entity_date,omitempty"` + EntityType string `json:"entityType,omitempty"` + EntityID uuid.UUID `json:"entityId,omitempty"` + EntityDate pgtype.Timestamptz `json:"entityDate,omitempty"` Owner int `json:"owner,omitempty"` Expiration pgtype.Timestamptz `json:"expiration,omitempty"` } @@ -95,18 +96,18 @@ type SweptCall struct { Submitter *int32 `json:"submitter,omitempty"` System int `json:"system,omitempty"` Talkgroup int `json:"talkgroup,omitempty"` - CallDate pgtype.Timestamptz `json:"call_date,omitempty"` - AudioName *string `json:"audio_name,omitempty"` - AudioBlob []byte `json:"audio_blob,omitempty"` + CallDate pgtype.Timestamptz `json:"callDate,omitempty"` + AudioName *string `json:"audioName,omitempty"` + AudioBlob []byte `json:"audioBlob,omitempty"` Duration *int32 `json:"duration,omitempty"` - AudioType *string `json:"audio_type,omitempty"` - AudioUrl *string `json:"audio_url,omitempty"` + AudioType *string `json:"audioType,omitempty"` + AudioUrl *string `json:"audioUrl,omitempty"` Frequency int `json:"frequency,omitempty"` Frequencies []int `json:"frequencies,omitempty"` Patches []int `json:"patches,omitempty"` - TGLabel *string `json:"tg_label,omitempty"` - TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` + TGLabel *string `json:"tgLabel,omitempty"` + TGAlphaTag *string `json:"tgAlphaTag,omitempty"` + TGGroup *string `json:"tgGroup,omitempty"` Source int `json:"source,omitempty"` Transcript *string `json:"transcript,omitempty"` } @@ -118,16 +119,16 @@ type System struct { type Talkgroup struct { ID int `json:"id,omitempty"` - SystemID int32 `json:"system_id,omitempty"` + SystemID int32 `json:"systemId,omitempty"` TGID int32 `json:"tgid,omitempty"` Name *string `json:"name,omitempty"` - AlphaTag *string `json:"alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` + AlphaTag *string `json:"alphaTag,omitempty"` + TGGroup *string `json:"tgGroup,omitempty"` Frequency *int32 `json:"frequency,omitempty"` Metadata jsontypes.Metadata `json:"metadata,omitempty"` Tags []string `json:"tags,omitempty"` Alert bool `json:"alert,omitempty"` - AlertRules rules.AlertRules `json:"alert_rules,omitempty"` + AlertRules rules.AlertRules `json:"alertRules,omitempty"` Weight float32 `json:"weight,omitempty"` Learned bool `json:"learned,omitempty"` Ignored bool `json:"ignored,omitempty"` @@ -136,18 +137,18 @@ type Talkgroup struct { type TalkgroupVersion struct { ID int `json:"id,omitempty"` Time pgtype.Timestamptz `json:"time,omitempty"` - CreatedBy *int32 `json:"created_by,omitempty"` + CreatedBy *int32 `json:"createdBy,omitempty"` Deleted *bool `json:"deleted,omitempty"` - SystemID *int32 `json:"system_id,omitempty"` + SystemID *int32 `json:"systemId,omitempty"` TGID *int32 `json:"tgid,omitempty"` Name *string `json:"name,omitempty"` - AlphaTag *string `json:"alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` + AlphaTag *string `json:"alphaTag,omitempty"` + TGGroup *string `json:"tgGroup,omitempty"` Frequency *int32 `json:"frequency,omitempty"` Metadata []byte `json:"metadata,omitempty"` Tags []string `json:"tags,omitempty"` Alert *bool `json:"alert,omitempty"` - AlertRules []byte `json:"alert_rules,omitempty"` + AlertRules []byte `json:"alertRules,omitempty"` Weight *float32 `json:"weight,omitempty"` Learned *bool `json:"learned,omitempty"` Ignored *bool `json:"ignored,omitempty"` @@ -158,6 +159,6 @@ type User struct { Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Email string `json:"email,omitempty"` - IsAdmin bool `json:"is_admin,omitempty"` + IsAdmin bool `json:"isAdmin,omitempty"` Prefs []byte `json:"prefs,omitempty"` } diff --git a/pkg/database/querier.go b/pkg/database/querier.go index d2cd4a9..36e68d7 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -42,6 +42,8 @@ type Querier interface { GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) GetShare(ctx context.Context, id string) (Share, error) + GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error) + GetSharesPCount(ctx context.Context, owner *int32) (int64, 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) diff --git a/pkg/database/share.sql.go b/pkg/database/share.sql.go index fbd2829..081f6b6 100644 --- a/pkg/database/share.sql.go +++ b/pkg/database/share.sql.go @@ -25,9 +25,9 @@ INSERT INTO shares ( type CreateShareParams struct { ID string `json:"id"` - EntityType string `json:"entity_type"` - EntityID uuid.UUID `json:"entity_id"` - EntityDate pgtype.Timestamptz `json:"entity_date"` + EntityType string `json:"entityType"` + EntityID uuid.UUID `json:"entityId"` + EntityDate pgtype.Timestamptz `json:"entityDate"` Owner int `json:"owner"` Expiration pgtype.Timestamptz `json:"expiration"` } @@ -55,14 +55,14 @@ func (q *Queries) DeleteShare(ctx context.Context, id string) error { const getShare = `-- name: GetShare :one SELECT - id, - entity_type, - entity_id, - entity_date, - owner, - expiration -FROM shares -WHERE id = $1 + s.id, + s.entity_type, + s.entity_id, + s.entity_date, + s.owner, + s.expiration +FROM shares s +WHERE s.id = $1 ` func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) { @@ -79,6 +79,82 @@ func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) { return i, err } +const getSharesP = `-- name: GetSharesP :many +SELECT + s.id, s.entity_type, s.entity_id, s.entity_date, s.owner, s.expiration, + u.username +FROM shares s +JOIN users u ON (s.owner = u.id) +WHERE +CASE WHEN $1::INTEGER IS NOT NULL THEN + s.owner = $1 ELSE TRUE END +ORDER BY +CASE WHEN $2::TEXT = 'asc' THEN s.entity_date END ASC, +CASE WHEN $2::TEXT = 'desc' THEN s.entity_date END DESC +OFFSET $3 ROWS +FETCH NEXT $4 ROWS ONLY +` + +type GetSharesPParams struct { + Owner *int32 `json:"owner"` + Direction string `json:"direction"` + Offset int32 `json:"offset"` + PerPage int32 `json:"perPage"` +} + +type GetSharesPRow struct { + Share Share `json:"share"` + Username string `json:"username"` +} + +func (q *Queries) GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error) { + rows, err := q.db.Query(ctx, getSharesP, + arg.Owner, + arg.Direction, + arg.Offset, + arg.PerPage, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSharesPRow + for rows.Next() { + var i GetSharesPRow + if err := rows.Scan( + &i.Share.ID, + &i.Share.EntityType, + &i.Share.EntityID, + &i.Share.EntityDate, + &i.Share.Owner, + &i.Share.Expiration, + &i.Username, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSharesPCount = `-- name: GetSharesPCount :one +SELECT COUNT(*) +FROM shares s +WHERE +CASE WHEN $1::INTEGER IS NOT NULL THEN + s.owner = $1 ELSE TRUE END +` + +func (q *Queries) GetSharesPCount(ctx context.Context, owner *int32) (int64, error) { + row := q.db.QueryRow(ctx, getSharesPCount, owner) + var count int64 + err := row.Scan(&count) + return count, err +} + const pruneShares = `-- name: PruneShares :exec DELETE FROM shares WHERE expiration < NOW() ` diff --git a/pkg/database/talkgroups.sql.go b/pkg/database/talkgroups.sql.go index 8023101..2ad8bae 100644 --- a/pkg/database/talkgroups.sql.go +++ b/pkg/database/talkgroups.sql.go @@ -32,11 +32,11 @@ INSERT INTO talkgroups( ` type AddLearnedTalkgroupParams struct { - SystemID int32 `json:"system_id"` + SystemID int32 `json:"systemId"` TGID int32 `json:"tgid"` Name *string `json:"name"` - AlphaTag *string `json:"alpha_tag"` - TGGroup *string `json:"tg_group"` + AlphaTag *string `json:"alphaTag"` + TGGroup *string `json:"tgGroup"` } func (q *Queries) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) { @@ -202,7 +202,7 @@ AND NOT (tags @> ARRAY[$3]) ` type GetTalkgroupIDsByTagsRow struct { - SystemID int32 `json:"system_id"` + SystemID int32 `json:"systemId"` TGID int32 `json:"tgid"` } @@ -511,9 +511,9 @@ FETCH NEXT $5 ROWS ONLY type GetTalkgroupsWithLearnedBySystemPParams struct { System int32 `json:"system"` Filter *string `json:"filter"` - OrderBy string `json:"order_by"` + OrderBy string `json:"orderBy"` Offset int32 `json:"offset"` - PerPage int32 `json:"per_page"` + PerPage int32 `json:"perPage"` } type GetTalkgroupsWithLearnedBySystemPRow struct { @@ -611,9 +611,9 @@ FETCH NEXT $4 ROWS ONLY type GetTalkgroupsWithLearnedPParams struct { Filter *string `json:"filter"` - OrderBy string `json:"order_by"` + OrderBy string `json:"orderBy"` Offset int32 `json:"offset"` - PerPage int32 `json:"per_page"` + PerPage int32 `json:"perPage"` } type GetTalkgroupsWithLearnedPRow struct { @@ -774,17 +774,17 @@ RETURNING id, system_id, tgid, name, alpha_tag, tg_group, frequency, metadata, t type UpdateTalkgroupParams struct { Name *string `json:"name"` - AlphaTag *string `json:"alpha_tag"` - TGGroup *string `json:"tg_group"` + AlphaTag *string `json:"alphaTag"` + TGGroup *string `json:"tgGroup"` Frequency *int32 `json:"frequency"` Metadata jsontypes.Metadata `json:"metadata"` Tags []string `json:"tags"` Alert *bool `json:"alert"` - AlertRules rules.AlertRules `json:"alert_rules"` + AlertRules rules.AlertRules `json:"alertRules"` Weight *float32 `json:"weight"` Learned *bool `json:"learned"` ID *int32 `json:"id"` - SystemID *int32 `json:"system_id"` + SystemID *int32 `json:"systemId"` TGID *int32 `json:"tgid"` } diff --git a/pkg/database/users.sql.go b/pkg/database/users.sql.go index 74f2b43..2f38048 100644 --- a/pkg/database/users.sql.go +++ b/pkg/database/users.sql.go @@ -51,7 +51,7 @@ type CreateUserParams struct { Username string `json:"username"` Password string `json:"password"` Email string `json:"email"` - IsAdmin bool `json:"is_admin"` + IsAdmin bool `json:"isAdmin"` } func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { @@ -108,10 +108,10 @@ WHERE api_key = $1 type GetAPIKeyRow struct { ID int `json:"id"` Owner int `json:"owner"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"createdAt"` Expires pgtype.Timestamp `json:"expires"` Disabled *bool `json:"disabled"` - ApiKey string `json:"api_key"` + ApiKey string `json:"apiKey"` Username string `json:"username"` } diff --git a/pkg/incidents/incident.go b/pkg/incidents/incident.go index 896c083..14ae1cb 100644 --- a/pkg/incidents/incident.go +++ b/pkg/incidents/incident.go @@ -2,7 +2,11 @@ package incidents import ( "encoding/json" + "fmt" + "net/url" + "strings" + "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/rbac/entities" @@ -14,19 +18,35 @@ type Incident struct { ID uuid.UUID `json:"id"` Owner users.UserID `json:"owner"` 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"` + Description *string `json:"description,omitempty"` + CreatedAt jsontypes.Time `json:"createdAt"` + StartTime *jsontypes.Time `json:"startTime,omitempty"` + EndTime *jsontypes.Time `json:"endTime,omitempty"` + Location jsontypes.Location `json:"location,omitempty"` + Metadata jsontypes.Metadata `json:"metadata,omitempty"` Calls []IncidentCall `json:"calls"` } +func (inc *Incident) SetShareURL(bu url.URL, shareID string) { + bu.Path = fmt.Sprintf("/share/%s/call/", shareID) + for i := range inc.Calls { + if inc.Calls[i].AudioURL != nil { + continue + } + inc.Calls[i].AudioURL = common.PtrTo(bu.String() + inc.Calls[i].ID.String()) + } +} + func (inc *Incident) GetResourceName() string { return entities.ResourceIncident } +func (inc *Incident) PlaylistFilename() string { + rep := strings.NewReplacer(" ", "_", "/", "_", ":", "_") + return rep.Replace(strings.ToLower(inc.Name)) +} + type IncidentCall struct { calls.Call - Notes json.RawMessage `json:"notes"` + Notes json.RawMessage `json:"notes,omitempty"` } diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index 8aa284b..81ce14d 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -55,7 +55,7 @@ type Store interface { // CallIn returns whether an incident is in an call CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) - // TGsIn returns the talkgroups referenced by an incident as a map, primary for rbac use. + // TGsIn returns the talkgroups referenced by an incident as a map, primarily for rbac use. TGsIn(ctx context.Context, inc uuid.UUID) (talkgroups.PresenceMap, error) } @@ -231,6 +231,7 @@ func fromDBIncident(id uuid.UUID, d database.Incident) incidents.Incident { Owner: users.UserID(d.Owner), Name: d.Name, Description: d.Description, + CreatedAt: jsontypes.Time(d.CreatedAt.Time), StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime), EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime), Metadata: d.Metadata, @@ -250,6 +251,7 @@ func fromDBListInPRow(id uuid.UUID, d database.ListIncidentsPRow) Incident { Owner: users.UserID(d.Owner), Name: d.Name, Description: d.Description, + CreatedAt: jsontypes.Time(d.CreatedAt.Time), StartTime: jsontypes.TimePtrFromTSTZ(d.StartTime), EndTime: jsontypes.TimePtrFromTSTZ(d.EndTime), Metadata: d.Metadata, @@ -268,6 +270,7 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall { ID: v.CallID, AudioName: common.ZeroIfNil(v.AudioName), AudioType: common.ZeroIfNil(v.AudioType), + AudioURL: v.AudioUrl, Duration: dur, DateTime: v.CallDate.Time, Frequencies: v.Frequencies, diff --git a/pkg/pb/stillbox.pb.go b/pkg/pb/stillbox.pb.go index f473933..2742bc8 100644 --- a/pkg/pb/stillbox.pb.go +++ b/pkg/pb/stillbox.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.33.0 -// protoc v5.28.3 +// protoc v5.29.3 // source: stillbox.proto package pb diff --git a/pkg/rbac/entities/entities.go b/pkg/rbac/entities/entities.go index eca0217..42e1548 100644 --- a/pkg/rbac/entities/entities.go +++ b/pkg/rbac/entities/entities.go @@ -2,6 +2,8 @@ package entities import ( "context" + "fmt" + "net/http" "github.com/el-mike/restrict/v2" ) @@ -30,14 +32,15 @@ const ( func SubjectFrom(ctx context.Context) Subject { sub, ok := ctx.Value(SubjectCtxKey).(Subject) - if ok { - return sub + if !ok { + panic("no subject in context") } - return new(PublicSubject) + return sub } type Subject interface { + fmt.Stringer restrict.Subject GetName() string } @@ -62,10 +65,18 @@ func (s *PublicSubject) GetName() string { return "PUBLIC:" + s.RemoteAddr } +func (s *PublicSubject) String() string { + return s.GetName() +} + func (s *PublicSubject) GetRoles() []string { return []string{RolePublic} } +func NewPublicSubject(r *http.Request) *PublicSubject { + return &PublicSubject{RemoteAddr: r.RemoteAddr} +} + type SystemServiceSubject struct { Name string } @@ -74,6 +85,10 @@ func (s *SystemServiceSubject) GetName() string { return "SYSTEM:" + s.Name } +func (s *SystemServiceSubject) String() string { + return s.GetName() +} + func (s *SystemServiceSubject) GetRoles() []string { return []string{RoleSystem} } diff --git a/pkg/rbac/policy/policy.go b/pkg/rbac/policy/policy.go index 16798a6..6cf50c1 100644 --- a/pkg/rbac/policy/policy.go +++ b/pkg/rbac/policy/policy.go @@ -78,19 +78,29 @@ var Policy = &restrict.PolicyDefinition{ }, }, entities.RoleAdmin: { - Parents: []string{entities.RoleUser}, + Description: "A superuser", + Parents: []string{entities.RoleUser}, Grants: restrict.GrantsMap{ entities.ResourceIncident: { + &restrict.Permission{Action: entities.ActionRead}, &restrict.Permission{Action: entities.ActionUpdate}, &restrict.Permission{Action: entities.ActionDelete}, &restrict.Permission{Action: entities.ActionShare}, }, entities.ResourceCall: { + &restrict.Permission{Action: entities.ActionRead}, &restrict.Permission{Action: entities.ActionUpdate}, &restrict.Permission{Action: entities.ActionDelete}, &restrict.Permission{Action: entities.ActionShare}, }, entities.ResourceTalkgroup: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Action: entities.ActionDelete}, + }, + entities.ResourceShare: { + &restrict.Permission{Action: entities.ActionRead}, &restrict.Permission{Action: entities.ActionUpdate}, &restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Action: entities.ActionDelete}, @@ -98,16 +108,15 @@ var Policy = &restrict.PolicyDefinition{ }, }, entities.RoleSystem: { - Parents: []string{entities.RoleSystem}, + Description: "A system service", + Parents: []string{entities.RoleAdmin}, }, entities.RolePublic: { - /* - Grants: restrict.GrantsMap{ - entities.ResourceShare: { - &restrict.Permission{Action: entities.ActionRead}, - }, + Grants: restrict.GrantsMap{ + entities.ResourceShare: { + &restrict.Permission{Action: entities.ActionRead}, }, - */ + }, }, }, PermissionPresets: restrict.PermissionPresets{ diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index 58da816..b153885 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -8,17 +8,23 @@ import ( "github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2/adapters" + "github.com/rs/zerolog/log" ) var ( ErrBadSubject = errors.New("bad subject in token") + ErrAccessDenied = errors.New("access denied") ) -func ErrAccessDenied(err error) *restrict.AccessDeniedError { +func IsErrAccessDenied(err error) error { if accessErr, ok := err.(*restrict.AccessDeniedError); ok { return accessErr } + if err == ErrAccessDenied { + return err + } + return nil } @@ -115,5 +121,19 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp Context: o.context, } - return sub, r.access.Authorize(req) + authRes := r.access.Authorize(req) + if IsErrAccessDenied(authRes) != nil { + subS := "" + resS := "" + if sub != nil { + subS = sub.String() + } + + if res != nil { + resS = res.GetResourceName() + } + log.Error().Str("resource", resS).Strs("actions", req.Actions).Str("subject", subS).Msg("access denied") + } + + return sub, authRes } diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 8f0928c..6fc0fd8 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -48,16 +48,7 @@ func New(baseURL url.URL) *api { incidents: newIncidentsAPI(&baseURL), users: new(usersAPI), } - s.shares = newShareAPI(&baseURL, - ShareHandlers{ - ShareRequestCall: s.calls.shareCallRoute, - ShareRequestCallDL: s.calls.shareCallDLRoute, - ShareRequestIncident: s.incidents.getIncident, - ShareRequestIncidentM3U: s.incidents.getCallsM3U, - ShareRequestTalkgroups: s.tgs.getTGsShareRoute, - }, - ) - + s.shares = newShareAPI(&baseURL, s.shareHandlers()) return s } @@ -188,7 +179,7 @@ func autoError(err error) render.Renderer { } } - if rbac.ErrAccessDenied(err) != nil { + if rbac.IsErrAccessDenied(err) != nil { return forbiddenErrText(err) } diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index 1492f6c..cdbc5bf 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -1,6 +1,7 @@ package rest import ( + "context" "errors" "fmt" "mime" @@ -11,7 +12,6 @@ import ( "dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/pkg/calls/callstore" "dynatron.me/x/stillbox/pkg/database" - "dynatron.me/x/stillbox/pkg/shares" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -102,7 +102,12 @@ func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Re _, _ = w.Write(call.AudioBlob) } -func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { +func (ca *callsAPI) getCallInfo(ctx context.Context, id ID) (SharedItem, error) { + cs := callstore.FromCtx(ctx) + return cs.Call(ctx, id.(uuid.UUID)) +} + +func (ca *callsAPI) shareCallRoute(id ID, w http.ResponseWriter, r *http.Request) { p := getAudioParams{ CallID: common.PtrTo(id.(uuid.UUID)), } @@ -110,7 +115,7 @@ func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter ca.getAudio(p, w, r) } -func (ca *callsAPI) shareCallDLRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { +func (ca *callsAPI) shareCallDLRoute(id ID, w http.ResponseWriter, r *http.Request) { p := getAudioParams{ CallID: common.PtrTo(id.(uuid.UUID)), Download: common.PtrTo("download"), diff --git a/pkg/rest/incidents.go b/pkg/rest/incidents.go index 6baf4a2..36a018a 100644 --- a/pkg/rest/incidents.go +++ b/pkg/rest/incidents.go @@ -2,6 +2,7 @@ package rest import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -12,7 +13,6 @@ import ( "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/incidents/incstore" - "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "github.com/go-chi/chi/v5" @@ -91,22 +91,22 @@ func (ia *incidentsAPI) createIncident(w http.ResponseWriter, r *http.Request) { func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request) { id, err := idOnlyParam(w, r) if err != nil { + wErr(w, r, autoError(err)) return } - ia.getIncident(id, nil, w, r) -} - -func (ia *incidentsAPI) getIncident(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - inc, err := incs.Incident(ctx, id.(uuid.UUID)) + e, err := ia.getIncident(r.Context(), id) if err != nil { wErr(w, r, autoError(err)) return } - respond(w, r, inc) + respond(w, r, e) +} + +func (ia *incidentsAPI) getIncident(ctx context.Context, id ID) (SharedItem, error) { + incs := incstore.FromCtx(ctx) + return incs.Incident(ctx, id.(uuid.UUID)) } func (ia *incidentsAPI) updateIncident(w http.ResponseWriter, r *http.Request) { @@ -195,10 +195,10 @@ func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request) return } - ia.getCallsM3U(id, nil, w, r) + ia.getCallsM3U(id, w, r) } -func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { +func (ia *incidentsAPI) getCallsM3U(id ID, w http.ResponseWriter, r *http.Request) { ctx := r.Context() incs := incstore.FromCtx(ctx) tgst := tgstore.FromCtx(ctx) @@ -213,9 +213,13 @@ func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseW callUrl := common.PtrTo(*ia.baseURL) urlRoot := "/api/call" + filename := inc.PlaylistFilename() + share := ShareFrom(ctx) if share != nil { urlRoot = fmt.Sprintf("/share/%s/call/", share.ID) + filename += "_" + share.ID } + filename += ".m3u" b.WriteString("#EXTM3U\n\n") for _, c := range inc.Calls { @@ -231,11 +235,12 @@ func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseW callUrl.Path = urlRoot + c.ID.String() - fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s)\n%s\n\n", + fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s @ %s)\n%s\n\n", c.Duration.Seconds(), tg.StringTag(true), from, - c.DateTime.Format("15:04 01/02"), + c.Duration.ColonFormat(), + c.DateTime.Format("15:04:05 01/02"), callUrl, ) } @@ -243,6 +248,7 @@ func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseW // 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.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) w.WriteHeader(http.StatusOK) _, _ = b.WriteTo(w) } diff --git a/pkg/rest/share.go b/pkg/rest/share.go index 1ffba53..45d676e 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -1,6 +1,7 @@ package rest import ( + "context" "errors" "net/http" "net/url" @@ -23,25 +24,29 @@ type ShareRequestType string const ( ShareRequestCall ShareRequestType = "call" + ShareRequestCallInfo ShareRequestType = "callinfo" ShareRequestCallDL ShareRequestType = "callDL" ShareRequestIncident ShareRequestType = "incident" ShareRequestIncidentM3U ShareRequestType = "m3u" ShareRequestTalkgroups ShareRequestType = "talkgroups" ) -func (rt ShareRequestType) IsValid() bool { - switch rt { - case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident, - ShareRequestIncidentM3U, ShareRequestTalkgroups: - return true +// shareHandlers returns a ShareHandlers map from the api. +func (s *api) shareHandlers() ShareHandlers { + return ShareHandlers{ + ShareRequestCall: s.calls.shareCallRoute, + ShareRequestCallInfo: s.respondShareHandler(s.calls.getCallInfo), + ShareRequestCallDL: s.calls.shareCallDLRoute, + ShareRequestIncident: s.respondShareHandler(s.incidents.getIncident), + ShareRequestIncidentM3U: s.incidents.getCallsM3U, + ShareRequestTalkgroups: s.tgs.getTGsShareRoute, } - - return false } -func (rt ShareRequestType) IsValidSubtype() bool { +func (rt ShareRequestType) IsValid() bool { switch rt { - case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups: + case ShareRequestCall, ShareRequestCallInfo, ShareRequestCallDL, ShareRequestIncident, + ShareRequestIncidentM3U, ShareRequestTalkgroups: return true } @@ -51,13 +56,59 @@ func (rt ShareRequestType) IsValidSubtype() bool { type ID interface { } -type HandlerFunc func(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) -type ShareHandlers map[ShareRequestType]HandlerFunc +type ShareHandlerFunc func(id ID, w http.ResponseWriter, r *http.Request) +type ShareHandlers map[ShareRequestType]ShareHandlerFunc type shareAPI struct { baseURL *url.URL shnd ShareHandlers } +type EntityFunc func(ctx context.Context, id ID) (SharedItem, error) +type SharedItem interface { + SetShareURL(baseURL url.URL, shareID string) +} + +type shareResponse struct { + ID ID `json:"id"` + Type shares.EntityType `json:"type"` + SharedItem SharedItem `json:"sharedItem,omitempty"` +} + +func ShareFrom(ctx context.Context) *shares.Share { + if share, hasShare := entities.SubjectFrom(ctx).(*shares.Share); hasShare { + return share + } + + return nil +} + +func (s *api) respondShareHandler(ie EntityFunc) ShareHandlerFunc { + return func(id ID, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + share := ShareFrom(ctx) + if share == nil { + wErr(w, r, autoError(ErrBadShare)) + return + } + + res, err := ie(r.Context(), id) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + sRes := shareResponse{ + ID: share.ID, + Type: share.Type, + SharedItem: res, + } + + sRes.SharedItem.SetShareURL(*s.baseURL, share.ID) + + respond(w, r, sRes) + } +} + func newShareAPI(baseURL *url.URL, shnd ShareHandlers) *shareAPI { return &shareAPI{ baseURL: baseURL, @@ -70,6 +121,7 @@ func (sa *shareAPI) Subrouter() http.Handler { r.Post(`/create`, sa.createShare) r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, sa.deleteShare) + r.Post(`/`, sa.listShares) return r } @@ -79,6 +131,7 @@ func (sa *shareAPI) RootRouter() http.Handler { r.Get("/{shareId:[A-Za-z0-9_-]{20,}}", sa.routeShare) r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}", sa.routeShare) + r.Get("/{shareId:[A-Za-z0-9_-]{20,}}.{type}", sa.routeShare) r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}/{subID}", sa.routeShare) return r } @@ -104,6 +157,34 @@ func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) { respond(w, r, sh) } +func (sa *shareAPI) listShares(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + shs := shares.FromCtx(ctx) + + p := shares.SharesParams{} + err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + shRes, count, err := shs.Shares(ctx, p) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + response := struct { + Shares []*shares.Share `json:"shares"` + TotalCount int `json:"totalCount"` + }{ + Shares: shRes, + TotalCount: count, + } + + respond(w, r, &response) +} + func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { ctx := r.Context() shs := shares.FromCtx(ctx) @@ -134,12 +215,11 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { } else { switch sh.Type { case shares.EntityCall: - rType = ShareRequestCall + rType = ShareRequestCallInfo params.SubID = common.PtrTo(sh.EntityID.String()) case shares.EntityIncident: rType = ShareRequestIncident } - w.Header().Set("X-Share-Type", string(rType)) } if !rType.IsValid() { @@ -157,23 +237,44 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { switch rType { case ShareRequestTalkgroups: - sa.shnd[rType](nil, sh, w, r) - case ShareRequestCall, ShareRequestCallDL: - if params.SubID == nil { - wErr(w, r, autoError(ErrBadShare)) - return + sa.shnd[rType](nil, w, r) + case ShareRequestCall, ShareRequestCallInfo, ShareRequestCallDL: + var subIDU uuid.UUID + if params.SubID != nil { + subIDU, err = uuid.Parse(*params.SubID) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + } else { + subIDU = sh.EntityID } - subIDU, err := uuid.Parse(*params.SubID) - if err != nil { - wErr(w, r, badRequest(err)) - return - } - sa.shnd[rType](subIDU, sh, w, r) + sa.shnd[rType](subIDU, w, r) case ShareRequestIncident, ShareRequestIncidentM3U: - sa.shnd[rType](sh.EntityID, sh, w, r) + sa.shnd[rType](sh.EntityID, w, r) } } func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + shs := shares.FromCtx(ctx) + + p := struct { + ID string `param:"id"` + }{} + + err := decodeParams(&p, r) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + err = shs.Delete(ctx, p.ID) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + w.WriteHeader(http.StatusNoContent) } diff --git a/pkg/rest/talkgroups.go b/pkg/rest/talkgroups.go index d97e9ea..5246109 100644 --- a/pkg/rest/talkgroups.go +++ b/pkg/rest/talkgroups.go @@ -4,11 +4,11 @@ import ( "errors" "fmt" "net/http" + "slices" "dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/incidents/incstore" - "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/xport" @@ -161,10 +161,16 @@ func (tga *talkgroupAPI) postPaginated(w http.ResponseWriter, r *http.Request) { respond(w, r, res) } -func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { +func (tga *talkgroupAPI) getTGsShareRoute(_ ID, w http.ResponseWriter, r *http.Request) { ctx := r.Context() tgs := tgstore.FromCtx(ctx) + share := ShareFrom(ctx) + if share == nil { + wErr(w, r, autoError(ErrBadShare)) + return + } + tgIDs, err := incstore.FromCtx(ctx).TGsIn(ctx, share.EntityID) if err != nil { wErr(w, r, autoError(err)) @@ -176,6 +182,14 @@ func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.Resp idSl = append(idSl, id) } + slices.SortFunc(idSl, func(a, b talkgroups.ID) int { + if d := int(a.System) - int(b.System); d != 0 { + return d + } + + return int(a.Talkgroup) - int(b.Talkgroup) + }) + tgRes, err := tgs.TGs(ctx, idSl) if err != nil { wErr(w, r, autoError(err)) diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 9410baf..fcc8c15 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -29,10 +29,11 @@ func (s *Server) setupRoutes() { r.Use(s.WithCtxStores()) s.installPprof() + r.Use(s.auth.VerifyMiddleware()) r.Group(func(r chi.Router) { + r.Use(s.auth.AuthorizedSubjectMiddleware()) // authenticated routes - r.Use(s.auth.VerifyMiddleware(), s.auth.AuthMiddleware()) s.nex.PrivateRoutes(r) s.auth.PrivateRoutes(r) s.alerter.PrivateRoutes(r) @@ -41,6 +42,7 @@ func (s *Server) setupRoutes() { r.Group(func(r chi.Router) { s.rateLimit(r) + r.Use(s.auth.PublicSubjectMiddleware()) r.Use(render.SetContentType(render.ContentTypeJSON)) // public routes s.sources.PublicRoutes(r) @@ -49,6 +51,7 @@ func (s *Server) setupRoutes() { r.Group(func(r chi.Router) { // auth/share routes get rate-limited heavily, but not using middleware s.rateLimit(r) + r.Use(s.auth.PublicSubjectMiddleware()) r.Use(render.SetContentType(render.ContentTypeJSON)) s.auth.PublicRoutes(r) r.Mount("/share", s.rest.ShareRouter()) @@ -56,9 +59,8 @@ func (s *Server) setupRoutes() { r.Group(func(r chi.Router) { s.rateLimit(r) - r.Use(s.auth.VerifyMiddleware()) - // optional auth routes + r.Use(s.auth.PublicSubjectMiddleware()) s.clientRoute(r, clientRoot) }) diff --git a/pkg/server/signals.go b/pkg/server/signals.go index b3a60a8..8cf9c64 100644 --- a/pkg/server/signals.go +++ b/pkg/server/signals.go @@ -18,6 +18,7 @@ func (s *Server) huppers() []hupper { s.logger, s.auth, s.tgs, + s.alerter, } } diff --git a/pkg/shares/share.go b/pkg/shares/share.go index 6184c02..67abc02 100644 --- a/pkg/shares/share.go +++ b/pkg/shares/share.go @@ -47,8 +47,9 @@ func (et EntityType) IsValid() bool { type Share struct { ID string `json:"id"` Type EntityType `json:"entityType"` - Date *jsontypes.Time `json:"-"` // we handle this for the user - Owner users.UserID `json:"owner"` + Date *jsontypes.Time `json:"entityDate,omitempty"` // we handle this for the user + Owner users.UserID `json:"-"` + OwnerUser *string `json:"owner,omitempty"` EntityID uuid.UUID `json:"entityID"` Expiration *jsontypes.Time `json:"expiration"` } @@ -57,6 +58,10 @@ func (s *Share) GetName() string { return "SHARE:" + s.ID } +func (s *Share) String() string { + return s.GetName() +} + func (s *Share) GetRoles() []string { return []string{entities.RoleShareGuest} } diff --git a/pkg/shares/store.go b/pkg/shares/store.go index d0fbad0..d0f1313 100644 --- a/pkg/shares/store.go +++ b/pkg/shares/store.go @@ -3,7 +3,9 @@ package shares import ( "context" "errors" + "fmt" + "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/rbac" @@ -12,13 +14,21 @@ import ( "github.com/jackc/pgx/v5" ) +type SharesParams struct { + common.Pagination + Direction *common.SortDirection `json:"dir"` +} + type Shares interface { // NewShare creates a new share. NewShare(ctx context.Context, sh CreateShareParams) (*Share, error) - // Share retreives a share record. + // Share retrieves a share record. GetShare(ctx context.Context, id string) (*Share, error) + // Shares retrieves shares visible by the context Subject. + Shares(ctx context.Context, p SharesParams) (shares []*Share, totalCount int, err error) + // Create stores a new share record. Create(ctx context.Context, share *Share) error @@ -48,6 +58,11 @@ func recToShare(share database.Share) *Share { } func (s *postgresStore) GetShare(ctx context.Context, id string) (*Share, error) { + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceShare), rbac.WithActions(entities.ActionRead)) + if err != nil { + return nil, err + } + db := database.FromCtx(ctx) rec, err := db.GetShare(ctx, id) switch err { @@ -80,7 +95,12 @@ func (s *postgresStore) Create(ctx context.Context, share *Share) error { } func (s *postgresStore) Delete(ctx context.Context, id string) error { - _, err := rbac.Check(ctx, new(Share), rbac.WithActions(entities.ActionDelete)) + sh, err := s.GetShare(ctx, id) + if err != nil { + return err + } + + _, err = rbac.Check(ctx, sh, rbac.WithActions(entities.ActionDelete)) if err != nil { return err } @@ -88,6 +108,54 @@ func (s *postgresStore) Delete(ctx context.Context, id string) error { return database.FromCtx(ctx).DeleteShare(ctx, id) } +func (s *postgresStore) Shares(ctx context.Context, p SharesParams) (shares []*Share, totalCount int, err error) { + sub := entities.SubjectFrom(ctx) + + // ersatz RBAC + owner := common.PtrTo(int32(-1)) // invalid UID + switch s := sub.(type) { + case *users.User: + if !s.IsAdmin { + owner = s.ID.Int32Ptr() + } else { + owner = nil + } + case *entities.SystemServiceSubject: + owner = nil + default: + return nil, 0, rbac.ErrAccessDenied + } + + db := database.FromCtx(ctx) + + count, err := db.GetSharesPCount(ctx, owner) + if err != nil { + return nil, 0, fmt.Errorf("shares count: %w", err) + } + + offset, perPage := p.Pagination.OffsetPerPage(100) + dbParam := database.GetSharesPParams{ + Owner: owner, + Direction: p.Direction.DirString(common.DirAsc), + Offset: offset, + PerPage: perPage, + } + + shs, err := db.GetSharesP(ctx, dbParam) + if err != nil { + return nil, 0, err + } + + shares = make([]*Share, 0, len(shs)) + for _, v := range shs { + s := recToShare(v.Share) + s.OwnerUser = &v.Username + shares = append(shares, s) + } + + return shares, int(count), nil +} + func (s *postgresStore) Prune(ctx context.Context) error { return database.FromCtx(ctx).PruneShares(ctx) } diff --git a/pkg/sources/http.go b/pkg/sources/http.go index dbc51f7..e004621 100644 --- a/pkg/sources/http.go +++ b/pkg/sources/http.go @@ -134,7 +134,7 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) { } err = h.ing.Ingest(entities.CtxWithSubject(ctx, submitterSub), call) if err != nil { - if rbac.ErrAccessDenied(err) != nil { + if rbac.IsErrAccessDenied(err) != nil { log.Error().Err(err).Msg("ingest failed") http.Error(w, "Call ingest failed.", http.StatusForbidden) } diff --git a/pkg/talkgroups/tgstore/store.go b/pkg/talkgroups/tgstore/store.go index ba86f06..b7dd472 100644 --- a/pkg/talkgroups/tgstore/store.go +++ b/pkg/talkgroups/tgstore/store.go @@ -588,6 +588,7 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara } func (t *cache) DeleteSystem(ctx context.Context, id int) error { + // talkgroups don't have owners, so we can use a generic Resource _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete)) if err != nil { return err diff --git a/pkg/talkgroups/xport/radioref/testdata/riscon.json b/pkg/talkgroups/xport/radioref/testdata/riscon.json index 596c99e..aa8c64e 100644 --- a/pkg/talkgroups/xport/radioref/testdata/riscon.json +++ b/pkg/talkgroups/xport/radioref/testdata/riscon.json @@ -1 +1 @@ -[{"id":0,"system_id":197,"tgid":2,"name":"Intercity Fire","alpha_tag":"Intercity FD","tg_group":"Statewide Mutual Aid/Intersystem","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":1,"system_id":197,"tgid":3,"name":"Intercity Police","alpha_tag":"Intercity PD","tg_group":"Statewide Mutual Aid/Intersystem","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":2,"system_id":197,"tgid":21,"name":"North Dispatch ","alpha_tag":"RISP N Disp","tg_group":"State Police - District A (North)","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":3,"system_id":197,"tgid":22,"name":"North Car-to-Car/Information","alpha_tag":"RISP N Car","tg_group":"State Police - District A (North)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":4,"system_id":197,"tgid":24,"name":"North Tactical Ops 1","alpha_tag":"RISP N Tac 1","tg_group":"State Police - District A (North)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":5,"system_id":197,"tgid":23,"name":"North Tactical Ops 2","alpha_tag":"RISP N Tac 2","tg_group":"State Police - District A (North)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":6,"system_id":197,"tgid":25,"name":"South Dispatch ","alpha_tag":"RISP S Disp","tg_group":"State Police - District B (South)","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":7,"system_id":197,"tgid":27,"name":"South Car-to-Car/Information","alpha_tag":"RISP S Car","tg_group":"State Police - District B (South)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":8,"system_id":197,"tgid":16,"name":"State Fire Marshall","alpha_tag":"State FMO","tg_group":"Statewide Fire","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":9,"system_id":197,"tgid":1038,"name":"Northern Rhode Island Fire Chiefs","alpha_tag":"NRI Fire Chi","tg_group":"Statewide Fire","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":10,"system_id":197,"tgid":1041,"name":"Southern Rhode Island Fire Chiefs","alpha_tag":"SRI Fire Chi","tg_group":"Statewide Fire","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":11,"system_id":197,"tgid":1314,"name":"Tanker Taskforce 1","alpha_tag":"Tanker TF 1","tg_group":"Statewide Fire","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":12,"system_id":197,"tgid":194,"name":"Lifepact Ambulance (Statewide)","alpha_tag":"Lifepact Amb","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":null,"tags":["EMS Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":13,"system_id":197,"tgid":212,"name":"Fatima St Josephs","alpha_tag":"Fatima-St Joes","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":null,"tags":["Business"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":14,"system_id":197,"tgid":220,"name":"Health 1","alpha_tag":"Health 1","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":{"encrypted":true},"tags":["EMS-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":15,"system_id":197,"tgid":221,"name":"Health 2","alpha_tag":"Health 2","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":{"encrypted":true},"tags":["EMS-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":16,"system_id":197,"tgid":222,"name":"Department of Health - Statewide","alpha_tag":"Dept of HealthSW","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":null,"tags":["EMS-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":17,"system_id":197,"tgid":228,"name":"DMAT South","alpha_tag":"DMAT South","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":{"encrypted":true},"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":18,"system_id":197,"tgid":232,"name":"Life Span Net 1","alpha_tag":"Life Span 1","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":null,"tags":["EMS-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":19,"system_id":197,"tgid":234,"name":"RI Hospital Operations","alpha_tag":"RI Hosp Ops","tg_group":"Statewide EMS and Hospitals","frequency":null,"metadata":null,"tags":["Business"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":20,"system_id":197,"tgid":120,"name":"Law Enforcement Operations","alpha_tag":"DEM PD Ops","tg_group":"Department of Environmental Management","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":21,"system_id":197,"tgid":122,"name":"Law Enforcement Police","alpha_tag":"DEM Police","tg_group":"Department of Environmental Management","frequency":null,"metadata":null,"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":22,"system_id":197,"tgid":10,"name":"Emergency Management Agency 1","alpha_tag":"EMA-1","tg_group":"Emergency Management Agency","frequency":null,"metadata":null,"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":23,"system_id":197,"tgid":20,"name":"Emergency Management Agency","alpha_tag":"EMA","tg_group":"Emergency Management Agency","frequency":null,"metadata":null,"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":24,"system_id":197,"tgid":4,"name":"Wide Area 3","alpha_tag":"Wide Area 3","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":25,"system_id":197,"tgid":5,"name":"Wide Area 4","alpha_tag":"Wide Area 4","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":26,"system_id":197,"tgid":6,"name":"Wide Area 5","alpha_tag":"Wide Area 5","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":27,"system_id":197,"tgid":7,"name":"Wide Area 6","alpha_tag":"Wide Area 6","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":28,"system_id":197,"tgid":1018,"name":"Southwide CH-1","alpha_tag":"SOUTHWIDE 1","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":29,"system_id":197,"tgid":1019,"name":"Southwide CH-2","alpha_tag":"SOUTHWIDE 2","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":30,"system_id":197,"tgid":1022,"name":"Wide Area 7","alpha_tag":"WIDE AREA 7","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":31,"system_id":197,"tgid":1023,"name":"Wide Area 8","alpha_tag":"WIDE AREA 8","tg_group":"Statewide Area/Events","frequency":null,"metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":32,"system_id":197,"tgid":1025,"name":"Inland Marine Interop","alpha_tag":"Inland Marine IO","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":33,"system_id":197,"tgid":1037,"name":"Southside CH 5","alpha_tag":"SOUTHSIDE 5","tg_group":"Statewide Area/Events","frequency":null,"metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":34,"system_id":197,"tgid":1173,"name":"North Wide 1","alpha_tag":"NORTHWIDE1","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":35,"system_id":197,"tgid":1174,"name":"North Wide 2","alpha_tag":"NORTHWIDE2","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":36,"system_id":197,"tgid":1177,"name":"North Wide 5","alpha_tag":"NORTHWIDE5","tg_group":"Statewide Area/Events","frequency":null,"metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":37,"system_id":197,"tgid":1185,"name":"Metro Wide 1","alpha_tag":"METROWIDE1","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":38,"system_id":197,"tgid":1186,"name":"Metro Wide 2","alpha_tag":"METROWIDE2","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":39,"system_id":197,"tgid":1187,"name":"Metro Wide 3","alpha_tag":"METROWIDE3","tg_group":"Statewide Area/Events","frequency":null,"metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":40,"system_id":197,"tgid":1335,"name":"East Wide 1","alpha_tag":"EASTWIDE 1","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":41,"system_id":197,"tgid":1336,"name":"East Wide 2","alpha_tag":"EASTWIDE 2","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":42,"system_id":197,"tgid":1337,"name":"East Wide 3","alpha_tag":"EASTWIDE 3","tg_group":"Statewide Area/Events","frequency":null,"metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":43,"system_id":197,"tgid":11186,"name":"Metro Wide 2","alpha_tag":"METROWIDE2","tg_group":"Statewide Area/Events","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":44,"system_id":197,"tgid":1033,"name":"Tanker Taskforce ","alpha_tag":"TANK TF","tg_group":"Statewide Emergency Response","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":45,"system_id":197,"tgid":1034,"name":"Hazmat 1","alpha_tag":"HZT DC1","tg_group":"Statewide Emergency Response","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":46,"system_id":197,"tgid":1035,"name":"Hazmat 2","alpha_tag":"HZT DC2","tg_group":"Statewide Emergency Response","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":47,"system_id":197,"tgid":176,"name":"Department of Transportation - Primary","alpha_tag":"RIDOT Primary","tg_group":"Department of Transportation","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":48,"system_id":197,"tgid":4421,"name":"Newport Pell Bridge Operations","alpha_tag":"RITBA - Pell Bdg","tg_group":"Tunnel and Bridge Authority","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":49,"system_id":197,"tgid":274,"name":"Providence VA Police","alpha_tag":"VA Police","tg_group":"Federal","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":50,"system_id":197,"tgid":186,"name":"Rhode Island Public Transit Auth.","alpha_tag":"RIPTA","tg_group":"RIPTA","frequency":null,"metadata":{"encrypted":true},"tags":["Transportation"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":51,"system_id":197,"tgid":187,"name":"Rhode Island Public Transit Auth.","alpha_tag":"RIPTA","tg_group":"RIPTA","frequency":null,"metadata":null,"tags":["Transportation"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":52,"system_id":197,"tgid":188,"name":"Rhode Island Public Transit Auth.","alpha_tag":"RIPTA","tg_group":"RIPTA","frequency":null,"metadata":null,"tags":["Transportation"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":53,"system_id":197,"tgid":189,"name":"Rhode Island Public Transit Auth.","alpha_tag":"RIPTA","tg_group":"RIPTA","frequency":null,"metadata":null,"tags":["Transportation"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":54,"system_id":197,"tgid":190,"name":"Rhode Island Public Transit. Auth.","alpha_tag":"RIPTA","tg_group":"RIPTA","frequency":null,"metadata":null,"tags":["Transportation"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":55,"system_id":197,"tgid":304,"name":"Fire Operations","alpha_tag":"Quonset ANGB FD","tg_group":"Quonset ANGB","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":56,"system_id":197,"tgid":17,"name":"Airport Police Operations","alpha_tag":"TF Green PD","tg_group":"Rhode Island Airport Commission","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":57,"system_id":197,"tgid":19,"name":"Airport Fire Operations","alpha_tag":"TF Green FD","tg_group":"Rhode Island Airport Commission","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":58,"system_id":197,"tgid":1126,"name":"University of Rhode Island Police - Dispatch","alpha_tag":"URI PD","tg_group":"College/Education Security","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":59,"system_id":197,"tgid":1131,"name":"University of Rhode Island - EMS","alpha_tag":"URI EMS","tg_group":"College/Education Security","frequency":null,"metadata":{"encrypted":true},"tags":["EMS Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":60,"system_id":197,"tgid":1348,"name":"St. George's School (Middletown) - Security","alpha_tag":"St George Sec","tg_group":"College/Education Security","frequency":null,"metadata":null,"tags":["Security"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":61,"system_id":197,"tgid":10228,"name":"Rhode Island School of Design - Security","alpha_tag":"RISD Secuty","tg_group":"College/Education Security","frequency":null,"metadata":{"encrypted":true},"tags":["Security"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":62,"system_id":197,"tgid":10229,"name":"Providence College Security - Dispatch","alpha_tag":"PROV COLL","tg_group":"College/Education Security","frequency":null,"metadata":{"encrypted":true},"tags":["Security"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":63,"system_id":197,"tgid":10230,"name":"Rhode Island College Security","alpha_tag":"RI COL SEC","tg_group":"College/Education Security","frequency":null,"metadata":null,"tags":["Security"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":64,"system_id":197,"tgid":11001,"name":"Brown University Police - Dispatch","alpha_tag":"BROWN UNIV","tg_group":"College/Education Security","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":65,"system_id":197,"tgid":11002,"name":"Brown University Police - Car-to-Car","alpha_tag":"BROWN CAR","tg_group":"College/Education Security","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":66,"system_id":197,"tgid":11003,"name":"Brown University Police - Tactical","alpha_tag":"BROWN TAC","tg_group":"College/Education Security","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":67,"system_id":197,"tgid":12,"name":"Metro Wide 2","alpha_tag":"METROWIDE2","tg_group":"Statewide Misc.","frequency":null,"metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":68,"system_id":197,"tgid":14,"name":"Metro Wide 4","alpha_tag":"METROWIDE4","tg_group":"Statewide Misc.","frequency":null,"metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":69,"system_id":197,"tgid":70,"name":"RI Traffic Tribunal Security","alpha_tag":"TFC TRIBUNAL","tg_group":"Statewide Misc.","frequency":null,"metadata":{"encrypted":true},"tags":["Security"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":70,"system_id":197,"tgid":168,"name":"Rhode Island Red Cross - Primary","alpha_tag":"Red Cross 1","tg_group":"Statewide Misc.","frequency":null,"metadata":null,"tags":["Other"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":71,"system_id":197,"tgid":169,"name":"Rhode Island Red Cross - Secondary","alpha_tag":"Red Cross 2","tg_group":"Statewide Misc.","frequency":null,"metadata":null,"tags":["Other"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":72,"system_id":197,"tgid":223,"name":"Statewide Nursing Homes Net","alpha_tag":"NURSING HM","tg_group":"Statewide Misc.","frequency":null,"metadata":null,"tags":["Other"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":73,"system_id":197,"tgid":243,"name":"Hospital Operations","alpha_tag":"Slater Hosp Ops","tg_group":"Statewide Misc.","frequency":null,"metadata":null,"tags":["Business"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":74,"system_id":197,"tgid":244,"name":"Slater Hospital Security","alpha_tag":"Slater Hosp Sec","tg_group":"Statewide Misc.","frequency":null,"metadata":null,"tags":["Security"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":75,"system_id":197,"tgid":1042,"name":"County Fireground","alpha_tag":"WashCo FireG","tg_group":"Washington County","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":76,"system_id":197,"tgid":1479,"name":"County Fire Station/Station","alpha_tag":"WashCo FireS","tg_group":"Washington County","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":77,"system_id":197,"tgid":1712,"name":"Fire 1 Dispatch","alpha_tag":"BarringtnFD1","tg_group":"Barrington","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":78,"system_id":197,"tgid":1713,"name":"Fire 2","alpha_tag":"BarringtnFD2","tg_group":"Barrington","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":79,"system_id":197,"tgid":1715,"name":"Police Operations","alpha_tag":"BarringtonPD 1","tg_group":"Barrington","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":80,"system_id":197,"tgid":1716,"name":"Police Secondary","alpha_tag":"BarringtonPD 2","tg_group":"Barrington","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":81,"system_id":197,"tgid":1744,"name":"Fire Operations (Patch from VHF)","alpha_tag":"Bristol FD","tg_group":"Bristol","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":82,"system_id":197,"tgid":1755,"name":"Harbormaster","alpha_tag":"Bristol Harbor","tg_group":"Bristol","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":83,"system_id":197,"tgid":2003,"name":"Police","alpha_tag":"Burrville PD","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":84,"system_id":197,"tgid":2004,"name":"Police 2","alpha_tag":"Burrvl PD2","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":85,"system_id":197,"tgid":2005,"name":"Police 3 Detectives","alpha_tag":"Burrvl PD3","tg_group":"Burrillville","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":86,"system_id":197,"tgid":2006,"name":"Police 4","alpha_tag":"Burrvl PD4","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":87,"system_id":197,"tgid":2000,"name":"Fire Misc (Ops are VHF)","alpha_tag":"Burrvl FD","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":88,"system_id":197,"tgid":2001,"name":"Fire TAC-1","alpha_tag":"Burvl FDTAC1","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":89,"system_id":197,"tgid":2009,"name":"Fire TAC-2","alpha_tag":"Burvl FDTAC2","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":90,"system_id":197,"tgid":2002,"name":"EMS Misc (Ops are VHF)","alpha_tag":"Burrvl EMS","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["EMS-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":91,"system_id":197,"tgid":2007,"name":"Town-Wide","alpha_tag":"Burrvl Town","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Multi-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":92,"system_id":197,"tgid":2008,"name":"Emergency Management","alpha_tag":"Burrvl EMA","tg_group":"Burrillville","frequency":null,"metadata":null,"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":93,"system_id":197,"tgid":1838,"name":"Police 1 Dispatch","alpha_tag":"CentFallsPD1","tg_group":"Central Falls","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":94,"system_id":197,"tgid":1839,"name":"Police 2","alpha_tag":"CentFallsPD2","tg_group":"Central Falls","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":95,"system_id":197,"tgid":1835,"name":"Fire Dispatch (Simulcast of UHF)","alpha_tag":"CentFalls FD 1","tg_group":"Central Falls","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":96,"system_id":197,"tgid":1836,"name":"Fireground","alpha_tag":"CentFalls FD 2","tg_group":"Central Falls","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":97,"system_id":197,"tgid":1425,"name":"Police Operations - Simulcast of UHF","alpha_tag":"CharlestownPD","tg_group":"Charlestown","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":98,"system_id":197,"tgid":1429,"name":"EMS - Linked to 151.3325","alpha_tag":"Chastown EMS","tg_group":"Charlestown","frequency":null,"metadata":null,"tags":["EMS Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":99,"system_id":197,"tgid":1483,"name":"Police 1 - Dispatch","alpha_tag":"Coventry PD","tg_group":"Coventry","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":100,"system_id":197,"tgid":1484,"name":"Police 2","alpha_tag":"Coventry PD2","tg_group":"Coventry","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":101,"system_id":197,"tgid":1480,"name":"Fire","alpha_tag":"Coventry FD","tg_group":"Coventry","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":102,"system_id":197,"tgid":1500,"name":"Fire - Dispatch/Operations","alpha_tag":"Cranston FD Disp","tg_group":"Cranston","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":103,"system_id":197,"tgid":1501,"name":"Fire - Fireground 2","alpha_tag":"Cranston FD FG2","tg_group":"Cranston","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":104,"system_id":197,"tgid":1502,"name":"Fire - Fireground 3","alpha_tag":"Cranston FD FG3","tg_group":"Cranston","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":105,"system_id":197,"tgid":1503,"name":"Fire - Fireground 4","alpha_tag":"Cranston FD FG4","tg_group":"Cranston","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":106,"system_id":197,"tgid":1504,"name":"Fire - Admin/Alt Fireground 5","alpha_tag":"Cranston FD Admi","tg_group":"Cranston","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":107,"system_id":197,"tgid":1520,"name":"Fire","alpha_tag":"Cumberland FD","tg_group":"Cumberland","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":108,"system_id":197,"tgid":1523,"name":"Police Secondary","alpha_tag":"Cumberland PD","tg_group":"Cumberland","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":109,"system_id":197,"tgid":1776,"name":"Fire Talk Around","alpha_tag":"E Greenwich F-TA","tg_group":"East Greenwich","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":110,"system_id":197,"tgid":1779,"name":"Police Operations","alpha_tag":"E Greenwich PD","tg_group":"East Greenwich","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":111,"system_id":197,"tgid":1869,"name":"Police 1 - Dispatch","alpha_tag":"E Prov PD 1","tg_group":"East Providence","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":112,"system_id":197,"tgid":1872,"name":"Police 2","alpha_tag":"E Prov PD 2","tg_group":"East Providence","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":113,"system_id":197,"tgid":1870,"name":"Police 3","alpha_tag":"E Prov PD 3","tg_group":"East Providence","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":114,"system_id":197,"tgid":1883,"name":"Detectives","alpha_tag":"E Prov PD12","tg_group":"East Providence","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":115,"system_id":197,"tgid":1866,"name":"Fire - Dispatch/Operations","alpha_tag":"E Prov FD 1","tg_group":"East Providence","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":116,"system_id":197,"tgid":1867,"name":"Fire \"Channel 2\"","alpha_tag":"E Prov FD 2","tg_group":"East Providence","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":117,"system_id":197,"tgid":1878,"name":"Fire \"Channel 3\"","alpha_tag":"E Prov FD 3","tg_group":"East Providence","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":118,"system_id":197,"tgid":2064,"name":"Fire - Fireground","alpha_tag":"Exeter FD-G","tg_group":"Exeter","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":119,"system_id":197,"tgid":1904,"name":"Fire","alpha_tag":"Foster Fire","tg_group":"Foster","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":120,"system_id":197,"tgid":1939,"name":"Police","alpha_tag":"Glocester PD","tg_group":"Glocester","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":121,"system_id":197,"tgid":1940,"name":"Police Secondary","alpha_tag":"Glocester PD 2","tg_group":"Glocester","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":122,"system_id":197,"tgid":1410,"name":"Police","alpha_tag":"Hopkinton PD","tg_group":"Hopkinton","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":123,"system_id":197,"tgid":1100,"name":"Police 1 - Dispatch","alpha_tag":"Jamestown PD 1","tg_group":"Jamestown","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":124,"system_id":197,"tgid":1101,"name":"Police 2","alpha_tag":"Jamestown PD 2","tg_group":"Jamestown","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":125,"system_id":197,"tgid":1108,"name":"Fire","alpha_tag":"Jamestown FD","tg_group":"Jamestown","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":126,"system_id":197,"tgid":1120,"name":"Fireground 1","alpha_tag":"Jamestown FG 1","tg_group":"Jamestown","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":127,"system_id":197,"tgid":1121,"name":"Fireground 2","alpha_tag":"Jamestown FG 2","tg_group":"Jamestown","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":128,"system_id":197,"tgid":1114,"name":"Public Works","alpha_tag":"Jamestown DPW","tg_group":"Jamestown","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":129,"system_id":197,"tgid":1107,"name":"Town Schools","alpha_tag":"Jamestown School","tg_group":"Jamestown","frequency":null,"metadata":null,"tags":["Schools"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":130,"system_id":197,"tgid":1619,"name":"Police Operations","alpha_tag":"Johnston PD","tg_group":"Johnston","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":131,"system_id":197,"tgid":1616,"name":"Fire Operations","alpha_tag":"Johnston FD","tg_group":"Johnston","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":132,"system_id":197,"tgid":1617,"name":"Fireground","alpha_tag":"Johnston FG","tg_group":"Johnston","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":133,"system_id":197,"tgid":1683,"name":"Police F1","alpha_tag":"Lincoln Police","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":134,"system_id":197,"tgid":1684,"name":"Police F2","alpha_tag":"Lincoln Police 2","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":135,"system_id":197,"tgid":1680,"name":"Fire Dispatch","alpha_tag":"Lincoln Fire 1","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":136,"system_id":197,"tgid":1681,"name":"Fireground 2","alpha_tag":"Lincoln Fire 2","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":137,"system_id":197,"tgid":1691,"name":"Fireground 3","alpha_tag":"Lincoln Fire 3","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":138,"system_id":197,"tgid":1682,"name":"EMS","alpha_tag":"Lincoln EMS","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["EMS Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":139,"system_id":197,"tgid":1688,"name":"Emergency Management","alpha_tag":"Lincoln EMA","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":140,"system_id":197,"tgid":1687,"name":"Townwide","alpha_tag":"Lincoln Townwide","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":141,"system_id":197,"tgid":1692,"name":"Public Works","alpha_tag":"Lincoln DPW","tg_group":"Lincoln","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":142,"system_id":197,"tgid":1264,"name":"Police","alpha_tag":"LittleCompPD","tg_group":"Little Compton","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":143,"system_id":197,"tgid":1266,"name":"Fire","alpha_tag":"LittleCompFD","tg_group":"Little Compton","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":144,"system_id":197,"tgid":1338,"name":"Police Operations","alpha_tag":"MiddletownPD","tg_group":"Middletown","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":145,"system_id":197,"tgid":1343,"name":"Fire Operations","alpha_tag":"Middletown FD","tg_group":"Middletown","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":146,"system_id":197,"tgid":1345,"name":"Townwide","alpha_tag":"MiddletownTW","tg_group":"Middletown","frequency":null,"metadata":null,"tags":["Multi-Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":147,"system_id":197,"tgid":1001,"name":"Police - Dispatch","alpha_tag":"Narrag PD 1","tg_group":"Narragansett","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":148,"system_id":197,"tgid":1002,"name":"Police - Car/Car","alpha_tag":"Narrag PD 2","tg_group":"Narragansett","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":149,"system_id":197,"tgid":1003,"name":"Police - Special Details 1/Town Beaches","alpha_tag":"Narrag PD 3","tg_group":"Narragansett","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":150,"system_id":197,"tgid":1004,"name":"Police - Special Details 2","alpha_tag":"Narrag PD 4","tg_group":"Narragansett","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":151,"system_id":197,"tgid":1005,"name":"Police - Harbormaster","alpha_tag":"Narrag PD 5","tg_group":"Narragansett","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":152,"system_id":197,"tgid":1007,"name":"Police - Detectives","alpha_tag":"Narrag PD 7","tg_group":"Narragansett","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":153,"system_id":197,"tgid":1008,"name":"Police - Detectives","alpha_tag":"Narrag PD 8","tg_group":"Narragansett","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":154,"system_id":197,"tgid":1006,"name":"Fire - Dispatch","alpha_tag":"Narrag FD","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":155,"system_id":197,"tgid":1012,"name":"Fire - Fireground 1","alpha_tag":"Narrag FDFG1","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":156,"system_id":197,"tgid":1013,"name":"Fire - Fireground 2","alpha_tag":"Narrag FDFG2","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":157,"system_id":197,"tgid":1016,"name":"Fire - Administration","alpha_tag":"Narrag FD AD","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":158,"system_id":197,"tgid":1014,"name":"Fire - EMS Ops","alpha_tag":"Narrag EMS","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["EMS Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":159,"system_id":197,"tgid":1017,"name":"Public Works","alpha_tag":"Narrag DPW","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":160,"system_id":197,"tgid":1010,"name":"Town Administration","alpha_tag":"Narrag TownA","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["Other"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":161,"system_id":197,"tgid":1011,"name":"Townwide Interop","alpha_tag":"Narrag IOP","tg_group":"Narragansett","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":162,"system_id":197,"tgid":1376,"name":"Police","alpha_tag":"New Shore PD","tg_group":"New Shoreham","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":163,"system_id":197,"tgid":1300,"name":"Police 1 - Dispatch","alpha_tag":"Newport PD 1","tg_group":"Newport","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":164,"system_id":197,"tgid":1302,"name":"Police 2 - Records","alpha_tag":"Newport PD 2","tg_group":"Newport","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":165,"system_id":197,"tgid":1304,"name":"Police 4 - Tactical 1","alpha_tag":"Newport PD 4","tg_group":"Newport","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":166,"system_id":197,"tgid":1307,"name":"Police 7 - Tactical 4","alpha_tag":"Newport PD 7","tg_group":"Newport","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":167,"system_id":197,"tgid":1308,"name":"Police 8 - Tactical 5","alpha_tag":"Newport PD 8","tg_group":"Newport","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":168,"system_id":197,"tgid":1303,"name":"Fire Dispatch/Operations","alpha_tag":"Newport FD1","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":169,"system_id":197,"tgid":1305,"name":"Fireground Ops 1","alpha_tag":"Newport FG1","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":170,"system_id":197,"tgid":1306,"name":"Fireground Ops 2","alpha_tag":"Newport FG2","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":171,"system_id":197,"tgid":1301,"name":"Fire - Training","alpha_tag":"Newport FDT","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":172,"system_id":197,"tgid":1291,"name":"Water Department","alpha_tag":"Newport Water","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":173,"system_id":197,"tgid":1293,"name":"Public Works","alpha_tag":"Newport DPW","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":174,"system_id":197,"tgid":1297,"name":"Citywide Events","alpha_tag":"Newport Evnt","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":175,"system_id":197,"tgid":1312,"name":"Newport Citywide","alpha_tag":"Newport CW","tg_group":"Newport","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":176,"system_id":197,"tgid":1285,"name":"Police 1 - Dispatch","alpha_tag":"NKing PD 1","tg_group":"North Kingstown","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":177,"system_id":197,"tgid":1286,"name":"Police 2 - Admin","alpha_tag":"NKing PD 2","tg_group":"North Kingstown","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":178,"system_id":197,"tgid":1287,"name":"Police 3 - Car/Car","alpha_tag":"NKing PD 3","tg_group":"North Kingstown","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":179,"system_id":197,"tgid":1280,"name":"Fire - Dispatch","alpha_tag":"NKing Fire D","tg_group":"North Kingstown","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":180,"system_id":197,"tgid":1281,"name":"Fire - Fireground","alpha_tag":"NKing Fire G","tg_group":"North Kingstown","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":181,"system_id":197,"tgid":1536,"name":"Police 1 - Dispatch","alpha_tag":"NorthPrv PD1","tg_group":"North Providence","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":182,"system_id":197,"tgid":1537,"name":"Police 2 - Car/Car","alpha_tag":"NorthPrv PD2","tg_group":"North Providence","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":183,"system_id":197,"tgid":1538,"name":"Police 3 - Tactical","alpha_tag":"NorthPrv PD3","tg_group":"North Providence","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":184,"system_id":197,"tgid":1547,"name":"Fire Dispatch ","alpha_tag":"NorthPrv FDD","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":185,"system_id":197,"tgid":1548,"name":"Fire 2","alpha_tag":"NorthPrv Fire 2","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":186,"system_id":197,"tgid":1549,"name":"Fire 3","alpha_tag":"NorthPrv Fire 3","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":187,"system_id":197,"tgid":1550,"name":"Fire 4","alpha_tag":"NorthPrv Fire 4","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":188,"system_id":197,"tgid":1551,"name":"Fire 5","alpha_tag":"NorthPrv Fire 5","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":189,"system_id":197,"tgid":1552,"name":"Fire 6","alpha_tag":"NorthPrv Fire 6","tg_group":"North Providence","frequency":null,"metadata":{"encrypted":true},"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":190,"system_id":197,"tgid":1544,"name":"Townwide 1","alpha_tag":"NorthPrv TownW 1","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":191,"system_id":197,"tgid":1545,"name":"Townwide 2","alpha_tag":"NorthPrv TownW 2","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Interop"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":192,"system_id":197,"tgid":1554,"name":"Public Works","alpha_tag":"NorthPrv DPW","tg_group":"North Providence","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":193,"system_id":197,"tgid":1971,"name":"Police","alpha_tag":"N Smithfd PD","tg_group":"North Smithfield","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":194,"system_id":197,"tgid":1968,"name":"Fire Dispatch/Operations","alpha_tag":"N Smithfield FD","tg_group":"North Smithfield","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":195,"system_id":197,"tgid":1969,"name":"Fire Secondary","alpha_tag":"N Smithfield FD2","tg_group":"North Smithfield","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":196,"system_id":197,"tgid":1981,"name":"Fireground","alpha_tag":"N Smithfield FD3","tg_group":"North Smithfield","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":197,"system_id":197,"tgid":1440,"name":"Fire - Operations","alpha_tag":"Pawtucket FD 1","tg_group":"Pawtucket","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":198,"system_id":197,"tgid":1441,"name":"Fireground","alpha_tag":"Pawtucket FG","tg_group":"Pawtucket","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":199,"system_id":197,"tgid":1442,"name":"EMS Tac","alpha_tag":"Pawtucket EMSTac","tg_group":"Pawtucket","frequency":null,"metadata":null,"tags":["EMS-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":200,"system_id":197,"tgid":1248,"name":"Police","alpha_tag":"PortsmouthPD","tg_group":"Portsmouth","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":201,"system_id":197,"tgid":1253,"name":"Fire Dispatch (Patch to VHF Primary)","alpha_tag":"Portsmouth FD","tg_group":"Portsmouth","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":202,"system_id":197,"tgid":1255,"name":"Fireground","alpha_tag":"Portsmouth FG","tg_group":"Portsmouth","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":203,"system_id":197,"tgid":1262,"name":"Island Fire Dispatch","alpha_tag":"Prudence Isl FD","tg_group":"Portsmouth","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":204,"system_id":197,"tgid":10000,"name":"Police - All Call - Emergency Broadcasts","alpha_tag":"PPD ATG","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":205,"system_id":197,"tgid":10001,"name":"Police 1 - Dispatch","alpha_tag":"PPD CH 1","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":206,"system_id":197,"tgid":10002,"name":"Police 2","alpha_tag":"PPD CH 2","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":207,"system_id":197,"tgid":10003,"name":"Police 3","alpha_tag":"PPD CH 3","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":208,"system_id":197,"tgid":10004,"name":"Police 4","alpha_tag":"PPD CH-4","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":209,"system_id":197,"tgid":10005,"name":"Police 5 -Detectives 1","alpha_tag":"PPD DETEC 1","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":210,"system_id":197,"tgid":10006,"name":"Police 6 - Car-to-Car","alpha_tag":"PPD T/A","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":211,"system_id":197,"tgid":10007,"name":"Police 7 - Narcotics 1","alpha_tag":"PPD NARC 1","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":212,"system_id":197,"tgid":10008,"name":"Police 8 - Narcotics 2","alpha_tag":"PPD NARC 2","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":213,"system_id":197,"tgid":10009,"name":"Police 9 - Detectives 2","alpha_tag":"PPD DETEC 2","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":214,"system_id":197,"tgid":10010,"name":"Police 10 - Special Details 1","alpha_tag":"PPD DETAIL 1","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":215,"system_id":197,"tgid":10011,"name":"Police 11 - Special Details 2","alpha_tag":"PPD DETAIL 2","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":216,"system_id":197,"tgid":10012,"name":"Police 12 - Corrections Security","alpha_tag":"PPD CORR SEC","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":217,"system_id":197,"tgid":10013,"name":"Police 13 - Special Response Unit","alpha_tag":"PPD SRU","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":218,"system_id":197,"tgid":10014,"name":"Police 14 - Administration","alpha_tag":"PPD ADMIN","tg_group":"Providence (City)","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":219,"system_id":197,"tgid":10100,"name":"Fire All Call - Emergency Broadcasts","alpha_tag":"PROV FD ATG","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":220,"system_id":197,"tgid":10101,"name":"Fire Dispatch","alpha_tag":"PFD DISPATCH","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":221,"system_id":197,"tgid":10107,"name":"Fireground 2","alpha_tag":"PFD CH-2 FG","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":222,"system_id":197,"tgid":10108,"name":"Fireground 3","alpha_tag":"PFD CH-3 FG","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":223,"system_id":197,"tgid":10109,"name":"Fireground 4","alpha_tag":"PFD CH-4 FG","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":224,"system_id":197,"tgid":10102,"name":"Fire 5","alpha_tag":"PFD CH-5","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":225,"system_id":197,"tgid":10103,"name":"Fire 6","alpha_tag":"PFD CH-6","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":226,"system_id":197,"tgid":10104,"name":"Fire 7","alpha_tag":"PFD CH-7","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":227,"system_id":197,"tgid":10110,"name":"Fire - Mutual Aid 1","alpha_tag":"PFD M/A 1","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":228,"system_id":197,"tgid":10111,"name":"Fire - Mutual Aid 2","alpha_tag":"PFD M/A 2","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":229,"system_id":197,"tgid":10112,"name":"Fire - Mutual Aid 3","alpha_tag":"PFD M/A 3","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":230,"system_id":197,"tgid":10113,"name":"Fireground 8","alpha_tag":"PFD Fireground 8","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":231,"system_id":197,"tgid":10105,"name":"Fire - Administration","alpha_tag":"PFD ADMIN","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":232,"system_id":197,"tgid":10106,"name":"Fire - Communications","alpha_tag":"PFD COMM","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":233,"system_id":197,"tgid":10207,"name":"Public Works","alpha_tag":"PROV DPW","tg_group":"Providence (City)","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":234,"system_id":197,"tgid":2035,"name":"Police","alpha_tag":"Richmond PD","tg_group":"Richmond","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":235,"system_id":197,"tgid":2042,"name":"Chariho Regional High School","alpha_tag":"Chariho Reg HS","tg_group":"Richmond","frequency":null,"metadata":null,"tags":["Schools"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":236,"system_id":197,"tgid":1460,"name":"Police","alpha_tag":"Scituate PD","tg_group":"Scituate","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":237,"system_id":197,"tgid":1463,"name":"Fire Operations","alpha_tag":"Scituate FD","tg_group":"Scituate","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":238,"system_id":197,"tgid":1651,"name":"Police Operations","alpha_tag":"SmithfieldPD","tg_group":"Smithfield","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":239,"system_id":197,"tgid":1652,"name":"Police Secondary","alpha_tag":"Smfld PD 2","tg_group":"Smithfield","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":240,"system_id":197,"tgid":1653,"name":"Police Detectives","alpha_tag":"Smfld PD Det","tg_group":"Smithfield","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":241,"system_id":197,"tgid":1654,"name":"Police Admin","alpha_tag":"Smfld PD Adm","tg_group":"Smithfield","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":242,"system_id":197,"tgid":1661,"name":"Police Details","alpha_tag":"Smfld PD Dtl","tg_group":"Smithfield","frequency":null,"metadata":null,"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":243,"system_id":197,"tgid":1648,"name":"Fire - Fireground","alpha_tag":"SmithfieldFD","tg_group":"Smithfield","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":244,"system_id":197,"tgid":1655,"name":"Town-Wide","alpha_tag":"Smfld Town","tg_group":"Smithfield","frequency":null,"metadata":null,"tags":["Multi-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":245,"system_id":197,"tgid":1657,"name":"Emergency Management","alpha_tag":"Smfld EMA","tg_group":"Smithfield","frequency":null,"metadata":null,"tags":["Emergency Ops"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":246,"system_id":197,"tgid":1660,"name":"Public Works","alpha_tag":"Smfld DPW","tg_group":"Smithfield","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":247,"system_id":197,"tgid":1225,"name":"Police 1 - Dispatch","alpha_tag":"SKing PD 1","tg_group":"South Kingstown","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":248,"system_id":197,"tgid":1226,"name":"Police 2 - Car/Car","alpha_tag":"SKing PD 2","tg_group":"South Kingstown","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":249,"system_id":197,"tgid":1235,"name":"Police 3 - Tactical","alpha_tag":"SKing PD 3","tg_group":"South Kingstown","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":250,"system_id":197,"tgid":1236,"name":"Police 5 - Tactical","alpha_tag":"SKing PD 5","tg_group":"South Kingstown","frequency":null,"metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":251,"system_id":197,"tgid":1232,"name":"Fire - UHF Simulcast","alpha_tag":"SKing FD Lnk","tg_group":"South Kingstown","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":252,"system_id":197,"tgid":1240,"name":"Fire - Detail","alpha_tag":"SKing Fire D","tg_group":"South Kingstown","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":253,"system_id":197,"tgid":1227,"name":"Union Fire District - Fireground 1","alpha_tag":"UnionFD FG 1","tg_group":"South Kingstown","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":254,"system_id":197,"tgid":1237,"name":"Union Fire District - Fireground 2","alpha_tag":"UnionFD FG 2","tg_group":"South Kingstown","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":255,"system_id":197,"tgid":1026,"name":"Union Fire District - Special Events","alpha_tag":"UnionFD Evnt","tg_group":"South Kingstown","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":256,"system_id":197,"tgid":1015,"name":"EMS","alpha_tag":"SKing EMS","tg_group":"South Kingstown","frequency":null,"metadata":{"encrypted":true},"tags":["EMS Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":257,"system_id":197,"tgid":1316,"name":"Police (Simulcast 482.9625)","alpha_tag":"Tiverton PD","tg_group":"Tiverton","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":258,"system_id":197,"tgid":1315,"name":"Fire (Simulcast 471.7875)","alpha_tag":"Tiverton FD","tg_group":"Tiverton","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":259,"system_id":197,"tgid":1162,"name":"Fire","alpha_tag":"Warwick FD","tg_group":"Warwick","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":260,"system_id":197,"tgid":1170,"name":"Fireground","alpha_tag":"Warwick FG","tg_group":"Warwick","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":261,"system_id":197,"tgid":1805,"name":"Police","alpha_tag":"W Greenwh PD","tg_group":"West Greenwich","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":262,"system_id":197,"tgid":1806,"name":"Police Secondary","alpha_tag":"W GreenwichPD2","tg_group":"West Greenwich","frequency":null,"metadata":null,"tags":["Law Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":263,"system_id":197,"tgid":1208,"name":"Fire Operations","alpha_tag":"W Warwick FD","tg_group":"West Warwick","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":264,"system_id":197,"tgid":1050,"name":"Police 1 - Dispatch","alpha_tag":"Westerly PD1","tg_group":"Westerly","frequency":null,"metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":265,"system_id":197,"tgid":1051,"name":"Police 2","alpha_tag":"Westerly PD2","tg_group":"Westerly","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":266,"system_id":197,"tgid":1052,"name":"Police 3","alpha_tag":"Westerly PD3","tg_group":"Westerly","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":267,"system_id":197,"tgid":1053,"name":"Police 4","alpha_tag":"Westerly PD4","tg_group":"Westerly","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":268,"system_id":197,"tgid":1054,"name":"Police 5 - Reserve Officers","alpha_tag":"Westerly PD5","tg_group":"Westerly","frequency":null,"metadata":null,"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":269,"system_id":197,"tgid":1064,"name":"Police 6 - Traffic Division","alpha_tag":"Westerly PD6","tg_group":"Westerly","frequency":null,"metadata":null,"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":270,"system_id":197,"tgid":1063,"name":"Fire Operations","alpha_tag":"Westerly FD","tg_group":"Westerly","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":271,"system_id":197,"tgid":1072,"name":"Police/Fire/EMS Ops","alpha_tag":"Westerly PFE","tg_group":"Westerly","frequency":null,"metadata":null,"tags":["Multi-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":272,"system_id":197,"tgid":1082,"name":"EMS Operations","alpha_tag":"Westerly EMS ","tg_group":"Westerly","frequency":null,"metadata":null,"tags":["EMS Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":273,"system_id":197,"tgid":1363,"name":"Police 1 - Dispatch","alpha_tag":"Woonskt PD 1","tg_group":"Woonsocket","frequency":null,"metadata":null,"tags":["Law Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":274,"system_id":197,"tgid":1364,"name":"Police 2","alpha_tag":"Woonskt PD 2","tg_group":"Woonsocket","frequency":null,"metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":275,"system_id":197,"tgid":1360,"name":"Fire Dispatch - Operations","alpha_tag":"Woonsocket FD D","tg_group":"Woonsocket","frequency":null,"metadata":null,"tags":["Fire-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":276,"system_id":197,"tgid":1361,"name":"Fire Secondary","alpha_tag":"Woonsocket FD 2","tg_group":"Woonsocket","frequency":null,"metadata":null,"tags":["Fire Dispatch"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":277,"system_id":197,"tgid":1354,"name":"Fire - Fireground 3","alpha_tag":"Woonskt FD 3","tg_group":"Woonsocket","frequency":null,"metadata":null,"tags":["Fire-Tac"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":278,"system_id":197,"tgid":1367,"name":"Citywide","alpha_tag":"Woonskt City","tg_group":"Woonsocket","frequency":null,"metadata":null,"tags":["Multi-Talk"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":279,"system_id":197,"tgid":1368,"name":"Public Works - Streets","alpha_tag":"Woonsocket PW","tg_group":"Woonsocket","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":280,"system_id":197,"tgid":1,"name":"RISCON Radio Technicians","alpha_tag":"Radio Techs","tg_group":"Radio Technicians","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":281,"system_id":197,"tgid":10125,"name":"RISCON Radio Technicians","alpha_tag":"Radio Techs","tg_group":"Radio Technicians","frequency":null,"metadata":null,"tags":["Public Works"],"alert":true,"alert_rules":null,"weight":1,"ignored":false,"system":{"id":197,"name":"RISCON"},"learned":false}] \ No newline at end of file +[{"systemId":197,"tgid":2,"name":"Intercity Fire","alphaTag":"Intercity FD","tgGroup":"Statewide Mutual Aid/Intersystem","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":1,"systemId":197,"tgid":3,"name":"Intercity Police","alphaTag":"Intercity PD","tgGroup":"Statewide Mutual Aid/Intersystem","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":2,"systemId":197,"tgid":21,"name":"North Dispatch ","alphaTag":"RISP N Disp","tgGroup":"State Police - District A (North)","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":3,"systemId":197,"tgid":22,"name":"North Car-to-Car/Information","alphaTag":"RISP N Car","tgGroup":"State Police - District A (North)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":4,"systemId":197,"tgid":24,"name":"North Tactical Ops 1","alphaTag":"RISP N Tac 1","tgGroup":"State Police - District A (North)","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":5,"systemId":197,"tgid":23,"name":"North Tactical Ops 2","alphaTag":"RISP N Tac 2","tgGroup":"State Police - District A (North)","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":6,"systemId":197,"tgid":25,"name":"South Dispatch ","alphaTag":"RISP S Disp","tgGroup":"State Police - District B (South)","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":7,"systemId":197,"tgid":27,"name":"South Car-to-Car/Information","alphaTag":"RISP S Car","tgGroup":"State Police - District B (South)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":8,"systemId":197,"tgid":16,"name":"State Fire Marshall","alphaTag":"State FMO","tgGroup":"Statewide Fire","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":9,"systemId":197,"tgid":1038,"name":"Northern Rhode Island Fire Chiefs","alphaTag":"NRI Fire Chi","tgGroup":"Statewide Fire","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":10,"systemId":197,"tgid":1041,"name":"Southern Rhode Island Fire Chiefs","alphaTag":"SRI Fire Chi","tgGroup":"Statewide Fire","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":11,"systemId":197,"tgid":1314,"name":"Tanker Taskforce 1","alphaTag":"Tanker TF 1","tgGroup":"Statewide Fire","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":12,"systemId":197,"tgid":194,"name":"Lifepact Ambulance (Statewide)","alphaTag":"Lifepact Amb","tgGroup":"Statewide EMS and Hospitals","tags":["EMS Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":13,"systemId":197,"tgid":212,"name":"Fatima St Josephs","alphaTag":"Fatima-St Joes","tgGroup":"Statewide EMS and Hospitals","tags":["Business"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":14,"systemId":197,"tgid":220,"name":"Health 1","alphaTag":"Health 1","tgGroup":"Statewide EMS and Hospitals","metadata":{"encrypted":true},"tags":["EMS-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":15,"systemId":197,"tgid":221,"name":"Health 2","alphaTag":"Health 2","tgGroup":"Statewide EMS and Hospitals","metadata":{"encrypted":true},"tags":["EMS-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":16,"systemId":197,"tgid":222,"name":"Department of Health - Statewide","alphaTag":"Dept of HealthSW","tgGroup":"Statewide EMS and Hospitals","tags":["EMS-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":17,"systemId":197,"tgid":228,"name":"DMAT South","alphaTag":"DMAT South","tgGroup":"Statewide EMS and Hospitals","metadata":{"encrypted":true},"tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":18,"systemId":197,"tgid":232,"name":"Life Span Net 1","alphaTag":"Life Span 1","tgGroup":"Statewide EMS and Hospitals","tags":["EMS-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":19,"systemId":197,"tgid":234,"name":"RI Hospital Operations","alphaTag":"RI Hosp Ops","tgGroup":"Statewide EMS and Hospitals","tags":["Business"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":20,"systemId":197,"tgid":120,"name":"Law Enforcement Operations","alphaTag":"DEM PD Ops","tgGroup":"Department of Environmental Management","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":21,"systemId":197,"tgid":122,"name":"Law Enforcement Police","alphaTag":"DEM Police","tgGroup":"Department of Environmental Management","tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":22,"systemId":197,"tgid":10,"name":"Emergency Management Agency 1","alphaTag":"EMA-1","tgGroup":"Emergency Management Agency","tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":23,"systemId":197,"tgid":20,"name":"Emergency Management Agency","alphaTag":"EMA","tgGroup":"Emergency Management Agency","tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":24,"systemId":197,"tgid":4,"name":"Wide Area 3","alphaTag":"Wide Area 3","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":25,"systemId":197,"tgid":5,"name":"Wide Area 4","alphaTag":"Wide Area 4","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":26,"systemId":197,"tgid":6,"name":"Wide Area 5","alphaTag":"Wide Area 5","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":27,"systemId":197,"tgid":7,"name":"Wide Area 6","alphaTag":"Wide Area 6","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":28,"systemId":197,"tgid":1018,"name":"Southwide CH-1","alphaTag":"SOUTHWIDE 1","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":29,"systemId":197,"tgid":1019,"name":"Southwide CH-2","alphaTag":"SOUTHWIDE 2","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":30,"systemId":197,"tgid":1022,"name":"Wide Area 7","alphaTag":"WIDE AREA 7","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":31,"systemId":197,"tgid":1023,"name":"Wide Area 8","alphaTag":"WIDE AREA 8","tgGroup":"Statewide Area/Events","metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":32,"systemId":197,"tgid":1025,"name":"Inland Marine Interop","alphaTag":"Inland Marine IO","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":33,"systemId":197,"tgid":1037,"name":"Southside CH 5","alphaTag":"SOUTHSIDE 5","tgGroup":"Statewide Area/Events","metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":34,"systemId":197,"tgid":1173,"name":"North Wide 1","alphaTag":"NORTHWIDE1","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":35,"systemId":197,"tgid":1174,"name":"North Wide 2","alphaTag":"NORTHWIDE2","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":36,"systemId":197,"tgid":1177,"name":"North Wide 5","alphaTag":"NORTHWIDE5","tgGroup":"Statewide Area/Events","metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":37,"systemId":197,"tgid":1185,"name":"Metro Wide 1","alphaTag":"METROWIDE1","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":38,"systemId":197,"tgid":1186,"name":"Metro Wide 2","alphaTag":"METROWIDE2","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":39,"systemId":197,"tgid":1187,"name":"Metro Wide 3","alphaTag":"METROWIDE3","tgGroup":"Statewide Area/Events","metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":40,"systemId":197,"tgid":1335,"name":"East Wide 1","alphaTag":"EASTWIDE 1","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":41,"systemId":197,"tgid":1336,"name":"East Wide 2","alphaTag":"EASTWIDE 2","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":42,"systemId":197,"tgid":1337,"name":"East Wide 3","alphaTag":"EASTWIDE 3","tgGroup":"Statewide Area/Events","metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":43,"systemId":197,"tgid":11186,"name":"Metro Wide 2","alphaTag":"METROWIDE2","tgGroup":"Statewide Area/Events","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":44,"systemId":197,"tgid":1033,"name":"Tanker Taskforce ","alphaTag":"TANK TF","tgGroup":"Statewide Emergency Response","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":45,"systemId":197,"tgid":1034,"name":"Hazmat 1","alphaTag":"HZT DC1","tgGroup":"Statewide Emergency Response","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":46,"systemId":197,"tgid":1035,"name":"Hazmat 2","alphaTag":"HZT DC2","tgGroup":"Statewide Emergency Response","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":47,"systemId":197,"tgid":176,"name":"Department of Transportation - Primary","alphaTag":"RIDOT Primary","tgGroup":"Department of Transportation","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":48,"systemId":197,"tgid":4421,"name":"Newport Pell Bridge Operations","alphaTag":"RITBA - Pell Bdg","tgGroup":"Tunnel and Bridge Authority","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":49,"systemId":197,"tgid":274,"name":"Providence VA Police","alphaTag":"VA Police","tgGroup":"Federal","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":50,"systemId":197,"tgid":186,"name":"Rhode Island Public Transit Auth.","alphaTag":"RIPTA","tgGroup":"RIPTA","metadata":{"encrypted":true},"tags":["Transportation"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":51,"systemId":197,"tgid":187,"name":"Rhode Island Public Transit Auth.","alphaTag":"RIPTA","tgGroup":"RIPTA","tags":["Transportation"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":52,"systemId":197,"tgid":188,"name":"Rhode Island Public Transit Auth.","alphaTag":"RIPTA","tgGroup":"RIPTA","tags":["Transportation"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":53,"systemId":197,"tgid":189,"name":"Rhode Island Public Transit Auth.","alphaTag":"RIPTA","tgGroup":"RIPTA","tags":["Transportation"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":54,"systemId":197,"tgid":190,"name":"Rhode Island Public Transit. Auth.","alphaTag":"RIPTA","tgGroup":"RIPTA","tags":["Transportation"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":55,"systemId":197,"tgid":304,"name":"Fire Operations","alphaTag":"Quonset ANGB FD","tgGroup":"Quonset ANGB","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":56,"systemId":197,"tgid":17,"name":"Airport Police Operations","alphaTag":"TF Green PD","tgGroup":"Rhode Island Airport Commission","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":57,"systemId":197,"tgid":19,"name":"Airport Fire Operations","alphaTag":"TF Green FD","tgGroup":"Rhode Island Airport Commission","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":58,"systemId":197,"tgid":1126,"name":"University of Rhode Island Police - Dispatch","alphaTag":"URI PD","tgGroup":"College/Education Security","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":59,"systemId":197,"tgid":1131,"name":"University of Rhode Island - EMS","alphaTag":"URI EMS","tgGroup":"College/Education Security","metadata":{"encrypted":true},"tags":["EMS Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":60,"systemId":197,"tgid":1348,"name":"St. George's School (Middletown) - Security","alphaTag":"St George Sec","tgGroup":"College/Education Security","tags":["Security"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":61,"systemId":197,"tgid":10228,"name":"Rhode Island School of Design - Security","alphaTag":"RISD Secuty","tgGroup":"College/Education Security","metadata":{"encrypted":true},"tags":["Security"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":62,"systemId":197,"tgid":10229,"name":"Providence College Security - Dispatch","alphaTag":"PROV COLL","tgGroup":"College/Education Security","metadata":{"encrypted":true},"tags":["Security"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":63,"systemId":197,"tgid":10230,"name":"Rhode Island College Security","alphaTag":"RI COL SEC","tgGroup":"College/Education Security","tags":["Security"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":64,"systemId":197,"tgid":11001,"name":"Brown University Police - Dispatch","alphaTag":"BROWN UNIV","tgGroup":"College/Education Security","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":65,"systemId":197,"tgid":11002,"name":"Brown University Police - Car-to-Car","alphaTag":"BROWN CAR","tgGroup":"College/Education Security","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":66,"systemId":197,"tgid":11003,"name":"Brown University Police - Tactical","alphaTag":"BROWN TAC","tgGroup":"College/Education Security","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":67,"systemId":197,"tgid":12,"name":"Metro Wide 2","alphaTag":"METROWIDE2","tgGroup":"Statewide Misc.","metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":68,"systemId":197,"tgid":14,"name":"Metro Wide 4","alphaTag":"METROWIDE4","tgGroup":"Statewide Misc.","metadata":{"encrypted":true},"tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":69,"systemId":197,"tgid":70,"name":"RI Traffic Tribunal Security","alphaTag":"TFC TRIBUNAL","tgGroup":"Statewide Misc.","metadata":{"encrypted":true},"tags":["Security"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":70,"systemId":197,"tgid":168,"name":"Rhode Island Red Cross - Primary","alphaTag":"Red Cross 1","tgGroup":"Statewide Misc.","tags":["Other"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":71,"systemId":197,"tgid":169,"name":"Rhode Island Red Cross - Secondary","alphaTag":"Red Cross 2","tgGroup":"Statewide Misc.","tags":["Other"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":72,"systemId":197,"tgid":223,"name":"Statewide Nursing Homes Net","alphaTag":"NURSING HM","tgGroup":"Statewide Misc.","tags":["Other"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":73,"systemId":197,"tgid":243,"name":"Hospital Operations","alphaTag":"Slater Hosp Ops","tgGroup":"Statewide Misc.","tags":["Business"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":74,"systemId":197,"tgid":244,"name":"Slater Hospital Security","alphaTag":"Slater Hosp Sec","tgGroup":"Statewide Misc.","tags":["Security"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":75,"systemId":197,"tgid":1042,"name":"County Fireground","alphaTag":"WashCo FireG","tgGroup":"Washington County","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":76,"systemId":197,"tgid":1479,"name":"County Fire Station/Station","alphaTag":"WashCo FireS","tgGroup":"Washington County","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":77,"systemId":197,"tgid":1712,"name":"Fire 1 Dispatch","alphaTag":"BarringtnFD1","tgGroup":"Barrington","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":78,"systemId":197,"tgid":1713,"name":"Fire 2","alphaTag":"BarringtnFD2","tgGroup":"Barrington","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":79,"systemId":197,"tgid":1715,"name":"Police Operations","alphaTag":"BarringtonPD 1","tgGroup":"Barrington","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":80,"systemId":197,"tgid":1716,"name":"Police Secondary","alphaTag":"BarringtonPD 2","tgGroup":"Barrington","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":81,"systemId":197,"tgid":1744,"name":"Fire Operations (Patch from VHF)","alphaTag":"Bristol FD","tgGroup":"Bristol","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":82,"systemId":197,"tgid":1755,"name":"Harbormaster","alphaTag":"Bristol Harbor","tgGroup":"Bristol","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":83,"systemId":197,"tgid":2003,"name":"Police","alphaTag":"Burrville PD","tgGroup":"Burrillville","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":84,"systemId":197,"tgid":2004,"name":"Police 2","alphaTag":"Burrvl PD2","tgGroup":"Burrillville","tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":85,"systemId":197,"tgid":2005,"name":"Police 3 Detectives","alphaTag":"Burrvl PD3","tgGroup":"Burrillville","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":86,"systemId":197,"tgid":2006,"name":"Police 4","alphaTag":"Burrvl PD4","tgGroup":"Burrillville","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":87,"systemId":197,"tgid":2000,"name":"Fire Misc (Ops are VHF)","alphaTag":"Burrvl FD","tgGroup":"Burrillville","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":88,"systemId":197,"tgid":2001,"name":"Fire TAC-1","alphaTag":"Burvl FDTAC1","tgGroup":"Burrillville","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":89,"systemId":197,"tgid":2009,"name":"Fire TAC-2","alphaTag":"Burvl FDTAC2","tgGroup":"Burrillville","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":90,"systemId":197,"tgid":2002,"name":"EMS Misc (Ops are VHF)","alphaTag":"Burrvl EMS","tgGroup":"Burrillville","tags":["EMS-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":91,"systemId":197,"tgid":2007,"name":"Town-Wide","alphaTag":"Burrvl Town","tgGroup":"Burrillville","tags":["Multi-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":92,"systemId":197,"tgid":2008,"name":"Emergency Management","alphaTag":"Burrvl EMA","tgGroup":"Burrillville","tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":93,"systemId":197,"tgid":1838,"name":"Police 1 Dispatch","alphaTag":"CentFallsPD1","tgGroup":"Central Falls","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":94,"systemId":197,"tgid":1839,"name":"Police 2","alphaTag":"CentFallsPD2","tgGroup":"Central Falls","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":95,"systemId":197,"tgid":1835,"name":"Fire Dispatch (Simulcast of UHF)","alphaTag":"CentFalls FD 1","tgGroup":"Central Falls","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":96,"systemId":197,"tgid":1836,"name":"Fireground","alphaTag":"CentFalls FD 2","tgGroup":"Central Falls","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":97,"systemId":197,"tgid":1425,"name":"Police Operations - Simulcast of UHF","alphaTag":"CharlestownPD","tgGroup":"Charlestown","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":98,"systemId":197,"tgid":1429,"name":"EMS - Linked to 151.3325","alphaTag":"Chastown EMS","tgGroup":"Charlestown","tags":["EMS Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":99,"systemId":197,"tgid":1483,"name":"Police 1 - Dispatch","alphaTag":"Coventry PD","tgGroup":"Coventry","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":100,"systemId":197,"tgid":1484,"name":"Police 2","alphaTag":"Coventry PD2","tgGroup":"Coventry","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":101,"systemId":197,"tgid":1480,"name":"Fire","alphaTag":"Coventry FD","tgGroup":"Coventry","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":102,"systemId":197,"tgid":1500,"name":"Fire - Dispatch/Operations","alphaTag":"Cranston FD Disp","tgGroup":"Cranston","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":103,"systemId":197,"tgid":1501,"name":"Fire - Fireground 2","alphaTag":"Cranston FD FG2","tgGroup":"Cranston","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":104,"systemId":197,"tgid":1502,"name":"Fire - Fireground 3","alphaTag":"Cranston FD FG3","tgGroup":"Cranston","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":105,"systemId":197,"tgid":1503,"name":"Fire - Fireground 4","alphaTag":"Cranston FD FG4","tgGroup":"Cranston","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":106,"systemId":197,"tgid":1504,"name":"Fire - Admin/Alt Fireground 5","alphaTag":"Cranston FD Admi","tgGroup":"Cranston","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":107,"systemId":197,"tgid":1520,"name":"Fire","alphaTag":"Cumberland FD","tgGroup":"Cumberland","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":108,"systemId":197,"tgid":1523,"name":"Police Secondary","alphaTag":"Cumberland PD","tgGroup":"Cumberland","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":109,"systemId":197,"tgid":1776,"name":"Fire Talk Around","alphaTag":"E Greenwich F-TA","tgGroup":"East Greenwich","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":110,"systemId":197,"tgid":1779,"name":"Police Operations","alphaTag":"E Greenwich PD","tgGroup":"East Greenwich","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":111,"systemId":197,"tgid":1869,"name":"Police 1 - Dispatch","alphaTag":"E Prov PD 1","tgGroup":"East Providence","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":112,"systemId":197,"tgid":1872,"name":"Police 2","alphaTag":"E Prov PD 2","tgGroup":"East Providence","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":113,"systemId":197,"tgid":1870,"name":"Police 3","alphaTag":"E Prov PD 3","tgGroup":"East Providence","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":114,"systemId":197,"tgid":1883,"name":"Detectives","alphaTag":"E Prov PD12","tgGroup":"East Providence","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":115,"systemId":197,"tgid":1866,"name":"Fire - Dispatch/Operations","alphaTag":"E Prov FD 1","tgGroup":"East Providence","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":116,"systemId":197,"tgid":1867,"name":"Fire \"Channel 2\"","alphaTag":"E Prov FD 2","tgGroup":"East Providence","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":117,"systemId":197,"tgid":1878,"name":"Fire \"Channel 3\"","alphaTag":"E Prov FD 3","tgGroup":"East Providence","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":118,"systemId":197,"tgid":2064,"name":"Fire - Fireground","alphaTag":"Exeter FD-G","tgGroup":"Exeter","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":119,"systemId":197,"tgid":1904,"name":"Fire","alphaTag":"Foster Fire","tgGroup":"Foster","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":120,"systemId":197,"tgid":1939,"name":"Police","alphaTag":"Glocester PD","tgGroup":"Glocester","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":121,"systemId":197,"tgid":1940,"name":"Police Secondary","alphaTag":"Glocester PD 2","tgGroup":"Glocester","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":122,"systemId":197,"tgid":1410,"name":"Police","alphaTag":"Hopkinton PD","tgGroup":"Hopkinton","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":123,"systemId":197,"tgid":1100,"name":"Police 1 - Dispatch","alphaTag":"Jamestown PD 1","tgGroup":"Jamestown","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":124,"systemId":197,"tgid":1101,"name":"Police 2","alphaTag":"Jamestown PD 2","tgGroup":"Jamestown","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":125,"systemId":197,"tgid":1108,"name":"Fire","alphaTag":"Jamestown FD","tgGroup":"Jamestown","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":126,"systemId":197,"tgid":1120,"name":"Fireground 1","alphaTag":"Jamestown FG 1","tgGroup":"Jamestown","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":127,"systemId":197,"tgid":1121,"name":"Fireground 2","alphaTag":"Jamestown FG 2","tgGroup":"Jamestown","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":128,"systemId":197,"tgid":1114,"name":"Public Works","alphaTag":"Jamestown DPW","tgGroup":"Jamestown","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":129,"systemId":197,"tgid":1107,"name":"Town Schools","alphaTag":"Jamestown School","tgGroup":"Jamestown","tags":["Schools"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":130,"systemId":197,"tgid":1619,"name":"Police Operations","alphaTag":"Johnston PD","tgGroup":"Johnston","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":131,"systemId":197,"tgid":1616,"name":"Fire Operations","alphaTag":"Johnston FD","tgGroup":"Johnston","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":132,"systemId":197,"tgid":1617,"name":"Fireground","alphaTag":"Johnston FG","tgGroup":"Johnston","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":133,"systemId":197,"tgid":1683,"name":"Police F1","alphaTag":"Lincoln Police","tgGroup":"Lincoln","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":134,"systemId":197,"tgid":1684,"name":"Police F2","alphaTag":"Lincoln Police 2","tgGroup":"Lincoln","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":135,"systemId":197,"tgid":1680,"name":"Fire Dispatch","alphaTag":"Lincoln Fire 1","tgGroup":"Lincoln","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":136,"systemId":197,"tgid":1681,"name":"Fireground 2","alphaTag":"Lincoln Fire 2","tgGroup":"Lincoln","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":137,"systemId":197,"tgid":1691,"name":"Fireground 3","alphaTag":"Lincoln Fire 3","tgGroup":"Lincoln","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":138,"systemId":197,"tgid":1682,"name":"EMS","alphaTag":"Lincoln EMS","tgGroup":"Lincoln","tags":["EMS Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":139,"systemId":197,"tgid":1688,"name":"Emergency Management","alphaTag":"Lincoln EMA","tgGroup":"Lincoln","tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":140,"systemId":197,"tgid":1687,"name":"Townwide","alphaTag":"Lincoln Townwide","tgGroup":"Lincoln","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":141,"systemId":197,"tgid":1692,"name":"Public Works","alphaTag":"Lincoln DPW","tgGroup":"Lincoln","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":142,"systemId":197,"tgid":1264,"name":"Police","alphaTag":"LittleCompPD","tgGroup":"Little Compton","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":143,"systemId":197,"tgid":1266,"name":"Fire","alphaTag":"LittleCompFD","tgGroup":"Little Compton","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":144,"systemId":197,"tgid":1338,"name":"Police Operations","alphaTag":"MiddletownPD","tgGroup":"Middletown","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":145,"systemId":197,"tgid":1343,"name":"Fire Operations","alphaTag":"Middletown FD","tgGroup":"Middletown","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":146,"systemId":197,"tgid":1345,"name":"Townwide","alphaTag":"MiddletownTW","tgGroup":"Middletown","tags":["Multi-Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":147,"systemId":197,"tgid":1001,"name":"Police - Dispatch","alphaTag":"Narrag PD 1","tgGroup":"Narragansett","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":148,"systemId":197,"tgid":1002,"name":"Police - Car/Car","alphaTag":"Narrag PD 2","tgGroup":"Narragansett","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":149,"systemId":197,"tgid":1003,"name":"Police - Special Details 1/Town Beaches","alphaTag":"Narrag PD 3","tgGroup":"Narragansett","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":150,"systemId":197,"tgid":1004,"name":"Police - Special Details 2","alphaTag":"Narrag PD 4","tgGroup":"Narragansett","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":151,"systemId":197,"tgid":1005,"name":"Police - Harbormaster","alphaTag":"Narrag PD 5","tgGroup":"Narragansett","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":152,"systemId":197,"tgid":1007,"name":"Police - Detectives","alphaTag":"Narrag PD 7","tgGroup":"Narragansett","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":153,"systemId":197,"tgid":1008,"name":"Police - Detectives","alphaTag":"Narrag PD 8","tgGroup":"Narragansett","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":154,"systemId":197,"tgid":1006,"name":"Fire - Dispatch","alphaTag":"Narrag FD","tgGroup":"Narragansett","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":155,"systemId":197,"tgid":1012,"name":"Fire - Fireground 1","alphaTag":"Narrag FDFG1","tgGroup":"Narragansett","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":156,"systemId":197,"tgid":1013,"name":"Fire - Fireground 2","alphaTag":"Narrag FDFG2","tgGroup":"Narragansett","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":157,"systemId":197,"tgid":1016,"name":"Fire - Administration","alphaTag":"Narrag FD AD","tgGroup":"Narragansett","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":158,"systemId":197,"tgid":1014,"name":"Fire - EMS Ops","alphaTag":"Narrag EMS","tgGroup":"Narragansett","tags":["EMS Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":159,"systemId":197,"tgid":1017,"name":"Public Works","alphaTag":"Narrag DPW","tgGroup":"Narragansett","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":160,"systemId":197,"tgid":1010,"name":"Town Administration","alphaTag":"Narrag TownA","tgGroup":"Narragansett","tags":["Other"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":161,"systemId":197,"tgid":1011,"name":"Townwide Interop","alphaTag":"Narrag IOP","tgGroup":"Narragansett","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":162,"systemId":197,"tgid":1376,"name":"Police","alphaTag":"New Shore PD","tgGroup":"New Shoreham","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":163,"systemId":197,"tgid":1300,"name":"Police 1 - Dispatch","alphaTag":"Newport PD 1","tgGroup":"Newport","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":164,"systemId":197,"tgid":1302,"name":"Police 2 - Records","alphaTag":"Newport PD 2","tgGroup":"Newport","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":165,"systemId":197,"tgid":1304,"name":"Police 4 - Tactical 1","alphaTag":"Newport PD 4","tgGroup":"Newport","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":166,"systemId":197,"tgid":1307,"name":"Police 7 - Tactical 4","alphaTag":"Newport PD 7","tgGroup":"Newport","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":167,"systemId":197,"tgid":1308,"name":"Police 8 - Tactical 5","alphaTag":"Newport PD 8","tgGroup":"Newport","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":168,"systemId":197,"tgid":1303,"name":"Fire Dispatch/Operations","alphaTag":"Newport FD1","tgGroup":"Newport","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":169,"systemId":197,"tgid":1305,"name":"Fireground Ops 1","alphaTag":"Newport FG1","tgGroup":"Newport","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":170,"systemId":197,"tgid":1306,"name":"Fireground Ops 2","alphaTag":"Newport FG2","tgGroup":"Newport","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":171,"systemId":197,"tgid":1301,"name":"Fire - Training","alphaTag":"Newport FDT","tgGroup":"Newport","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":172,"systemId":197,"tgid":1291,"name":"Water Department","alphaTag":"Newport Water","tgGroup":"Newport","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":173,"systemId":197,"tgid":1293,"name":"Public Works","alphaTag":"Newport DPW","tgGroup":"Newport","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":174,"systemId":197,"tgid":1297,"name":"Citywide Events","alphaTag":"Newport Evnt","tgGroup":"Newport","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":175,"systemId":197,"tgid":1312,"name":"Newport Citywide","alphaTag":"Newport CW","tgGroup":"Newport","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":176,"systemId":197,"tgid":1285,"name":"Police 1 - Dispatch","alphaTag":"NKing PD 1","tgGroup":"North Kingstown","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":177,"systemId":197,"tgid":1286,"name":"Police 2 - Admin","alphaTag":"NKing PD 2","tgGroup":"North Kingstown","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":178,"systemId":197,"tgid":1287,"name":"Police 3 - Car/Car","alphaTag":"NKing PD 3","tgGroup":"North Kingstown","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":179,"systemId":197,"tgid":1280,"name":"Fire - Dispatch","alphaTag":"NKing Fire D","tgGroup":"North Kingstown","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":180,"systemId":197,"tgid":1281,"name":"Fire - Fireground","alphaTag":"NKing Fire G","tgGroup":"North Kingstown","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":181,"systemId":197,"tgid":1536,"name":"Police 1 - Dispatch","alphaTag":"NorthPrv PD1","tgGroup":"North Providence","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":182,"systemId":197,"tgid":1537,"name":"Police 2 - Car/Car","alphaTag":"NorthPrv PD2","tgGroup":"North Providence","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":183,"systemId":197,"tgid":1538,"name":"Police 3 - Tactical","alphaTag":"NorthPrv PD3","tgGroup":"North Providence","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":184,"systemId":197,"tgid":1547,"name":"Fire Dispatch ","alphaTag":"NorthPrv FDD","tgGroup":"North Providence","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":185,"systemId":197,"tgid":1548,"name":"Fire 2","alphaTag":"NorthPrv Fire 2","tgGroup":"North Providence","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":186,"systemId":197,"tgid":1549,"name":"Fire 3","alphaTag":"NorthPrv Fire 3","tgGroup":"North Providence","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":187,"systemId":197,"tgid":1550,"name":"Fire 4","alphaTag":"NorthPrv Fire 4","tgGroup":"North Providence","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":188,"systemId":197,"tgid":1551,"name":"Fire 5","alphaTag":"NorthPrv Fire 5","tgGroup":"North Providence","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":189,"systemId":197,"tgid":1552,"name":"Fire 6","alphaTag":"NorthPrv Fire 6","tgGroup":"North Providence","metadata":{"encrypted":true},"tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":190,"systemId":197,"tgid":1544,"name":"Townwide 1","alphaTag":"NorthPrv TownW 1","tgGroup":"North Providence","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":191,"systemId":197,"tgid":1545,"name":"Townwide 2","alphaTag":"NorthPrv TownW 2","tgGroup":"North Providence","tags":["Interop"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":192,"systemId":197,"tgid":1554,"name":"Public Works","alphaTag":"NorthPrv DPW","tgGroup":"North Providence","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":193,"systemId":197,"tgid":1971,"name":"Police","alphaTag":"N Smithfd PD","tgGroup":"North Smithfield","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":194,"systemId":197,"tgid":1968,"name":"Fire Dispatch/Operations","alphaTag":"N Smithfield FD","tgGroup":"North Smithfield","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":195,"systemId":197,"tgid":1969,"name":"Fire Secondary","alphaTag":"N Smithfield FD2","tgGroup":"North Smithfield","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":196,"systemId":197,"tgid":1981,"name":"Fireground","alphaTag":"N Smithfield FD3","tgGroup":"North Smithfield","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":197,"systemId":197,"tgid":1440,"name":"Fire - Operations","alphaTag":"Pawtucket FD 1","tgGroup":"Pawtucket","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":198,"systemId":197,"tgid":1441,"name":"Fireground","alphaTag":"Pawtucket FG","tgGroup":"Pawtucket","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":199,"systemId":197,"tgid":1442,"name":"EMS Tac","alphaTag":"Pawtucket EMSTac","tgGroup":"Pawtucket","tags":["EMS-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":200,"systemId":197,"tgid":1248,"name":"Police","alphaTag":"PortsmouthPD","tgGroup":"Portsmouth","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":201,"systemId":197,"tgid":1253,"name":"Fire Dispatch (Patch to VHF Primary)","alphaTag":"Portsmouth FD","tgGroup":"Portsmouth","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":202,"systemId":197,"tgid":1255,"name":"Fireground","alphaTag":"Portsmouth FG","tgGroup":"Portsmouth","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":203,"systemId":197,"tgid":1262,"name":"Island Fire Dispatch","alphaTag":"Prudence Isl FD","tgGroup":"Portsmouth","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":204,"systemId":197,"tgid":10000,"name":"Police - All Call - Emergency Broadcasts","alphaTag":"PPD ATG","tgGroup":"Providence (City)","tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":205,"systemId":197,"tgid":10001,"name":"Police 1 - Dispatch","alphaTag":"PPD CH 1","tgGroup":"Providence (City)","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":206,"systemId":197,"tgid":10002,"name":"Police 2","alphaTag":"PPD CH 2","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":207,"systemId":197,"tgid":10003,"name":"Police 3","alphaTag":"PPD CH 3","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":208,"systemId":197,"tgid":10004,"name":"Police 4","alphaTag":"PPD CH-4","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":209,"systemId":197,"tgid":10005,"name":"Police 5 -Detectives 1","alphaTag":"PPD DETEC 1","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":210,"systemId":197,"tgid":10006,"name":"Police 6 - Car-to-Car","alphaTag":"PPD T/A","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":211,"systemId":197,"tgid":10007,"name":"Police 7 - Narcotics 1","alphaTag":"PPD NARC 1","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":212,"systemId":197,"tgid":10008,"name":"Police 8 - Narcotics 2","alphaTag":"PPD NARC 2","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":213,"systemId":197,"tgid":10009,"name":"Police 9 - Detectives 2","alphaTag":"PPD DETEC 2","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":214,"systemId":197,"tgid":10010,"name":"Police 10 - Special Details 1","alphaTag":"PPD DETAIL 1","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":215,"systemId":197,"tgid":10011,"name":"Police 11 - Special Details 2","alphaTag":"PPD DETAIL 2","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":216,"systemId":197,"tgid":10012,"name":"Police 12 - Corrections Security","alphaTag":"PPD CORR SEC","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":217,"systemId":197,"tgid":10013,"name":"Police 13 - Special Response Unit","alphaTag":"PPD SRU","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":218,"systemId":197,"tgid":10014,"name":"Police 14 - Administration","alphaTag":"PPD ADMIN","tgGroup":"Providence (City)","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":219,"systemId":197,"tgid":10100,"name":"Fire All Call - Emergency Broadcasts","alphaTag":"PROV FD ATG","tgGroup":"Providence (City)","tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":220,"systemId":197,"tgid":10101,"name":"Fire Dispatch","alphaTag":"PFD DISPATCH","tgGroup":"Providence (City)","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":221,"systemId":197,"tgid":10107,"name":"Fireground 2","alphaTag":"PFD CH-2 FG","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":222,"systemId":197,"tgid":10108,"name":"Fireground 3","alphaTag":"PFD CH-3 FG","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":223,"systemId":197,"tgid":10109,"name":"Fireground 4","alphaTag":"PFD CH-4 FG","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":224,"systemId":197,"tgid":10102,"name":"Fire 5","alphaTag":"PFD CH-5","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":225,"systemId":197,"tgid":10103,"name":"Fire 6","alphaTag":"PFD CH-6","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":226,"systemId":197,"tgid":10104,"name":"Fire 7","alphaTag":"PFD CH-7","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":227,"systemId":197,"tgid":10110,"name":"Fire - Mutual Aid 1","alphaTag":"PFD M/A 1","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":228,"systemId":197,"tgid":10111,"name":"Fire - Mutual Aid 2","alphaTag":"PFD M/A 2","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":229,"systemId":197,"tgid":10112,"name":"Fire - Mutual Aid 3","alphaTag":"PFD M/A 3","tgGroup":"Providence (City)","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":230,"systemId":197,"tgid":10113,"name":"Fireground 8","alphaTag":"PFD Fireground 8","tgGroup":"Providence (City)","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":231,"systemId":197,"tgid":10105,"name":"Fire - Administration","alphaTag":"PFD ADMIN","tgGroup":"Providence (City)","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":232,"systemId":197,"tgid":10106,"name":"Fire - Communications","alphaTag":"PFD COMM","tgGroup":"Providence (City)","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":233,"systemId":197,"tgid":10207,"name":"Public Works","alphaTag":"PROV DPW","tgGroup":"Providence (City)","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":234,"systemId":197,"tgid":2035,"name":"Police","alphaTag":"Richmond PD","tgGroup":"Richmond","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":235,"systemId":197,"tgid":2042,"name":"Chariho Regional High School","alphaTag":"Chariho Reg HS","tgGroup":"Richmond","tags":["Schools"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":236,"systemId":197,"tgid":1460,"name":"Police","alphaTag":"Scituate PD","tgGroup":"Scituate","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":237,"systemId":197,"tgid":1463,"name":"Fire Operations","alphaTag":"Scituate FD","tgGroup":"Scituate","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":238,"systemId":197,"tgid":1651,"name":"Police Operations","alphaTag":"SmithfieldPD","tgGroup":"Smithfield","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":239,"systemId":197,"tgid":1652,"name":"Police Secondary","alphaTag":"Smfld PD 2","tgGroup":"Smithfield","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":240,"systemId":197,"tgid":1653,"name":"Police Detectives","alphaTag":"Smfld PD Det","tgGroup":"Smithfield","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":241,"systemId":197,"tgid":1654,"name":"Police Admin","alphaTag":"Smfld PD Adm","tgGroup":"Smithfield","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":242,"systemId":197,"tgid":1661,"name":"Police Details","alphaTag":"Smfld PD Dtl","tgGroup":"Smithfield","tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":243,"systemId":197,"tgid":1648,"name":"Fire - Fireground","alphaTag":"SmithfieldFD","tgGroup":"Smithfield","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":244,"systemId":197,"tgid":1655,"name":"Town-Wide","alphaTag":"Smfld Town","tgGroup":"Smithfield","tags":["Multi-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":245,"systemId":197,"tgid":1657,"name":"Emergency Management","alphaTag":"Smfld EMA","tgGroup":"Smithfield","tags":["Emergency Ops"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":246,"systemId":197,"tgid":1660,"name":"Public Works","alphaTag":"Smfld DPW","tgGroup":"Smithfield","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":247,"systemId":197,"tgid":1225,"name":"Police 1 - Dispatch","alphaTag":"SKing PD 1","tgGroup":"South Kingstown","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":248,"systemId":197,"tgid":1226,"name":"Police 2 - Car/Car","alphaTag":"SKing PD 2","tgGroup":"South Kingstown","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":249,"systemId":197,"tgid":1235,"name":"Police 3 - Tactical","alphaTag":"SKing PD 3","tgGroup":"South Kingstown","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":250,"systemId":197,"tgid":1236,"name":"Police 5 - Tactical","alphaTag":"SKing PD 5","tgGroup":"South Kingstown","metadata":{"encrypted":true},"tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":251,"systemId":197,"tgid":1232,"name":"Fire - UHF Simulcast","alphaTag":"SKing FD Lnk","tgGroup":"South Kingstown","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":252,"systemId":197,"tgid":1240,"name":"Fire - Detail","alphaTag":"SKing Fire D","tgGroup":"South Kingstown","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":253,"systemId":197,"tgid":1227,"name":"Union Fire District - Fireground 1","alphaTag":"UnionFD FG 1","tgGroup":"South Kingstown","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":254,"systemId":197,"tgid":1237,"name":"Union Fire District - Fireground 2","alphaTag":"UnionFD FG 2","tgGroup":"South Kingstown","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":255,"systemId":197,"tgid":1026,"name":"Union Fire District - Special Events","alphaTag":"UnionFD Evnt","tgGroup":"South Kingstown","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":256,"systemId":197,"tgid":1015,"name":"EMS","alphaTag":"SKing EMS","tgGroup":"South Kingstown","metadata":{"encrypted":true},"tags":["EMS Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":257,"systemId":197,"tgid":1316,"name":"Police (Simulcast 482.9625)","alphaTag":"Tiverton PD","tgGroup":"Tiverton","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":258,"systemId":197,"tgid":1315,"name":"Fire (Simulcast 471.7875)","alphaTag":"Tiverton FD","tgGroup":"Tiverton","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":259,"systemId":197,"tgid":1162,"name":"Fire","alphaTag":"Warwick FD","tgGroup":"Warwick","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":260,"systemId":197,"tgid":1170,"name":"Fireground","alphaTag":"Warwick FG","tgGroup":"Warwick","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":261,"systemId":197,"tgid":1805,"name":"Police","alphaTag":"W Greenwh PD","tgGroup":"West Greenwich","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":262,"systemId":197,"tgid":1806,"name":"Police Secondary","alphaTag":"W GreenwichPD2","tgGroup":"West Greenwich","tags":["Law Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":263,"systemId":197,"tgid":1208,"name":"Fire Operations","alphaTag":"W Warwick FD","tgGroup":"West Warwick","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":264,"systemId":197,"tgid":1050,"name":"Police 1 - Dispatch","alphaTag":"Westerly PD1","tgGroup":"Westerly","metadata":{"encrypted":true},"tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":265,"systemId":197,"tgid":1051,"name":"Police 2","alphaTag":"Westerly PD2","tgGroup":"Westerly","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":266,"systemId":197,"tgid":1052,"name":"Police 3","alphaTag":"Westerly PD3","tgGroup":"Westerly","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":267,"systemId":197,"tgid":1053,"name":"Police 4","alphaTag":"Westerly PD4","tgGroup":"Westerly","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":268,"systemId":197,"tgid":1054,"name":"Police 5 - Reserve Officers","alphaTag":"Westerly PD5","tgGroup":"Westerly","tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":269,"systemId":197,"tgid":1064,"name":"Police 6 - Traffic Division","alphaTag":"Westerly PD6","tgGroup":"Westerly","tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":270,"systemId":197,"tgid":1063,"name":"Fire Operations","alphaTag":"Westerly FD","tgGroup":"Westerly","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":271,"systemId":197,"tgid":1072,"name":"Police/Fire/EMS Ops","alphaTag":"Westerly PFE","tgGroup":"Westerly","tags":["Multi-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":272,"systemId":197,"tgid":1082,"name":"EMS Operations","alphaTag":"Westerly EMS ","tgGroup":"Westerly","tags":["EMS Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":273,"systemId":197,"tgid":1363,"name":"Police 1 - Dispatch","alphaTag":"Woonskt PD 1","tgGroup":"Woonsocket","tags":["Law Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":274,"systemId":197,"tgid":1364,"name":"Police 2","alphaTag":"Woonskt PD 2","tgGroup":"Woonsocket","metadata":{"encrypted":true},"tags":["Law Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":275,"systemId":197,"tgid":1360,"name":"Fire Dispatch - Operations","alphaTag":"Woonsocket FD D","tgGroup":"Woonsocket","tags":["Fire-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":276,"systemId":197,"tgid":1361,"name":"Fire Secondary","alphaTag":"Woonsocket FD 2","tgGroup":"Woonsocket","tags":["Fire Dispatch"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":277,"systemId":197,"tgid":1354,"name":"Fire - Fireground 3","alphaTag":"Woonskt FD 3","tgGroup":"Woonsocket","tags":["Fire-Tac"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":278,"systemId":197,"tgid":1367,"name":"Citywide","alphaTag":"Woonskt City","tgGroup":"Woonsocket","tags":["Multi-Talk"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":279,"systemId":197,"tgid":1368,"name":"Public Works - Streets","alphaTag":"Woonsocket PW","tgGroup":"Woonsocket","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":280,"systemId":197,"tgid":1,"name":"RISCON Radio Technicians","alphaTag":"Radio Techs","tgGroup":"Radio Technicians","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false},{"id":281,"systemId":197,"tgid":10125,"name":"RISCON Radio Technicians","alphaTag":"Radio Techs","tgGroup":"Radio Technicians","tags":["Public Works"],"alert":true,"weight":1,"system":{"id":197,"name":"RISCON"},"learned":false}] \ No newline at end of file diff --git a/pkg/users/store.go b/pkg/users/store.go index 0129181..5722ab2 100644 --- a/pkg/users/store.go +++ b/pkg/users/store.go @@ -2,11 +2,16 @@ package users import ( "context" + "errors" "dynatron.me/x/stillbox/internal/cache" "dynatron.me/x/stillbox/pkg/database" ) +var ( + ErrNoSuchUser = errors.New("no such user") +) + type Store interface { // GetUser gets a user by UID. GetUser(ctx context.Context, username string) (*User, error) @@ -84,6 +89,10 @@ func (s *postgresStore) GetUser(ctx context.Context, username string) (*User, er dbu, err := s.db.GetUserByUsername(ctx, username) if err != nil { + if database.IsNoRows(err) { + return nil, ErrNoSuchUser + } + return nil, err } diff --git a/pkg/users/user.go b/pkg/users/user.go index 9860be1..b5d5d27 100644 --- a/pkg/users/user.go +++ b/pkg/users/user.go @@ -71,6 +71,10 @@ func (u *User) GetName() string { return u.Username } +func (u *User) String() string { + return "USER:"+u.GetName() +} + func (u *User) GetRoles() []string { r := make([]string, 1, 2) diff --git a/sql/postgres/migrations/001_initial.up.sql b/sql/postgres/migrations/001_initial.up.sql index 2a3995d..ea77bf7 100644 --- a/sql/postgres/migrations/001_initial.up.sql +++ b/sql/postgres/migrations/001_initial.up.sql @@ -143,6 +143,7 @@ CREATE TABLE IF NOT EXISTS incidents( name TEXT NOT NULL, owner INTEGER NOT NULL, description TEXT, + created_at TIMESTAMPTZ, start_time TIMESTAMPTZ, end_time TIMESTAMPTZ, location JSONB, diff --git a/sql/postgres/queries/calls.sql b/sql/postgres/queries/calls.sql index a35ded6..8496475 100644 --- a/sql/postgres/queries/calls.sql +++ b/sql/postgres/queries/calls.sql @@ -180,6 +180,7 @@ SELECT tg_label, tg_alpha_tag, tg_group, - source + source, + transcript FROM calls WHERE id = @id; diff --git a/sql/postgres/queries/incidents.sql b/sql/postgres/queries/incidents.sql index 0bc1e89..9dca9f6 100644 --- a/sql/postgres/queries/incidents.sql +++ b/sql/postgres/queries/incidents.sql @@ -42,6 +42,7 @@ INSERT INTO incidents ( name, owner, description, + created_at, start_time, end_time, location, @@ -51,6 +52,7 @@ INSERT INTO incidents ( @name, @owner, sqlc.narg('description'), + NOW(), sqlc.narg('start_time'), sqlc.narg('end_time'), sqlc.narg('location'), @@ -65,6 +67,7 @@ SELECT i.name, i.owner, i.description, + i.created_at, i.start_time, i.end_time, i.location, @@ -160,6 +163,7 @@ SELECT i.name, i.owner, i.description, + i.created_at, i.start_time, i.end_time, i.location, diff --git a/sql/postgres/queries/share.sql b/sql/postgres/queries/share.sql index 7854c48..08e9bce 100644 --- a/sql/postgres/queries/share.sql +++ b/sql/postgres/queries/share.sql @@ -1,13 +1,13 @@ -- name: GetShare :one SELECT - id, - entity_type, - entity_id, - entity_date, - owner, - expiration -FROM shares -WHERE id = @id; + s.id, + s.entity_type, + s.entity_id, + s.entity_date, + s.owner, + s.expiration +FROM shares s +WHERE s.id = @id; -- name: CreateShare :exec INSERT INTO shares ( @@ -24,3 +24,27 @@ DELETE FROM shares WHERE id = @id; -- name: PruneShares :exec DELETE FROM shares WHERE expiration < NOW(); + +-- name: GetSharesP :many +SELECT + sqlc.embed(s), + u.username +FROM shares s +JOIN users u ON (s.owner = u.id) +WHERE +CASE WHEN sqlc.narg('owner')::INTEGER IS NOT NULL THEN + s.owner = @owner ELSE TRUE END +ORDER BY +CASE WHEN @direction::TEXT = 'asc' THEN s.entity_date END ASC, +CASE WHEN @direction::TEXT = 'desc' THEN s.entity_date END DESC +OFFSET sqlc.arg('offset') ROWS +FETCH NEXT sqlc.arg('per_page') ROWS ONLY +; + +-- name: GetSharesPCount :one +SELECT COUNT(*) +FROM shares s +WHERE +CASE WHEN sqlc.narg('owner')::INTEGER IS NOT NULL THEN + s.owner = @owner ELSE TRUE END +; diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 54898f7..cfa8b86 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -11,6 +11,7 @@ sql: query_parameter_limit: 3 emit_json_tags: true emit_interface: true + json_tags_case_style: camel initialisms: - id - tgid