Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ jobs:
env:
NODE_ENV: ${{ secrets.NODE_ENV }}
SECRET_ADMIN_KEY: ${{ secrets.SECRET_ADMIN_KEY }}
SECRET_EMAIL_RECEIVER: ${{ secrets.SECRET_EMAIL_RECEIVER }}
SECRET_EMAIL_SENDER: ${{ secrets.SECRET_EMAIL_SENDER }}
SECRET_EMAIL_PASS: ${{ secrets.SECRET_EMAIL_PASS }}
SECRET_NOTIFY_ADMIN_ID: ${{ secrets.SECRET_NOTIFY_ADMIN_ID }}
SECRET_NOTIFY_BOT_KEY: ${{ secrets.SECRET_NOTIFY_BOT_KEY }}
SECRET_BETTERSTACK_LOGGING_KEY: ${{ secrets.SECRET_BETTERSTACK_LOGGING_KEY }}
SECRET_BETTERSTACK_HOST: ${{ secrets.SECRET_BETTERSTACK_HOST }}
SECRET_DB_USER: ${{ secrets.SECRET_DB_USER }}
Expand Down
55 changes: 38 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# yqni13 | $\texttt{\color{cornflowerblue}{SUPPORT}}$
### $\textsf{\color{brown}{v1.4.9}}$
### $\textsf{\color{brown}{v1.6.0}}$

#### Support hub - handling feedback/ratings (`/feedback`) and bug/support requests (`/tickets`) including file attachments across multiple applications via REST API. Built with NodeJS (Typescript), Express & PostgreSQL in Docker container using API-Key authentication and rate-limiting. Created following Test-Driven Development (450+ tests) and hosting env:prod via Render, Neon and Cloudflare.

Expand All @@ -16,6 +16,7 @@
<a href="https://www.cloudflare.com/de-de/application-services/products/cdn/"><img src="assets/icons/cloudflare.png" alt="Cloudflare"></a>
<a href="https://betterstack.com/"><img src="assets/icons/betterstack.png" alt="Betterstack"></a>
<a href="https://testcontainers.com/"><img src="assets/icons/testcontainers.png" alt="Testcontainers"></a>
<a href="https://core.telegram.org/bots/tutorial"><img src="assets/icons/telegram.png" alt="Testcontainers"></a>
</div>

<br><br>
Expand Down Expand Up @@ -61,6 +62,7 @@ Alternatively run application in Docker container [(see docs)](./docs/DEVOPS.md)
| 🔐 Maintenance Mode | Enable/disable application triggered by request or internal logic |
| 🕵️ Rate limiting | Request throttling with violation handling |
| 🔑 API Key Auth | Client authentication via API keys |
| 📬 Notifications | Admin/Developer notifications on certain events via Telegram bot |

<br>

Expand Down Expand Up @@ -96,20 +98,45 @@ In terms of rate-limiting, penalties and ready-to-extend functionality, the obse

A certain set of rules checks for incoming requests on a total number for the day and within a certain time range. Before the engine returns found violations, the adapter calls for an increment of the daily rate-limit count. Violations are handled by the penalty handler (setting flags/status) and the workflow ends with either throwing an exception or calling next() to pass to the next middleware.
<div align="center">
<img src="assets/img/observe_middleware_diagram.png" alt="&nbsp;observe middleware diagram">
<img src="assets/diagram/observation_middleware_v1.5.2.png" alt="&nbsp;observe middleware diagram">
Figure 2 - observation middleware workflow, v1.0.0-beta.2
</div>

<br>

### $\textsf{\color{teal}Notifications}$

