Notification system enhanced

- Base for notifcation center
- Show text in map if no data is available
This commit is contained in:
2020-11-21 14:11:26 +01:00
parent 8b54431449
commit 533749c7c8
26 changed files with 323 additions and 76 deletions

View File

@@ -10,6 +10,7 @@ import * as winston from 'winston';
import { config } from './config';
import { GetBeat, GetBeatStats } from './endpoints/beat';
import { getNotification } from './endpoints/notification';
import { GetPhone, PostPhone } from './endpoints/phone';
import { DeleteUser, GetUser, LoginRabbitUser, LoginUser, MW_User, PatchUser, PostUser, Resource, Topic, VHost } from './endpoints/user';
import { hashPassword, randomPepper, randomString } from './lib/crypto';
@@ -145,17 +146,20 @@ async function run() {
app.get('/user/resource', (req, res) => Resource(req, res));
app.get('/user/topic', (req, res) => Topic(req, res));
// Basic user actions
// CRUD user
app.get('/user/notification', MW_User, (req, res) => getNotification(req, res)); // Notifications
app.get('/user/', MW_User, (req, res) => GetUser(req, res));
app.post('/user/', MW_User, (req, res) => PostUser(req, res));
app.get('/user/:id', MW_User, (req, res) => GetUser(req, res));
app.patch('/user/:id', MW_User, (req, res) => PatchUser(req, res));
app.delete('/user/:id', MW_User, (req, res) => DeleteUser(req, res));
// Phones
app.get('/phone/:id', MW_User, (req, res) => GetPhone(req, res));
app.get('/phone', MW_User, (req, res) => GetPhone(req, res));
app.post('/phone', MW_User, (req, res) => PostPhone(req, res));
// Beats
app.get('/beat/', MW_User, (req, res) => GetBeat(req, res));
app.get('/beat/stats', MW_User, (req, res) => GetBeatStats(req, res));

View File

@@ -0,0 +1,22 @@
import { Response } from "express";
import { LivebeatRequest } from "../lib/request";
import { Notification } from "../models/notifications/notification.model";
export async function getNotification(req: LivebeatRequest, res: Response) {
let limit = req.query.limit;
let skip = req.query.skip;
if (limit === undefined) { limit = '100' };
if (skip === undefined) { skip = '0' };
try {
const notifications = await Notification.find({ user: req.user?._id }).limit(Number(limit)).sort({ _id: -1 });
res.status(200).send(notifications);
} catch(error: any) {
if (error instanceof TypeError) {
res.status(400).send({ message: "'Limit' has to be a number" });
} else {
res.status(500).send({ message: "An error occured while processing your request" });
}
}
}

View File

@@ -1,7 +1,12 @@
import * as amqp from 'amqplib';
import { Schema, SchemaType } from 'mongoose';
import { logger, RABBITMQ_URI } from '../app';
import { Beat } from '../models/beat/beat.model.';
import { ISeverity, NotificationType } from '../models/notifications/notification.interface';
import { addNotification, Notification } from '../models/notifications/notification.model';
import { IPhone } from '../models/phone/phone.interface';
import { Phone } from '../models/phone/phone.model';
import { User } from '../models/user/user.model';
interface IBeat {
token: string,
@@ -14,7 +19,7 @@ export class RabbitMQ {
connection: amqp.Connection | null = null;
channel: amqp.Channel | null = null;
timeouts: Map<string, Timeout> = new Map<string, Timeout>();
timeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
async init() {
this.connection = await amqp.connect(RABBITMQ_URI);
@@ -46,16 +51,15 @@ export class RabbitMQ {
// Broadcast if device became active
if (this.timeouts.has(phone.id)) {
clearTimeout(this.timeouts.get(phone.id));
clearTimeout(this.timeouts.get(phone.id)!!);
} else {
logger.debug('Set phone active');
phone.active = true;
await phone.save();
this.publish(phone.user.toString(), phone.toJSON(), 'phone_alive');
this.publish(phone.user.toString(), phone.toJSON(), 'phone_alive', ISeverity.SUCCESS);
}
const timeoutTimer = setTimeout(async () => {
this.publish(phone.user.toString(), phone.toJSON(), 'phone_dead');
this.publish(phone.user.toString(), phone.toJSON(), 'phone_dead', ISeverity.WARN);
this.timeouts.delete(phone.id);
phone.active = false;
await phone.save();
@@ -66,10 +70,22 @@ export class RabbitMQ {
}, { noAck: true });
}
async publish(userId: string, data: any, type: 'beat' | 'phone_alive' | 'phone_dead' | 'phone_register' | 'panic' = 'beat') {
if (this.connection == undefined) await this.init()
async publish(userId: string, data: any, type: NotificationType, severity = ISeverity.INFO) {
if (this.connection == undefined) await this.init();
data = { type, ...data };
const user = await User.findById(userId);
if (user === null) return;
/* Manage notifications */
if (type != 'beat') {
if (type == 'phone_alive' || type == 'phone_dead') {
addNotification(type, severity, ((data as IPhone)._id), user);
}
}
data = { type, severity, ...data };
console.log('Send:', data);
this.channel?.publish('amq.topic', userId, Buffer.from(JSON.stringify(data)));
}
}

View File

@@ -6,7 +6,7 @@ const schemaBeat = new Schema({
accuracy: { type: Number, required: false },
speed: { type: Number, required: false },
battery: { type: Number, required: false },
phone: { type: SchemaTypes.ObjectId, required: true, default: 'user' },
phone: { type: SchemaTypes.ObjectId, required: true },
createdAt: { type: SchemaTypes.Date, required: false }
}, {
timestamps: {

View File

@@ -0,0 +1,18 @@
import { Document } from "mongoose";
import { IUser } from "../user/user.interface";
export enum ISeverity {
INFO = 0,
SUCCESS = 1,
WARN = 2,
ERROR = 3
}
export type NotificationType = 'beat' | 'phone_alive' | 'phone_dead' | 'phone_register' | 'panic';
export interface INotification extends Document {
type: NotificationType;
severity: ISeverity;
message: any;
user: IUser;
}

View File

@@ -0,0 +1,11 @@
import { Model, model } from 'mongoose';
import { IUser } from '../user/user.interface';
import { INotification, ISeverity, NotificationType } from './notification.interface';
import { schemaNotification } from './notification.schema';
const modelNotification: Model<INotification> = model<INotification>('Notification', schemaNotification, 'Notification');
export { modelNotification as Notification };
export function addNotification(type: NotificationType, severity: ISeverity, message: any, user: IUser) {
return modelNotification.create({ type, severity, message, user });
}

View File

@@ -0,0 +1,15 @@
import { Schema, SchemaTypes } from 'mongoose';
const schemaNotification = new Schema({
type: { type: String, required: true },
severity: { type: Number, required: true },
message: { type: Object, required: true },
user: { type: SchemaTypes.ObjectId }
}, {
timestamps: {
createdAt: true,
updatedAt: false
},
versionKey: false
});
export { schemaNotification };

View File

@@ -188,10 +188,9 @@
}
},
"@types/node": {
"version": "14.11.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.10.tgz",
"integrity": "sha512-yV1nWZPlMFpoXyoknm4S56y2nlTAuFYaJuQtYRAOU7xA/FJ9RY0Xm7QOkaYMMmr8ESdHIuUb6oQgR/0+2NqlyA==",
"dev": true
"version": "14.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.9.tgz",
"integrity": "sha512-JsoLXFppG62tWTklIoO4knA+oDTYsmqWxHRvd4lpmfQRNhX6osheUOWETP2jMoV/2bEHuMra8Pp3Dmo/stBFcw=="
},
"@types/qs": {
"version": "6.9.5",

View File

@@ -17,6 +17,7 @@
"author": "Mondei1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@types/node": "^14.14.9",
"amqplib": "^0.6.0",
"argon2": "^0.27.0",
"body-parser": "^1.19.0",

View File

@@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ES5",
"target": "ES2020",
"lib": ["ES2020"],
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./",

View File

@@ -16,10 +16,10 @@ export class Alert {
}
export enum AlertType {
Success,
Error,
Info,
Warning
INFO = 0,
SUCCESS = 1,
WARN = 2,
ERROR = 3
}
@Injectable({ providedIn: 'root' })
@@ -36,23 +36,30 @@ export class AlertService {
// convenience methods
success(message: string, title = 'Success', options?: any): void {
this.alert(new Alert({ ...options, type: AlertType.Success, message, title }));
this.alert(new Alert({ ...options, type: AlertType.SUCCESS, message, title }));
}
error(message: string, title = 'Error!', options?: any): void {
const audio = new Audio(this.errorSoundPath);
audio.load();
audio.play();
audio.play().catch(error => {});
this.alert(new Alert({ ...options, type: AlertType.Error, message, title }));
this.alert(new Alert({ ...options, type: AlertType.ERROR, message, title }));
}
info(message: string, title = 'Info', options?: any): void {
this.alert(new Alert({ ...options, type: AlertType.Info, message, title }));
this.alert(new Alert({ ...options, type: AlertType.INFO, message, title }));
}
warn(message: string, title = 'Warning!', options?: any): void {
this.alert(new Alert({ ...options, type: AlertType.Warning, message, title }));
this.alert(new Alert({ ...options, type: AlertType.WARN, message, title }));
}
/**
* Use this to use a variable as alert type.
*/
dynamic(message: string, type: AlertType, title = 'Dynamic', options?: any) {
this.alert(new Alert({ ...options, type, message, title }));
}
// main alert method

View File

@@ -62,10 +62,10 @@ export class AlertComponent implements OnInit, OnDestroy {
const classes = ['alert', 'alert-dismissable'];
const alertTypeClass = {
[AlertType.Success]: 'alert alert-success',
[AlertType.Error]: 'alert alert-danger',
[AlertType.Info]: 'alert alert-info',
[AlertType.Warning]: 'alert alert-warning'
[AlertType.SUCCESS]: 'alert alert-success',
[AlertType.ERROR]: 'alert alert-danger',
[AlertType.INFO]: 'alert alert-info',
[AlertType.WARN]: 'alert alert-warning'
};
classes.push(alertTypeClass[alert.type]);

View File

@@ -6,6 +6,9 @@ import * as moment from 'moment';
import { error } from 'protractor';
import { AlertService } from './_alert/alert.service';
/*
* DEFINITION OF TYPE
*/
export interface ILogin {
token: string;
}
@@ -65,6 +68,26 @@ export interface ITimespan {
to?: number;
}
export enum ISeverity {
INFO = 0,
SUCCESS = 1,
WARN = 2,
ERROR = 3
}
export type NotificationType = 'beat' | 'phone_alive' | 'phone_dead' | 'phone_register' | 'panic';
export interface INotification extends Document {
type: NotificationType;
severity: ISeverity;
message: any;
user: IUser;
}
/*
* END OF THE DEFINITION OF TYPE
*/
@Injectable({
providedIn: 'root'
})
@@ -74,10 +97,6 @@ export class APIService {
username: string;
rabbitmq: any;
time: ITimespan | undefined = {
from: moment().subtract(1, 'day').unix(),
to: moment().unix()
};
// Passthough data (not useful for api but a way for components to share data)
showFilter = true;
@@ -99,6 +118,7 @@ export class APIService {
createdAt: new Date(),
twoFASecret: ''
};
notifications: INotification[];
// Events when new data got fetched
beatsEvent: BehaviorSubject<IBeat[]> = new BehaviorSubject([]);
@@ -121,30 +141,26 @@ export class APIService {
password: this.user.brokerToken
});
this.mqtt.observe(this.user._id).subscribe(message => {
this.mqtt.observe(this.user._id).subscribe(async message => {
if (message !== undefined || message !== null) {
const obj = JSON.parse(message.payload.toString());
console.log('Received message:', obj);
switch (obj.type) {
case 'beat':
if (this.beats !== undefined) {
this.beats.push(obj);
this.beatsEvent.next([obj]); // We just push one, so the map doesn't has to rebuild everything from scratch.
this.beatStats.totalBeats++;
}
break;
case 'phone_available':
this.alert.info(`Device ${obj.name} is now online`, 'Device');
break;
case 'phone_register':
this.alert.success(`New device "${obj.displayName}"`, 'New device');
break;
case 'phone_alive':
this.alert.info('Device is now active', obj.displayName.toString());
break;
case 'phone_dead':
this.alert.warn('Device is now offline', obj.displayName.toString());
break;
if (obj.type === 'beat') {
if (this.beats !== undefined) {
this.beats.push(obj);
this.beatsEvent.next([obj]); // We just push one, so the map doesn't has to rebuild everything from scratch.
this.beatStats.totalBeats++;
}
} else if (obj.type === 'phone_available') {
this.alert.dynamic(`Device ${obj.displayName} is now online`, obj.severity, 'Device');
} else if (obj.type === 'phone_register') {
await this.getPhones();
this.alert.dynamic(`New device "${obj.displayName}"`, obj.severity, 'New device');
} else if (obj.type === 'phone_alive') {
this.alert.dynamic('Device is now active', obj.severity, obj.displayName);
} else if (obj.type === 'phone_dead') {
this.alert.dynamic('Device is now offline', obj.severity, obj.displayName);
}
}
});
@@ -163,6 +179,7 @@ export class APIService {
this.username = username;
await this.getPhones();
await this.getUserInfo();
await this.getNotifications();
this.mqttInit();
@@ -208,20 +225,25 @@ export class APIService {
/*
BEATS
*/
async getBeats(): Promise<IBeat[]> {
async getBeats(time?: ITimespan): Promise<IBeat[]> {
return new Promise<IBeat[]>((resolve, reject) => {
if (this.token === undefined) { reject([]); }
this.fetchingDataEvent.next(true);
// If time is not specified, default to 'today'
if (time === undefined) {
time = { from: moment().startOf('day').unix(), to: moment().unix() };
}
const headers = new HttpHeaders({ token: this.token });
let params = new HttpParams();
if (this.time !== undefined) {
params = params.set('from', this.time.from.toString());
if (time !== undefined) {
params = params.set('from', time.from.toString());
if (this.time.to !== 0) {
params = params.set('to', this.time.to.toString());
if (time.to !== 0) {
params = params.set('to', time.to.toString());
}
}
@@ -314,6 +336,10 @@ export class APIService {
});
}
/**
* Fetch information about one phone from API and cache them locally.
* @param phoneId Object id of target phone
*/
async getPhone(phoneId: string): Promise<{ IPhone, IBeat }> {
return new Promise<{ IPhone, IBeat }>((resolve, reject) => {
if (this.token === undefined) { reject([]); }
@@ -330,6 +356,35 @@ export class APIService {
});
}
/**
* This function searchs for the target phone in local array.
*
* **Notice:** If you want to fetch data again from the server consider using `getPhone(...)`.
* @param phoneId Object id of target phone
*/
getPhoneFromCache(phoneId: string): IPhone | undefined {
return this.phones.find((phone, i, array) => {
if (phone._id === phoneId) { return true; }
});
}
getNotifications(): Promise<INotification[]> {
return new Promise<INotification[]>((resolve, reject) => {
if (!this.hasSession()) { resolve([]); }
const headers = new HttpHeaders({ token: this.token });
this.fetchingDataEvent.next(true);
this.httpClient.get(this.API_ENDPOINT + '/user/notification', { responseType: 'json', headers })
.subscribe((notifications: INotification[]) => {
this.notifications = notifications;
this.fetchingDataEvent.next(false);
resolve(notifications);
});
});
}
/* HELPER CLASSES */
degreesToRadians(degrees: number): number {
return degrees * Math.PI / 180;

View File

@@ -5,6 +5,7 @@ import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { LoginComponent } from './login/login.component';
import { MapComponent } from './map/map.component';
import { NotificationsComponent } from './notifications/notifications.component';
import { UserComponent } from './user/user.component';
const routes: Routes = [
@@ -24,6 +25,10 @@ const routes: Routes = [
path: 'map',
component: MapComponent
},
{
path: 'notifications',
component: NotificationsComponent
},
{
path: 'user/:id',
component: UserComponent

View File

@@ -5,6 +5,9 @@
<li><a [routerLink]="['/dashboard']" routerLinkActive="router-link-active">Dashboard</a></li>
<li><a [routerLink]="['/map']" routerLinkActive="router-link-active">Map</a></li>
<li class="navbar-right"><a [routerLink]="['/notifications']">
<img src="assets/message.svg">
</a></li>
<li class="navbar-right"><a [routerLink]="['/user', this.api.user._id]"
routerLinkActive="router-link-active">
<fa-icon [icon]="faUser"></fa-icon>

View File

@@ -20,6 +20,7 @@ import { environment } from '../environments/environment';
import { AdminComponent } from './admin/admin.component';
import { AlertComponent } from './_alert/alert/alert.component';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NotificationsComponent } from './notifications/notifications.component';
@NgModule({
declarations: [
@@ -31,7 +32,8 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
UserComponent,
DashboardWidgetComponent,
AdminComponent,
AlertComponent
AlertComponent,
NotificationsComponent
],
imports: [
BrowserModule,

View File

@@ -12,40 +12,47 @@ export class FilterComponent implements OnInit {
presetHours = -1;
customRange: any;
customUnit: moment.unitOfTime.DurationConstructor;
timeRange: ITimespan; // Differs to customRange since this is the actual object used for query.
constructor(public api: APIService) { }
ngOnInit(): void {
}
update(value: number): void {
let result: ITimespan | undefined = { to: 0, from: 0 };
/**
* @param value Time in hours.
* Except: `-1`: Today,
* `-2`: Custom time range,
* `0`: All time
*/
update(): void {
let time: ITimespan = { from: 0, to: 0 };
console.log(this.customRange, this.customUnit, this.presetHours);
if (this.presetHours == -2) {
if (this.customRange !== undefined && this.customUnit !== undefined) {
result.from = moment().subtract(this.customRange, this.customUnit).unix();
console.log(result.from);
time.from = moment().subtract(this.customRange, this.customUnit).unix();
}
} else if (this.presetHours == -1) {
result.from = moment().startOf('day').unix();
time.from = moment().startOf('day').unix();
} else if (this.presetHours == 0) {
result = undefined;
// Do nothing since we want `from` to stay zero.
} else {
result.from = moment().subtract(this.presetHours, 'hours').unix();
time.from = moment().subtract(this.presetHours, 'hours').unix();
}
console.log(result);
this.api.time = result;
this.refresh();
time.to = moment().unix();
this.timeRange = time;
this.refresh(time);
}
updateAccuracy(val: any): void {
this.api.maxAccuracy.next(Number(val.target.value));
}
async refresh(): Promise<void> {
await this.api.getBeats();
async refresh(time: ITimespan): Promise<void> {
await this.api.getBeats(time);
await this.api.getBeatStats();
await this.api.getPhones();
await this.api.getUserInfo();

View File

@@ -25,7 +25,7 @@ addEventListener('message', ({ data }) => {
progress++;
// Report progress every fifth loop
if (Math.trunc(progress / data.length * 100) % 5 === 0) {
if (Math.trunc(progress / data.length * 100) % 3 === 0) {
postMessage({ progress: (progress / data.length) * 100 });
}
@@ -49,6 +49,5 @@ addEventListener('message', ({ data }) => {
}
});
console.log(`worker response to`, mostVisit);
postMessage(mostVisit);
});

View File

@@ -36,4 +36,8 @@
position="top-right"
></mgl-control>
</div>
</mgl-map>
</mgl-map>
<div id="noData" *ngIf="!this.showMap">
<h1>There is no data</h1><br>
<p>Selected time span does not contain any coordiantes.</p>
</div>

View File

@@ -5,4 +5,13 @@ mgl-map {
left: 0;
height: 100vh;
width: 100vw;
}
#noData {
position: fixed;
top: 35vh;
text-align: center;
height: 100vh;
width: 100%;
z-index: -1;
}

View File

@@ -161,8 +161,12 @@ export class MapComponent {
buildMap(isUpdate: boolean, maxAccuracy: number = 30): void {
// If this is an update don't rebuild entire map.
if (!isUpdate) {
if (this.api.beats.length === 0) {
console.warn('Build map method was called while there are no beats!');
if (this.api.beats !== undefined) {
if (this.api.beats.length === 0) {
console.warn('Build map method was called while there are no beats!');
return;
}
} else {
return;
}

View File

@@ -0,0 +1,3 @@
<div id="notifications">
<h1>Notifications</h1>
</div>

View File

@@ -0,0 +1,4 @@
#notifications {
margin-left: 3rem;
margin-right: 3rem;
}

View File

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

View File

@@ -0,0 +1,25 @@
import { AfterContentInit, Component, OnDestroy, OnInit } from '@angular/core';
import { APIService } from '../api.service';
@Component({
selector: 'app-notifications',
templateUrl: './notifications.component.html',
styleUrls: ['./notifications.component.scss']
})
export class NotificationsComponent implements OnInit, AfterContentInit, OnDestroy {
constructor(private api: APIService) { }
ngOnInit(): void {
}
ngAfterContentInit(): void {
this.api.showFilter = false;
console.log(this.api.showFilter);
}
ngOnDestroy(): void {
this.api.showFilter = true;
}
}

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-message" width="19" height="19" viewBox="0 0 24 24" stroke-width="2" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 21v-13a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-9l-4 4" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="13" x2="14" y2="13" />
</svg>

After

Width:  |  Height:  |  Size: 446 B