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
55 changes: 39 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# yqni13 | support
$\texttt{\color{teal}{v1.4.4}}$
# yqni13 | $\texttt{\color{cornflowerblue}{SUPPORT}}$
### $\textsf{\color{brown}{v1.4.9}}$

### 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. Created following Test-Driven Development (450+ tests including ephemeral database by testcontainers) and hosting env:prod via Render, Neon and Cloudflare.
#### 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.

<br>

Expand Down Expand Up @@ -64,7 +64,14 @@ Alternatively run application in Docker container [(see docs)](./docs/DEVOPS.md)

<br>

### $\textsf{\color{teal}Feedback \&\ Rating}$
### $\textsf{\color{teal}Tickets}$

Main focus on this application is the creating and handling of tickets that are used to represent bug reports or support requests. Tickets are authenticated by client (application) and user identifier and hold information in different ways: `title` and `message` are used as the main description, followed by more specific but optional information like `device`, `operational system` and `browser` or optional file attachments that are stored in the cloud.<br>
Tickets use status to signal the process and ensure it doesn't get deleted before solved, canceled or after a certain time when paused. Deleting a ticket also removes the respective files from the cloud that were originally attached.

<br>

### $\textsf{\color{teal}Feedback and Rating}$

User can utilize a feedback & rating system to rate the application in use and send criticism or praise. For every client can exist multiple entries for the entity `Feedback` but only one `FeedbackRating` which holds the accumulated data of the pointing feedback entries.<br>
Resubmissions are handled in the database by an `ON CONFLICT` upsert query [see upsertInTa()](./backend/src/repositories/feedback.repository.ts) on the unique `(client_id, user_id)` constraint, followed by an atomic aggregate update to the 'FeedbackRating' table entry. Both queries are executed within a single transaction to guarantee data consistency.<br>
Expand All @@ -75,21 +82,34 @@ Furthermore, if an existing feedback entry has a message stored, but is not revi

### $\textsf{\color{teal}File handling}$

User can attach files for any support/bug ticket to provide further information (screenshots, images, ...) on their message. Attachments are limited to upload up to `5` files and each file can be up to `1`MB [see validation](./backend/src/middleware/files/validate.files.middleware.ts). Currently only `images` (webp, jpg, jpeg, png) and `pdf` files are supported, but more will follow. Cloud in use is `Cloudflare` (see Figure 1) using S3Client for api communication and files will be deleted when a ticket is closed, canceled or expired (time check).
User can attach files for any support/bug ticket to provide further information (screenshots, images, ...) on their message. Attachments are limited to upload up to `5` files and each file can be up to `1`MB [see validation](./backend/src/middleware/files/validate.files.middleware.ts). Currently only `images` (webp, jpg, jpeg, png) and `pdf` files are supported, but more will follow. Cloud in use is `Cloudflare` (see `Figure 1`) using S3Client for api communication and files will be deleted when a ticket is closed, canceled or expired (time check).
<div align="center">
<img src="assets/img/cloudflare_demo.png" alt="&nbsp;Cloudflare upload demo">
Figure 1 - Cloudflare upload demo, v1.0.0
</div>

<br>

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

