diff --git a/package-lock.json b/package-lock.json index 601a832..caea7de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@types/cors": "^2.8.17", "@types/express": "^4.17.13", "@types/node": "^20.3.1", "body-parser": "^1.20.2", + "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.1", "http-status": "^1.6.2", @@ -600,6 +602,14 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -1328,6 +1338,18 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", diff --git a/package.json b/package.json index 1635a25..8a2aed9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "migration:run": "npm run build && typeorm migration:run -d dist/src/dataSource.js", "migration:create": " typeorm migration:create src/migrations/$npm_config_name", "migration:revert": " npm run build && typeorm migration:revert -d dist/src/dataSource.js", - "typeorm": "typeorm-ts-node-esm" + "typeorm": "typeorm-ts-node-esm", + "script:importDb": "npm run build && node dist/src/scripts/importDb.js" }, "author": "", "license": "ISC", @@ -22,9 +23,11 @@ "typescript": "^4.6.4" }, "dependencies": { + "@types/cors": "^2.8.17", "@types/express": "^4.17.13", "@types/node": "^20.3.1", "body-parser": "^1.20.2", + "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.1", "http-status": "^1.6.2", diff --git a/src/app/app.ts b/src/app/app.ts index 20689c6..100f49c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,5 +1,5 @@ -import Express, { Response } from 'express'; -import path from 'path'; +import Express from 'express'; +import cors from 'cors'; import bodyParser from 'body-parser'; import { config } from '../config'; import { dataSource } from '../dataSource'; @@ -14,7 +14,7 @@ async function runApp() { const app = Express(); - app.use('/api', bodyParser.json(), router); + app.use('/api', bodyParser.json(), cors({ origin: config.HOST_URL }), router); app.listen(config.PORT, async () => { logger.info(`Server is running on port ${config.PORT}`); diff --git a/src/client/src/lib/api.ts b/src/client/src/lib/api.ts index 20dab96..ef3f968 100644 --- a/src/client/src/lib/api.ts +++ b/src/client/src/lib/api.ts @@ -1,10 +1,20 @@ import { config } from '../config'; import { localStorage } from './localStorage'; -const api = {}; +const api = { getClients, getClientSummary }; const BASE_URL = `${config.API_URL}/api`; +async function getClients() { + const URL = `${BASE_URL}/clients`; + return performApiCall(URL, 'GET'); +} + +async function getClientSummary(clientId: string) { + const URL = `${BASE_URL}/clients/${clientId}/summary`; + return performApiCall(URL, 'GET'); +} + async function performApiCall( url: string, method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', diff --git a/src/client/src/lib/pathHandler.test.ts b/src/client/src/lib/pathHandler.test.ts new file mode 100644 index 0000000..7b487c3 --- /dev/null +++ b/src/client/src/lib/pathHandler.test.ts @@ -0,0 +1,49 @@ +import { pathHandler } from './pathHandler'; + +describe('pathHandler', () => { + describe('getRoutePath', () => { + it('should return the generic route path if no parameters provided', () => { + const path = pathHandler.getRoutePath('CLIENTS'); + + expect(path).toBe('/clients'); + }); + + it('should return the route path with parameter', () => { + const path = pathHandler.getRoutePath('CLIENT_SUMMARY', { + clientId: '219a36c4-a04e-4877-b300-000a27c0830f', + }); + + expect(path).toBe('/clients/219a36c4-a04e-4877-b300-000a27c0830f'); + }); + }); + + describe('extractParameters', () => { + it('should return path with no parameter if path has no parameter', () => { + const path = pathHandler.getRoutePath('CLIENTS'); + + const parsedPath = pathHandler.parsePath(path); + + expect(parsedPath?.routeKey).toEqual('CLIENTS'); + expect(parsedPath?.parameters).toEqual({}); + }); + + it('should return home if path is home', () => { + const path = pathHandler.getRoutePath('HOME'); + + const parsedPath = pathHandler.parsePath(path); + + expect(parsedPath?.routeKey).toEqual('HOME'); + expect(parsedPath?.parameters).toEqual({}); + }); + + it('should return path with parameter if path has one parameter', () => { + const clientId = `${Math.floor(Math.random() * 10000) + 1}`; + const path = pathHandler.getRoutePath('CLIENT_SUMMARY', { clientId }); + + const parsedPath = pathHandler.parsePath(path); + + expect(parsedPath?.routeKey).toEqual('CLIENT_SUMMARY'); + expect(parsedPath?.parameters).toEqual({ clientId }); + }); + }); +}); diff --git a/src/client/src/lib/pathHandler.ts b/src/client/src/lib/pathHandler.ts new file mode 100644 index 0000000..ac54fa4 --- /dev/null +++ b/src/client/src/lib/pathHandler.ts @@ -0,0 +1,65 @@ +import { ROUTE_KEYS } from '../routes/routeKeys'; +import { ROUTE_PATHS } from '../routes/routePaths'; + +const pathHandler = { + getRoutePath, + parsePath, +}; + +function getRoutePath>( + routeKey: (typeof ROUTE_KEYS)[number], + parameters?: paramsT, + queryParameters?: Record, +) { + let path = ROUTE_PATHS[routeKey].path; + if (parameters) { + Object.keys(parameters).forEach((key) => { + path = path.replace(new RegExp(':' + key), parameters[key]); + }); + } + if (queryParameters) { + path = path + '?'; + const queryParameterKeys = Object.keys(queryParameters); + for (let i = 0; i < queryParameterKeys.length; i++) { + const key = queryParameterKeys[i]; + const value = queryParameters[key]; + if (i > 0) { + path += '&'; + } + path += `${key}=${value}`; + } + } + return path; +} + +function parsePath( + path: string, +): { parameters: Record; routeKey: (typeof ROUTE_KEYS)[number] } | undefined { + const splitActualPath = path.split('/'); + + routeKeysLoop: for (const ROUTE_KEY of ROUTE_KEYS) { + const parameters: any = {}; + + const ROUTE_PATH = ROUTE_PATHS[ROUTE_KEY].path; + const splitCanonicalPath = ROUTE_PATH.split('/'); + if (splitActualPath.length !== splitCanonicalPath.length) { + continue; + } + const chunkCount = splitActualPath.length; + for (let i = 0; i < chunkCount; i++) { + const actualPathChunk = splitActualPath[i]; + const canonicalPathChunk = splitCanonicalPath[i]; + if (canonicalPathChunk.length > 0 && canonicalPathChunk[0] === ':') { + const parameterKey = canonicalPathChunk.substring(1); + const parameterValue = actualPathChunk; + parameters[parameterKey] = parameterValue; + } else if (canonicalPathChunk !== actualPathChunk) { + continue routeKeysLoop; + } + } + return { parameters, routeKey: ROUTE_KEY }; + } + return undefined; +} + +export { pathHandler }; diff --git a/src/client/src/pages/ClientSummary.tsx b/src/client/src/pages/ClientSummary.tsx new file mode 100644 index 0000000..03133a4 --- /dev/null +++ b/src/client/src/pages/ClientSummary.tsx @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'react-router-dom'; +import { api } from '../lib/api'; + +type statusValueType = 'up' | 'down'; + +// type elementaryStatusType = { timestamp: number; statusValue: statusValueType }; + +type eventType = { timestamp: number; kind: statusValueType; title: string }; + +type clientSummaryType = { + id: string; + name: string; + currentStatusValue: statusValueType; + // uptime: { + // last24Hours: elementaryStatusType[]; + // last90Days: elementaryStatusType[]; + // }; + // overallUptime: { + // last24Hours: number; + // last7Days: number; + // last30Days: number; + // last90Days: number; + // }; + events: eventType[]; +}; + +function ClientSummary() { + const params = useParams<{ clientId: string }>(); + const clientId = params.clientId as string; + const query = useQuery({ + queryFn: () => api.getClientSummary(clientId), + queryKey: ['clients', clientId, 'summary'], + }); + + if (!query.data) { + return
Loading...
; + } + return
; +} + +export { ClientSummary }; diff --git a/src/client/src/pages/Clients.tsx b/src/client/src/pages/Clients.tsx new file mode 100644 index 0000000..750272c --- /dev/null +++ b/src/client/src/pages/Clients.tsx @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import { Link } from 'react-router-dom'; +import { pathHandler } from '../lib/pathHandler'; + +type clientType = { + id: string; + name: string; +}; + +function Clients() { + const query = useQuery({ queryFn: api.getClients, queryKey: ['clients'] }); + + if (!query.data) { + return
Loading...
; + } + return ( +
+
    + {query.data.map((client) => ( +
  • + + {client.name} + +
  • + ))} +
+
+ ); +} + +export { Clients }; diff --git a/src/client/src/routes/routeElements.tsx b/src/client/src/routes/routeElements.tsx index a26619c..1bfcebf 100644 --- a/src/client/src/routes/routeElements.tsx +++ b/src/client/src/routes/routeElements.tsx @@ -1,8 +1,12 @@ +import { ClientSummary } from '../pages/ClientSummary'; +import { Clients } from '../pages/Clients'; import { Home } from '../pages/Home'; import { ROUTE_KEYS } from './routeKeys'; const ROUTE_ELEMENTS: Record<(typeof ROUTE_KEYS)[number], { element: JSX.Element }> = { HOME: { element: }, + CLIENTS: { element: }, + CLIENT_SUMMARY: { element: }, }; export { ROUTE_ELEMENTS }; diff --git a/src/client/src/routes/routeKeys.ts b/src/client/src/routes/routeKeys.ts index b7c7cc8..b895ce2 100644 --- a/src/client/src/routes/routeKeys.ts +++ b/src/client/src/routes/routeKeys.ts @@ -1,3 +1,3 @@ -const ROUTE_KEYS = ['HOME'] as const; +const ROUTE_KEYS = ['HOME', 'CLIENTS', 'CLIENT_SUMMARY'] as const; export { ROUTE_KEYS }; diff --git a/src/client/src/routes/routePaths.ts b/src/client/src/routes/routePaths.ts index 5995d93..f876574 100644 --- a/src/client/src/routes/routePaths.ts +++ b/src/client/src/routes/routePaths.ts @@ -2,7 +2,13 @@ import { ROUTE_KEYS } from './routeKeys'; const ROUTE_PATHS: Record<(typeof ROUTE_KEYS)[number], { path: string }> = { HOME: { - path: '/*', + path: '/', + }, + CLIENT_SUMMARY: { + path: '/clients/:clientId', + }, + CLIENTS: { + path: '/clients', }, }; diff --git a/src/client/src/routes/routeTitles.ts b/src/client/src/routes/routeTitles.ts index 5b8902b..41dc84b 100644 --- a/src/client/src/routes/routeTitles.ts +++ b/src/client/src/routes/routeTitles.ts @@ -2,6 +2,8 @@ import { ROUTE_KEYS } from './routeKeys'; const ROUTE_TITLES: Record<(typeof ROUTE_KEYS)[number], string> = { HOME: 'Accueil', + CLIENTS: 'Liste des clients', + CLIENT_SUMMARY: 'Résumé', }; export { ROUTE_TITLES }; diff --git a/src/config.ts b/src/config.ts index c5791fa..9b238cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ if (process.env.DATABASE_URL) { const config = { PORT: process.env.PORT || 3000, + HOST_URL: process.env.HOST_URL || '', DATABASE_HOST: process.env.DATABASE_HOST || '', DATABASE_PASSWORD: process.env.DATABASE_PASSWORD || '', DATABASE_USER: process.env.DATABASE_USER || '', diff --git a/src/dataSource.ts b/src/dataSource.ts index 99e6130..be93dd6 100644 --- a/src/dataSource.ts +++ b/src/dataSource.ts @@ -2,7 +2,7 @@ import { DataSource } from 'typeorm'; import { config } from './config'; import { Client } from './modules/client'; -import { Ping } from './modules/ping'; +import { Event } from './modules/event'; const dataSource = new DataSource({ type: 'postgres', @@ -13,7 +13,7 @@ const dataSource = new DataSource({ database: config.DATABASE_NAME, logging: ['warn', 'error'], connectTimeoutMS: 20000, - entities: [Client, Ping], + entities: [Client, Event], subscribers: [], migrations: ['**/migrations/*.js'], }); diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..675e696 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,26 @@ +import { Client } from '../modules/client'; +import { Event } from '../modules/event'; + +const api = { + fetchAllClients, + fetchAllEvents, +}; +const BASE_URL = 'https://ping-storage.osc-fr1.scalingo.io'; + +async function fetchAllClients(): Promise { + const URL = `${BASE_URL}/api/all-clients`; + + const response = await fetch(URL); + const parsedData = await response.json(); + return parsedData; +} + +async function fetchAllEvents(): Promise { + const URL = `${BASE_URL}/api/all-events`; + + const response = await fetch(URL); + const parsedData = await response.json(); + return parsedData; +} + +export { api }; diff --git a/src/migrations/1729418677813-add-event.ts b/src/migrations/1729418677813-add-event.ts new file mode 100644 index 0000000..dcd68a8 --- /dev/null +++ b/src/migrations/1729418677813-add-event.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddEvent1729418677813 implements MigrationInterface { + name = 'AddEvent1729418677813' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."event_kind_enum" AS ENUM('up', 'down')`); + await queryRunner.query(`CREATE TABLE "event" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "kind" "public"."event_kind_enum" NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "clientId" uuid NOT NULL, CONSTRAINT "PK_30c2f3bbaf6d34a55f8ae6e4614" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "ping" DROP COLUMN "createdAt"`); + await queryRunner.query(`ALTER TABLE "ping" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "event" ADD CONSTRAINT "FK_332ae914d279c823c7ae4197d5d" FOREIGN KEY ("clientId") REFERENCES "client"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "FK_332ae914d279c823c7ae4197d5d"`); + await queryRunner.query(`ALTER TABLE "ping" DROP COLUMN "createdAt"`); + await queryRunner.query(`ALTER TABLE "ping" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`DROP TABLE "event"`); + await queryRunner.query(`DROP TYPE "public"."event_kind_enum"`); + } + +} diff --git a/src/migrations/1729420898909-add-frequency-and-grace-period.ts b/src/migrations/1729420898909-add-frequency-and-grace-period.ts new file mode 100644 index 0000000..cc545a9 --- /dev/null +++ b/src/migrations/1729420898909-add-frequency-and-grace-period.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddFrequencyAndGracePeriod1729420898909 implements MigrationInterface { + name = 'AddFrequencyAndGracePeriod1729420898909' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "client" ADD "frequency" integer NOT NULL DEFAULT '1'`); + await queryRunner.query(`ALTER TABLE "client" ADD "gracePeriod" integer NOT NULL DEFAULT '1'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "client" DROP COLUMN "gracePeriod"`); + await queryRunner.query(`ALTER TABLE "client" DROP COLUMN "frequency"`); + } + +} diff --git a/src/migrations/1729424213523-add-last-pinged-at.ts b/src/migrations/1729424213523-add-last-pinged-at.ts new file mode 100644 index 0000000..37b2ab8 --- /dev/null +++ b/src/migrations/1729424213523-add-last-pinged-at.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLastPingedAt1729424213523 implements MigrationInterface { + name = 'AddLastPingedAt1729424213523'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "client" ADD "lastPingedAt" TIMESTAMP`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "client" DROP COLUMN "lastPingedAt"`); + } +} diff --git a/src/migrations/1729425458873-delete-pings.ts b/src/migrations/1729425458873-delete-pings.ts new file mode 100644 index 0000000..5b63243 --- /dev/null +++ b/src/migrations/1729425458873-delete-pings.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DeletePings1729425458873 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "ping" DROP CONSTRAINT "FK_eaee4e2004faa8d8af53024ed8f"`, + ); + await queryRunner.query(`DROP TABLE "ping"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "ping" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "clientId" uuid NOT NULL, CONSTRAINT "PK_b01cab9d614b77bac5973937663" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "ping" ADD CONSTRAINT "FK_eaee4e2004faa8d8af53024ed8f" FOREIGN KEY ("clientId") REFERENCES "client"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/modules/client/Client.entity.ts b/src/modules/client/Client.entity.ts index 5ec0195..42914e5 100644 --- a/src/modules/client/Client.entity.ts +++ b/src/modules/client/Client.entity.ts @@ -7,4 +7,13 @@ export class Client { @Column({ unique: true }) name: string; + + @Column({ default: 1 }) + frequency: number; + + @Column({ default: 1 }) + gracePeriod: number; + + @Column({ type: 'timestamp', nullable: true }) + lastPingedAt: string | null; } diff --git a/src/modules/client/client.controller.ts b/src/modules/client/client.controller.ts index 822552e..c542e51 100644 --- a/src/modules/client/client.controller.ts +++ b/src/modules/client/client.controller.ts @@ -9,6 +9,9 @@ function buildClientController() { createClient, assertIsClientUp, getAllClients, + getClients, + getClientSummary, + pingClient, }; return clientController; @@ -24,4 +27,16 @@ function buildClientController() { async function getAllClients() { return clientService.getAllClients(); } + + async function getClients() { + return clientService.getAllClients(); + } + + async function pingClient(params: { urlParams: { clientId: Client['id'] } }) { + return clientService.pingClient(params.urlParams.clientId); + } + + async function getClientSummary(params: { urlParams: { clientId: Client['id'] } }) { + return clientService.getClientSummary(params.urlParams.clientId); + } } diff --git a/src/modules/client/client.service.ts b/src/modules/client/client.service.ts index dece91b..f9e69b2 100644 --- a/src/modules/client/client.service.ts +++ b/src/modules/client/client.service.ts @@ -1,16 +1,18 @@ import { dataSource } from '../../dataSource'; -import { buildPingService } from '../ping'; +import { buildEventService } from '../event'; import { Client } from './Client.entity'; export { buildClientService }; function buildClientService() { const clientRepository = dataSource.getRepository(Client); - const pingService = buildPingService(); + const eventService = buildEventService(); const clientService = { createClient, assertIsClientUp, getAllClients, + getClientSummary, + pingClient, }; return clientService; @@ -24,9 +26,51 @@ function buildClientService() { return { clientId: result.identifiers[0].id }; } + async function pingClient(clientId: Client['id']) { + const now = new Date(); + const result = await clientRepository.update( + { id: clientId }, + { lastPingedAt: now.toISOString() }, + ); + if (result.affected !== 1) { + throw new Error(`client id ${clientId} does not exist`); + } + + const lastEvent = await eventService.getLastEvent(clientId); + if (!lastEvent) { + await eventService.createEvent(clientId, { title: 'Service en route !', kind: 'up' }); + } else { + if (lastEvent.kind === 'down') { + await eventService.createEvent(clientId, { + title: 'Le service est revenu', + kind: 'up', + }); + } + } + + return true; + } + async function assertIsClientUp(name: Client['name']) { - const client = await clientRepository.findOneByOrFail({ name }); - await pingService.assertHasClientBeenPingedRecently(client.id); + const client = await clientRepository.findOneOrFail({ where: { name } }); + if (!client.lastPingedAt) { + throw new Error(`Client "${client.name}" has never been pinged`); + } + const now = new Date(); + const MAX_DELAY_SINCE_LAST_PING = (client.frequency * 60 + client.gracePeriod * 60) * 1000; + const lastPingThresholdDate = new Date(now.getTime() - MAX_DELAY_SINCE_LAST_PING); + const lastPingedAt = new Date(client.lastPingedAt); + + if (lastPingedAt.getTime() < lastPingThresholdDate.getTime()) { + throw new Error( + `Last ping found for client "${ + client.name + }" was too long ago: ${lastPingedAt.toLocaleString()}`, + ); + } + return { ok: true }; } + + async function getClientSummary(clientId: Client['id']) {} } diff --git a/src/modules/event/Event.entity.ts b/src/modules/event/Event.entity.ts new file mode 100644 index 0000000..79ce567 --- /dev/null +++ b/src/modules/event/Event.entity.ts @@ -0,0 +1,21 @@ +import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Client } from '../client'; +import { EVENT_KINDS, eventKindType } from './types'; + +@Entity() +export class Event { + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column('enum', { enum: EVENT_KINDS }) + kind: eventKindType; + + @ManyToOne(() => Client, { onDelete: 'CASCADE', nullable: false }) + client: Client; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt: string; +} diff --git a/src/modules/event/event.controller.ts b/src/modules/event/event.controller.ts new file mode 100644 index 0000000..d556e03 --- /dev/null +++ b/src/modules/event/event.controller.ts @@ -0,0 +1,16 @@ +import { buildEventService } from './event.service'; + +export { buildEventController }; + +function buildEventController() { + const eventService = buildEventService(); + const eventController = { + getAllEvents, + }; + + return eventController; + + async function getAllEvents() { + return eventService.getAllEvents(); + } +} diff --git a/src/modules/event/event.service.ts b/src/modules/event/event.service.ts new file mode 100644 index 0000000..0c8a79e --- /dev/null +++ b/src/modules/event/event.service.ts @@ -0,0 +1,52 @@ +import { dataSource } from '../../dataSource'; +import { Client } from '../client'; +import { Event } from './Event.entity'; + +export { buildEventService }; + +function buildEventService() { + const eventRepository = dataSource.getRepository(Event); + + const eventService = { + createEvent, + getLastEvent, + getEvents, + getAllEvents, + }; + + return eventService; + + async function getLastEvent(clientId: Client['id']) { + return eventRepository.findOne({ + where: { client: { id: clientId } }, + order: { createdAt: 'DESC' }, + relations: ['client'], + }); + } + + async function getEvents(clientId: Client['id']) { + return eventRepository.find({ + where: { client: { id: clientId } }, + order: { createdAt: 'DESC' }, + relations: ['client'], + }); + } + + async function getAllEvents() { + return eventRepository.find({ + relations: ['client'], + }); + } + + async function createEvent( + clientId: Client['id'], + params: { title: Event['title']; kind: Event['kind'] }, + ) { + await eventRepository.insert({ + client: { id: clientId }, + title: params.title, + kind: params.kind, + }); + return true; + } +} diff --git a/src/modules/event/index.ts b/src/modules/event/index.ts new file mode 100644 index 0000000..5d2ec9e --- /dev/null +++ b/src/modules/event/index.ts @@ -0,0 +1,4 @@ +import { Event } from './Event.entity'; +import { buildEventService } from './event.service'; +import { buildEventController } from './event.controller'; +export { Event, buildEventService, buildEventController }; diff --git a/src/modules/event/types.ts b/src/modules/event/types.ts new file mode 100644 index 0000000..2ae4f72 --- /dev/null +++ b/src/modules/event/types.ts @@ -0,0 +1,6 @@ +const EVENT_KINDS = ['up', 'down'] as const; + +type eventKindType = (typeof EVENT_KINDS)[number]; + +export { EVENT_KINDS }; +export type { eventKindType }; diff --git a/src/modules/ping/Ping.entity.ts b/src/modules/ping/Ping.entity.ts deleted file mode 100644 index 89bc118..0000000 --- a/src/modules/ping/Ping.entity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; -import { Client } from '../client'; - -@Entity() -export class Ping { - @PrimaryGeneratedColumn() - id: number; - - @ManyToOne(() => Client, { onDelete: 'CASCADE', nullable: false }) - client: Client; - - @CreateDateColumn({ type: 'timestamp' }) - createdAt: string; -} diff --git a/src/modules/ping/index.ts b/src/modules/ping/index.ts deleted file mode 100644 index 94c272d..0000000 --- a/src/modules/ping/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Ping } from './Ping.entity'; -import { buildPingController } from './ping.controller'; -import { buildPingService } from './ping.service'; - -export { Ping, buildPingController, buildPingService }; diff --git a/src/modules/ping/ping.controller.ts b/src/modules/ping/ping.controller.ts deleted file mode 100644 index af9fe64..0000000 --- a/src/modules/ping/ping.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { buildPingService } from './ping.service'; - -export { buildPingController }; - -function buildPingController() { - const pingService = buildPingService(); - const pingController = { - createPing, - getAllPings, - }; - - return pingController; - - async function createPing(params: { urlParams: { clientId: string } }) { - return pingService.createPing(params.urlParams.clientId); - } - - async function getAllPings() { - return pingService.getAllPings(); - } -} diff --git a/src/modules/ping/ping.service.ts b/src/modules/ping/ping.service.ts deleted file mode 100644 index 2414cc3..0000000 --- a/src/modules/ping/ping.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { MoreThan } from 'typeorm'; -import { dataSource } from '../../dataSource'; -import { Client } from '../client'; -import { Ping } from './Ping.entity'; - -export { buildPingService }; - -const MAX_DELAY_SINCE_LAST_PING = 120 * 1000; - -function buildPingService() { - const pingRepository = dataSource.getRepository(Ping); - - const pingService = { - createPing, - getAllPings, - assertHasClientBeenPingedRecently, - }; - - return pingService; - - async function getAllPings() { - return pingRepository.find({ relations: ['client'] }); - } - - async function createPing(clientId: Client['id']) { - await pingRepository.insert({ client: { id: clientId } }); - return true; - } - - async function assertHasClientBeenPingedRecently(clientId: Client['id']) { - const now = new Date(); - const lastPingThresholdDate = new Date(now.getTime() - MAX_DELAY_SINCE_LAST_PING); - const pings = await pingRepository.find({ - relations: ['client'], - where: { - client: { id: clientId }, - createdAt: MoreThan(lastPingThresholdDate.toISOString()), - }, - }); - if (pings.length === 0) { - throw new Error( - `No ping found ${MAX_DELAY_SINCE_LAST_PING} ms ago for clientId ${clientId}`, - ); - } - } -} diff --git a/src/router/clientRoutes.ts b/src/router/clientRoutes.ts index eb58f5f..78d0f16 100644 --- a/src/router/clientRoutes.ts +++ b/src/router/clientRoutes.ts @@ -24,8 +24,23 @@ const clientRoutes: Array> = [ { method: 'GET', path: '/clients', + controller: clientController.getClients, + }, + { + method: 'GET', + path: '/clients/:clientId/summary', + controller: clientController.getClientSummary, + }, + { + method: 'GET', + path: '/all-clients', controller: clientController.getAllClients, }, + { + method: 'POST', + path: '/clients/:clientId/pings', + controller: clientController.pingClient, + }, ]; export { clientRoutes }; diff --git a/src/router/eventRoutes.ts b/src/router/eventRoutes.ts new file mode 100644 index 0000000..c63ad7a --- /dev/null +++ b/src/router/eventRoutes.ts @@ -0,0 +1,15 @@ +import Joi from 'joi'; +import { buildEventController } from '../modules/event'; +import { routeType } from './types'; + +const eventController = buildEventController(); + +const eventRoutes: Array> = [ + { + method: 'GET', + path: '/all-events', + controller: eventController.getAllEvents, + }, +]; + +export { eventRoutes }; diff --git a/src/router/pingRoutes.ts b/src/router/pingRoutes.ts deleted file mode 100644 index 03dbc3a..0000000 --- a/src/router/pingRoutes.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { buildPingController } from '../modules/ping'; -import { routeType } from './types'; - -const pingController = buildPingController(); - -const pingRoutes: Array> = [ - { - method: 'POST', - path: '/clients/:clientId/pings', - controller: pingController.createPing, - }, - { - method: 'GET', - path: '/pings', - controller: pingController.getAllPings, - }, -]; - -export { pingRoutes }; diff --git a/src/router/routes.ts b/src/router/routes.ts index eccc3fc..a40c320 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -1,5 +1,5 @@ import { clientRoutes } from './clientRoutes'; -import { pingRoutes } from './pingRoutes'; +import { eventRoutes } from './eventRoutes'; import { routeType } from './types'; const routes = buildRoutes(); @@ -7,7 +7,7 @@ const routes = buildRoutes(); function buildRoutes() { const routes: routeType[] = []; routes.push(...clientRoutes); - routes.push(...pingRoutes); + routes.push(...eventRoutes); return routes; } diff --git a/src/scripts/importDb.ts b/src/scripts/importDb.ts new file mode 100644 index 0000000..7f31e32 --- /dev/null +++ b/src/scripts/importDb.ts @@ -0,0 +1,35 @@ +import { dataSource } from '../dataSource'; +import { api } from '../lib/api'; +import { Client } from '../modules/client'; +import { Event } from '../modules/event'; + +async function importDb() { + console.log('Initializing database...'); + await dataSource.initialize(); + console.log('Database initialized!'); + const clientRepository = dataSource.getRepository(Client); + const eventRepository = dataSource.getRepository(Event); + + console.log('Erasing local database...'); + + await clientRepository.delete({}); + await eventRepository.delete({}); + + console.log('Fetching clients...'); + const allClients = await api.fetchAllClients(); + console.log(`${allClients.length} clients fetched! Inserting them in database...`); + + await clientRepository.insert(allClients); + console.log('Clients inserted! Now fetching events...'); + + const allEvents = await api.fetchAllEvents(); + + console.log(`${allEvents.length} events fetched! Inserting them in database...`); + + await eventRepository.insert(allEvents); + console.log(`${allEvents.length} events inserted!`); + + console.log('Done!'); +} + +importDb();