While the support hub can receive requests for bugs, help, or feedback and create tickets based on this data, someone has to be informed of them if further action is necessary. Therefore, a notification service has been implemented to push messages to the responsible admin/developer.<br>
This notification service works with the `Telegram Bot API` by simply creating a bot and sending a custom message (text) with your credentials (`BOT_KEY`, `ADMIN_ID`) to the API:
```sh
private async notify(params: NotificationPostParams) {
try {
await axios.post(`https://api.telegram.org/bot${secrets.NOTIFY_BOT_KEY.trim()}/sendMessage`, {
chat_id: secrets.NOTIFY_ADMIN_ID.trim(),
text: params.text.trim(),
});
} catch(err: any) {
CommonUtils.logError(params.logMsg, params.logMethod, err);
}
}
```
Should the notification fail while the rest of the process was successfully executed, the error will only be logged and not thrown as an exception. Telegram can be used on mobile or desktop operating systems and displays notifications as standard messages with the customized text (see Figure 3, for example).<br>
[see Telegram Bot API](https://core.telegram.org/bots/tutorial)

<div align="center">
<img src="assets/img/notification_example.jpg" alt="&nbsp;Betterstack logging dashboard">
Figure 3 - Notification via Telegram bot, v1.5.2
</div>

<br>

## 📝 $\textsf{\color{salmon}Logging}$

To monitor errors the logging framework `Winston` is used in combination with Logtail from `Betterstack` as a Singleton: [config](./backend/src/logger/config.logger.ts)
<br>While working within local (DEV) or test environment, error messages are logged into the consoles. For the deployed environments (STAG/PROD) the logging is set to send logtails to Betterstack (longer storage time than app-hosting service). For easy access and monitoring of error messages, the Betterstack UI client dashboard comes in handy (see `Figure 3`). Additional meta data (environment + version numbers) help identifying and assigning errors.
<br>While working within local (DEV) or test environment, error messages are logged into the consoles. For the deployed environments (STAG/PROD) the logging is set to send logtails to Betterstack (longer storage time than app-hosting service). For easy access and monitoring of error messages, the Betterstack UI client dashboard comes in handy (see `Figure 4`). Additional meta data (environment + version numbers) help identifying and assigning errors.
<div align="center">
<img src="assets/img/betterstack_logging.png" alt="&nbsp;Betterstack logging dashboard">
Figure 3 - Betterstack logging dashboard, v1.0.0-beta.1
Figure 4 - Betterstack logging dashboard, v1.0.0-beta.1
</div>

<br>
Expand All @@ -124,14 +151,14 @@ Testing of the application server can be done automatically via Jest tests (next
[PAYLOAD] { "demo_mode": DemoMode }
```
Use `https://support-0hsq.onrender.com` for {{url}} to test on live conditions.<br>
See Figure 4 for the different use cases & responses (Postman, v11.73.5) - from left to right:
See Figure 5 for the different use cases & responses (Postman, v11.73.5) - from left to right:
<br>[PAYLOAD]: { "mode_enum": "success" } => retrieve current version number as request without fail
<br>[PAYLOAD]: undefined (none) or empty obj/array => retrieve exception for undefined body
<br>[PAYLOAD]: { "mode_enum": "%§$" } => retrieve exception due to invalid value
<br>[PAYLOAD]: { "mode_enum": "error" } => retrieve exception for intended failing db query (see data.message: SEL instead of SELECT)
<div align="center">
<img src="assets/img/demo_results.png" alt="&nbsp;Betterstack logging dashboard">
Figure 4 - /test/demo responses, v1.3.1
Figure 5 - /test/demo responses, v1.3.1
</div>

<br>
Expand Down Expand Up @@ -170,16 +197,16 @@ or simply save as script command in `package.json` to run `npm test`:
<br>

To automatically check tests before merging feature/development branch further up, a `GitHub Action` is set up, see [main.yml](.github/workflows/main.yml).<br>
Preventing an unwanted merge with unfinished/failed test run, the project is set up to disable merging until all tests have passed (see Figure 5 to Figure 6).
Preventing an unwanted merge with unfinished/failed test run, the project is set up to disable merging until all tests have passed (see Figure 6 to Figure 7).

<div align="center">
<img src="assets/img/github-action-jest-processing.png" alt="&nbsp;GitHub processing tests">
Figure 5 - processing tests, v0.9.1
Figure 6 - processing tests, v0.9.1
</div>
<br>
<div align="center">
<img src="assets/img/github-action-jest-passed.png" alt="&nbsp;GitHub tests passed">
Figure 6 - passing tests, v0.9.1
Figure 7 - passing tests, v0.9.1
</div>

<br>
Expand All @@ -188,14 +215,8 @@ Preventing an unwanted merge with unfinished/failed test run, the project is set
[see changelog for all updates](/docs/CHANGELOG.md)