In terms of rate-limiting, penalties and ready-to-extend functionality, the observation middleware takes care of monitoring incoming requests by users and clients (see following workflow or `Figure 2`).<br>

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">
Figure 2 - observation middleware workflow, v1.0.0-beta.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 2). 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 3`). 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 2 - Betterstack logging dashboard, v1.0.0-beta.1
Figure 3 - Betterstack logging dashboard, v1.0.0-beta.1
</div>

<br>
Expand All @@ -104,14 +124,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 3 for the different use cases & responses (Postman, v11.73.5) - from left to right:
See Figure 4 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 3 - /test/demo responses, v1.3.1
Figure 4 - /test/demo responses, v1.3.1
</div>

<br>
Expand Down Expand Up @@ -150,16 +170,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 4 to Figure 5).
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).

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

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


$\textsf{[v1.4.1\ =>\ {\textbf{\color{brown}v1.4.4}]}}$ app<br>
$\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:
+ entity ID's are using now nominal types instead basic string|number.
+ some model functions are renamed to keep consistency and improve readability.
+ some api routes have been shortened to keep consistency and improve readability.
+ 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 (&).

<br>

Expand Down
Binary file added assets/img/observe_middleware_diagram.png
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.4",
"version": "1.4.9",
"appMeta": {
"db_version": "1.6.0",
"docker_image": "yqni13/support",
Expand Down
8 changes: 4 additions & 4 deletions backend/src/controllers/clients.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { NextFunction, Request, Response } from "express";
import { checkValidation } from "../middleware/validation.middleware";
import {
ClientsCreateResponseDTO,
ClientsStatusResponseDTO,
ClientsCreateDTO,
ClientsStatusUpdateDTO
ClientsStatusUpdateDTO,
ClientsResponseDTO
} from "../dtos/clients.dto";
import clientsService from "../services/clients.service";
import { ClientsId } from "../repositories/interfaces/clients.entity.interface";
Expand All @@ -14,7 +14,7 @@ class ClientsController {
try {
checkValidation(req);
const name = req.params.name;
const response: ClientsStatusResponseDTO | null = await clientsService.getClientStatusByName(name);
const response: ClientsResponseDTO | null = await clientsService.getClientStatusByName(name);
res.json(response);
} catch(err: any) {
next(err);
Expand All @@ -37,7 +37,7 @@ class ClientsController {
checkValidation(req);
const id = req.params.id as ClientsId;
const dto: ClientsStatusUpdateDTO = req.body;
const response: ClientsStatusResponseDTO | null = await clientsService.updateClientStatus(id, dto);
const response: ClientsResponseDTO | null = await clientsService.updateClientStatus(id, dto);
res.json(response);
} catch(err: any) {
next(err);
Expand Down
4 changes: 2 additions & 2 deletions backend/src/controllers/feedback.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express";
import { checkValidation } from "../middleware/validation.middleware";
import { FeedbackCreateDTO, FeedbackFilterDTO, FeedbackResponseDTO } from "../dtos/feedback.dto";
import { FeedbackCreateDTO, FeedbackCreateResponseDTO, FeedbackFilterDTO, FeedbackResponseDTO } from "../dtos/feedback.dto";
import feedbackService from "../services/feedback.service";
import { FeedbackId } from "../repositories/interfaces/feedback.entity.interface";

Expand Down Expand Up @@ -35,7 +35,7 @@ class FeedbackController {
client_id: req.apiClients.client_id,
user_id: req.apiUsers.user_id
};
const response: FeedbackResponseDTO | null = await feedbackService.createFeedback(dto);
const response: FeedbackCreateResponseDTO | null = await feedbackService.createFeedback(dto);
res.json(response);
} catch(err: any) {
next(err);
Expand Down
7 changes: 4 additions & 3 deletions backend/src/controllers/tickets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
TicketsResponseDTO,
TicketsFilterDTO,
TicketsCreateDTO,
TicketsUpdateDTO
TicketsUpdateDTO,
TicketsCreateResponseDTO
} from "../dtos/tickets.dto";
import ticketsService from "../services/tickets.service";
import { TicketsId } from "../repositories/interfaces/tickets.entity.interface";
Expand Down Expand Up @@ -55,8 +56,8 @@ class TicketsController {
client_id: req.apiClients.client_id,
user_id: req.apiUsers.user_id
};
const files = req.files as Express.Multer.File[] ?? null;
const response: TicketsResponseDTO = await ticketsService.createTicket(dto, files);
const files = !req.files || req.files.length === 0 ? null : req.files as Express.Multer.File[];
const response: TicketsCreateResponseDTO = await ticketsService.createTicket(dto, files);
res.json(response);
} catch(err: any) {
next(err);
Expand Down
30 changes: 4 additions & 26 deletions backend/src/dtos/clients.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,6 @@ export interface ClientsLastUseUpdateDTO {
last_use: string
}

export interface ClientsExistResponseDTO {
client_id: ClientsId,
name: string,
api_key_hash: string,
status: ApiKeyStatus,
flag: Flag | null,
last_use: string,
last_modified: string,
created_on: string
}

export interface ClientsCreateResponseDTO {
client_id: ClientsId,
name: string,
Expand All @@ -42,27 +31,16 @@ export interface ClientsCreateResponseDTO {
created_on: string
}

export interface ClientsFlagResponseDTO {
client_id: ClientsId,
flag: Flag | null,
last_use: string,
last_modified: string,
created_on: string
}

export interface ClientsStatusResponseDTO {
export interface ClientsResponseDTO {
client_id: ClientsId,
name: string,
status: ApiKeyStatus,
flag: Flag | null,
last_use: string,
last_modified: string,
created_on: string
}

export interface ClientsLastUseResponseDTO {
client_id: ClientsId,
name: string,
last_use: string,
last_modified: string,
created_on: string
export interface ClientsExtendedResponseDTO extends ClientsResponseDTO{
api_key_hash: string
}
11 changes: 11 additions & 0 deletions backend/src/dtos/feedback.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ export interface FeedbackFilterDTO {
created_on?: string | string[]
}

/**
* @description Reduced content as this is used as response outside of admin environment.
*/
export interface FeedbackCreateResponseDTO {
rating: number,
rating_old?: number,
rating_average_new: number,
blocked?: boolean,
created_on: string
}

export interface FeedbackResponseDTO {
feedback_id: FeedbackId,
client_id: ClientsId,
Expand Down
10 changes: 10 additions & 0 deletions backend/src/dtos/tickets.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ export interface TicketsFilterDTO {
created_on?: string[]
}

/**
* @description Reduced content as this is used as response outside of admin environment.
*/
export interface TicketsCreateResponseDTO {
status: TicketStatus,
option: TicketOption,
flag: Flag | null,
created_on: string
}

export interface TicketsResponseDTO {
ticket_id: TicketsId,
client_id: ClientsId,
Expand Down
21 changes: 21 additions & 0 deletions backend/src/middleware/parser/form-data.parser.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Request, Response, NextFunction } from "express";
import { logError } from "../../utils/common.utils";

export function parseFormData() {
return function(req: Request, res: Response, next: NextFunction) {
try {
if(req.body && req.body.data) {
req.body = JSON.parse(req.body.data);
}
next();
} catch(err: any) {
err.status = !err.status ? 404 : err.status;
logError(
"PARSE MIDDLEWARE ERROR ON FORM DATA",
"SUPPORT_middleware_parseFormData",
err
);
next(err);
}
}
}
34 changes: 25 additions & 9 deletions backend/src/models/clients.model.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,49 @@
import {
ClientsCreateDTO,
ClientsCreateResponseDTO,
ClientsExtendedResponseDTO,
ClientsResponseDTO,
} from "../dtos/clients.dto";
import { Clients, ClientsId } from "../repositories/interfaces/clients.entity.interface";
import crypto from 'crypto';
import * as CommonUtils from "../utils/common.utils";
import { ApiKeyStatus } from "../utils/enums/api-key-status.enum";

class ClientsModel {
private timeMapTargets: string[];

constructor() {
this.timeMapTargets = ['last_use', 'last_modified', 'created_on'];
}

toClientsCreateResponseDTO(data: Clients, apiKey: string): ClientsCreateResponseDTO {
data = CommonUtils.mapObjTimestamps(data, this.timeMapTargets);
return {
client_id: data.client_id,
name: data.name,
api_key: apiKey,
status: data.status,
flag: data.flag,
last_use: data.last_use,
last_modified: data.last_modified,
created_on: data.created_on
last_use: CommonUtils.getTimestampUTC(new Date(data.last_use)),
last_modified: CommonUtils.getTimestampUTC(new Date(data.last_modified)),
created_on: CommonUtils.getTimestampUTC(new Date(data.created_on))
};
}

toClientsResponseDTO(entity: Clients, extended: true): ClientsExtendedResponseDTO;
toClientsResponseDTO(entity: Clients, extended: false): ClientsResponseDTO;

toClientsResponseDTO(entity: Clients, extended: boolean) {
const response = {
client_id: entity.client_id,
name: entity.name,
api_key_hash: entity.api_key_hash,
status: entity.status,
flag: entity.flag,
last_use: CommonUtils.getTimestampUTC(new Date(entity.last_use)),
last_modified: CommonUtils.getTimestampUTC(new Date(entity.last_modified)),
created_on: CommonUtils.getTimestampUTC(new Date(entity.created_on))
};
if(!extended) {
delete (response as any)['api_key_hash'];
}
return response;
}

private generateApiKeyObj(): { keyRaw: string, keyHash: string } {
const keyLength = 42;
const charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
Expand Down
Loading
Loading