diff --git a/backend/app.ts b/backend/app.ts index 7f357d5..0cfc5bd 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -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)); diff --git a/backend/endpoints/notification.ts b/backend/endpoints/notification.ts new file mode 100644 index 0000000..c07b358 --- /dev/null +++ b/backend/endpoints/notification.ts @@ -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" }); + } + } +} \ No newline at end of file diff --git a/backend/lib/rabbit.ts b/backend/lib/rabbit.ts index aae137c..396212c 100644 --- a/backend/lib/rabbit.ts +++ b/backend/lib/rabbit.ts @@ -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 = new Map(); + timeouts: Map = new Map(); 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))); } } \ No newline at end of file diff --git a/backend/models/beat/beat.schema.ts b/backend/models/beat/beat.schema.ts index c14f54d..4466238 100644 --- a/backend/models/beat/beat.schema.ts +++ b/backend/models/beat/beat.schema.ts @@ -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: { diff --git a/backend/models/notifications/notification.interface.ts b/backend/models/notifications/notification.interface.ts new file mode 100644 index 0000000..e9f4b7d --- /dev/null +++ b/backend/models/notifications/notification.interface.ts @@ -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; +} \ No newline at end of file diff --git a/backend/models/notifications/notification.model.ts b/backend/models/notifications/notification.model.ts new file mode 100644 index 0000000..6ad8fe8 --- /dev/null +++ b/backend/models/notifications/notification.model.ts @@ -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 = model('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 }); +} \ No newline at end of file diff --git a/backend/models/notifications/notification.schema.ts b/backend/models/notifications/notification.schema.ts new file mode 100644 index 0000000..e4399a0 --- /dev/null +++ b/backend/models/notifications/notification.schema.ts @@ -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 }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index a37b9fb..c50d62c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 9ea47ac..1be9960 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/tsconfig.json b/backend/tsconfig.json index f4180c2..5f9a2eb 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "target": "ES5", + "target": "ES2020", + "lib": ["ES2020"], "module": "commonjs", "outDir": "./dist", "rootDir": "./", diff --git a/frontend/src/app/_alert/alert.service.ts b/frontend/src/app/_alert/alert.service.ts index b97656e..606046a 100644 --- a/frontend/src/app/_alert/alert.service.ts +++ b/frontend/src/app/_alert/alert.service.ts @@ -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 diff --git a/frontend/src/app/_alert/alert/alert.component.ts b/frontend/src/app/_alert/alert/alert.component.ts index b97757a..5864a6a 100644 --- a/frontend/src/app/_alert/alert/alert.component.ts +++ b/frontend/src/app/_alert/alert/alert.component.ts @@ -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]); diff --git a/frontend/src/app/api.service.ts b/frontend/src/app/api.service.ts index ba25742..4e1f0d3 100644 --- a/frontend/src/app/api.service.ts +++ b/frontend/src/app/api.service.ts @@ -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 = 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 { + async getBeats(time?: ITimespan): Promise { return new Promise((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 { + return new Promise((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; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 843accc..3df526a 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -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 diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index f5377f9..dbda4e4 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -5,6 +5,9 @@
  • Dashboard
  • Map
  • +