$\textsf{[v1.4.4\ =>\ {\textbf{\color{brown}v1.4.9}]}}$ app<br>
- $\textsf{\color{teal}Addition:}$ Added form-data parser middleware for requests including files.
- $\textsf{\color{orange}Patch:}$ Updated:
+ return types, mapping and handling (part 1).
+ calculation for delete-permission by comparing timestamps (incorporate timezone offset on database read timestamp).
+ 'tickets' request: more accurate check for containing files.
+ use Promise-instance fn finally() in repository-layer to reduce code.
+ documentation headers and display error on symbol (&).
$\textsf{[v1.5.2\ =>\ {\textbf{\color{brown}v1.6.0}]}}$ app<br>
- $\textsf{\color{teal}Addition:}$ Added health endpoints to improve health checks (manually + cron-jobs).

<br>

Expand Down
Binary file added assets/diagram/db_migration_v1.5.2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/diagram/public_workflow_v1.5.2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/telegram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/img/notification_example.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "support_backend",
"version": "1.4.9",
"version": "1.6.0",
"appMeta": {
"db_version": "1.6.0",
"docker_image": "yqni13/support",
Expand Down
5 changes: 2 additions & 3 deletions backend/src/configs/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ export const Config: any = {
ADMIN_KEY: process.env.SECRET_ADMIN_KEY || null,
ENV_MODE: process.env.NODE_ENV || 'development',
PORT: process.env.ENV_PORT || 3000,
EMAIL_RECEIVER: process.env.SECRET_EMAIL_RECEIVER || null,
EMAIL_SENDER: process.env.SECRET_EMAIL_SENDER || null,
EMAIL_PASS: process.env.SECRET_EMAIL_PASS || null,
NOTIFY_ADMIN_ID: process.env.SECRET_NOTIFY_ADMIN_ID || null,
NOTIFY_BOT_KEY: process.env.SECRET_NOTIFY_BOT_KEY || null,
BETTERSTACK_LOGGING_KEY: process.env.SECRET_BETTERSTACK_LOGGING_KEY || null,
BETTERSTACK_HOST: process.env.SECRET_BETTERSTACK_HOST || null,
DB_USER: process.env.SECRET_DB_USER || null,
Expand Down
24 changes: 24 additions & 0 deletions backend/src/controllers/health.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextFunction, Request, Response } from "express";
import healthService from "../services/health.service";
import { HealthCheckExtended } from "../services/interfaces/health.interface.service";

class HealthController {
async getHealthCheck(req: Request, res: Response, next: NextFunction) {
try {
res.status(200).json({ status: 200 });
} catch(err: any) {
next(err);
}
}

async getHealthCheckDetails(req: Request, res: Response, next: NextFunction) {
try {
const response: HealthCheckExtended = await healthService.getHealthCheckDetails();
res.json(response);
} catch(err: any) {
next(err);
}
}
}

export default new HealthController();
17 changes: 0 additions & 17 deletions backend/src/controllers/mailing.controller.ts

This file was deleted.

5 changes: 5 additions & 0 deletions backend/src/dtos/feedback.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,8 @@ export interface FeedbackResponseDTO {
created_on: string,
blocked?: boolean
}

export interface FeedbackExtendedResponseDTO extends FeedbackResponseDTO {
client_name: string,
user_email: string
}
6 changes: 3 additions & 3 deletions backend/src/loaders/routes.loader.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { Application } from 'express';
import clientsRouter from '../routes/clients.route';
import mailingRouter from '../routes/mailing.route';
import metaRouter from '../routes/meta.route';
import ticketsRouter from '../routes/tickets.route';
import usersRouter from '../routes/users.route';
import testRouter from '../routes/test.route';
import feedbackRouter from '../routes/feedback.route';
import feedbackRatingRouter from '../routes/feedback-rating.route';
import healthRouter from '../routes/health.route';

export class RoutesLoader {
static initRoutes(app: Application, version: string) {
app.use(`/api/${version}/clients`, clientsRouter);
app.use(`/api/${version}/feedback`, feedbackRouter);
app.use(`/api/${version}/feedback-rating`, feedbackRatingRouter);
app.use(`/api/${version}/mailing`, mailingRouter);
app.use(`/api/${version}/health`, healthRouter);
app.use(`/api/${version}/meta`, metaRouter);
app.use(`/api/${version}/test`, testRouter);
app.use(`/api/${version}/tickets`, ticketsRouter);
app.use(`/api/${version}/users`, usersRouter);
}
};
}
31 changes: 26 additions & 5 deletions backend/src/middleware/handler/penalty.handler.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import usersService from "../../services/users.service";
import { UsersFlagUpdateDTO } from "../../dtos/users.dto";
import { MaintenanceUpdateDTO } from "../../dtos/meta.dto";
import metaService from "../../services/meta.service";
import { NotificationService } from "../../services/notificiation.service";

export class PenaltyHandler{
constructor(private readonly handlers: Map<Violation, PenaltyApply>) {
Expand All @@ -35,8 +36,15 @@ export class ClientsFlagPenalty implements PenaltyApply<Extract<PenaltyContext,
async apply(context: PenaltyClientsFlagContext) {
const id = context.id;
const dto: ClientsFlagUpdateDTO = { flag: getNextRankEnumValue(Flag, context.penaltyValue) };
await clientsService.updateClientFlag(id, dto);
// TODO(yqni13): add mail notification (SUPPORT-49)
const result = await clientsService.updateClientFlag(id, dto);
const notification = NotificationService.getInstance();
await notification.sendPenaltyInfo({
id: context.id,
entity: 'Clients',
client_name: result?.name,
violation: context.type,
penalty: dto.flag,
});
}
}

Expand All @@ -46,8 +54,15 @@ export class UsersFlagPenalty implements PenaltyApply<Extract<PenaltyContext, {
async apply(context: PenaltyUsersFlagContext) {
const id = context.id;
const dto: UsersFlagUpdateDTO = { flag: getNextRankEnumValue(Flag, context.penaltyValue) };
await usersService.updateUserFlag(id, dto);
// TODO(yqni13): add mail notification (SUPPORT-49)
const result = await usersService.updateUserFlag(id, dto);
const notification = NotificationService.getInstance();
await notification.sendPenaltyInfo({
id: context.id,
entity: 'Users',
user_email: result?.email,
violation: context.type,
penalty: dto.flag,
});
}
}

Expand All @@ -59,6 +74,12 @@ export class MaintenanceTrafficPenalty implements PenaltyApply<Extract<PenaltyCo
const id = context.id;
const dto: MaintenanceUpdateDTO = { maintenance_mode: context.penaltyValue }; // MaintenanceMode.T011
await metaService.updateMaintenanceMode(id, dto);
// TODO(yqni13): add mail notification (SUPPORT-49)
const notification = NotificationService.getInstance();
await notification.sendPenaltyInfo({
id: context.id,
entity: 'Maintenance',
violation: context.type,
penalty: dto.maintenance_mode,
});
}
}
11 changes: 7 additions & 4 deletions backend/src/middleware/observe.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ import { DemoLimitsIncrement, RateLimitsIncrement } from "./adapter/rate-limits.
import { penaltyHandler } from "./container/penalty.container.middleware";
import { ClientsId } from "../repositories/interfaces/clients.entity.interface";
import { UsersId } from "../repositories/interfaces/users.entity.interface";
import { EnvMode } from "../utils/enums/env-mode.enum";

export function observe(isDemo: boolean = false) {
return async function (req: Request, res: Response, next: NextFunction) {
try {
const provokedRateLimits = !isDemo ? await checkRateLimits(req) : await checkDemoLimits();
if(provokedRateLimits) {
await penaltyHandler.apply(provokedRateLimits.penalty);
throw new ExceedMaxEndpointException(provokedRateLimits.msg, provokedRateLimits.retryAfter);
if(secrets.ENV_MODE.trim() !== EnvMode.DEV) {
const provokedRateLimits = !isDemo ? await checkRateLimits(req) : await checkDemoLimits();
if(provokedRateLimits) {
await penaltyHandler.apply(provokedRateLimits.penalty);
throw new ExceedMaxEndpointException(provokedRateLimits.msg, provokedRateLimits.retryAfter);
}
}
next();
} catch(err: any) {
Expand Down
15 changes: 15 additions & 0 deletions backend/src/models/health.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { HealthCheckMemory } from "../services/interfaces/health.interface.service";

class HealthModel {
checkMemory(): HealthCheckMemory {
const memory = process.memoryUsage();
const toMB = (num: number) => `${Math.round(num / 1024 / 1024)}MB`;
return {
heapUsed: toMB(memory.heapUsed),
heapTotal: toMB(memory.heapTotal),
rss: toMB(memory.rss)
}
}
}

export default new HealthModel();
Loading
Loading