Compare commits

...

21 Commits

Author SHA1 Message Date
41615303db Frontend> Add testupload to testpage 2025-02-09 19:32:56 +01:00
06767268de Frontend> Add contact page 2025-02-09 19:32:07 +01:00
3c03cc133c use prisma schema for api validation (sort field) 2025-02-09 19:30:50 +01:00
8bbadf36a8 add s3 baseline / Prepare content schema for s3 2025-02-09 19:28:30 +01:00
634f05bc27 prisma schema -> Generate alerthook / content name+filename unique 2025-02-09 16:08:41 +01:00
64d317115c Add some default (security) headers / remove unmaintained deps 2025-02-09 16:07:29 +01:00
5a583a94ff some frontend change 2025-02-03 22:23:53 +01:00
8383080395 schema updates 2025-02-03 22:23:37 +01:00
2e8ee7ca5c table interaction 2025-02-03 22:22:27 +01:00
a0ebf89ef8 Remove todo.. 2025-02-02 20:24:52 +01:00
d565497de1 Fix crash when using FullTextSearch with more then two asterisks in sequence. min(3) validation des the rest 2025-02-02 20:04:25 +01:00
fa26595797 Implement prisma error handling 2025-02-02 18:13:02 +01:00
ee6dd16be2 Support for tables in frontend 2025-01-31 23:49:41 +01:00
e3fba930d2 Remove/Deprecate parseIntOrUndefined 2025-01-31 23:49:16 +01:00
e377af7501 Implemented skip+take in alertContact api and some other optimizations 2025-01-31 23:09:53 +01:00
fb23f73963 Remove whitespace in prisma schema 2025-01-30 01:37:49 +01:00
7715672802 Implemented POST / PATCH / DELETE for alertcontact api route 2025-01-30 01:37:32 +01:00
94034fa29f Add alertContact POST validation 2025-01-30 01:00:24 +01:00
d4da439542 Add joi dependency 2025-01-30 00:32:29 +01:00
8981fec28d Frontend updates 2025-01-30 00:32:10 +01:00
c96bdfddb0 Migrated validation to joi / Moved prisma_helpers to dedicated file / 2025-01-30 00:24:59 +01:00
24 changed files with 1826 additions and 33200 deletions

View File

@ -16,3 +16,34 @@ Funktionen:
- Erklärung MP3
- Quittierung MP3
- Verabschiedung MP3
## API Endpoint planning
alertContacts (CRUD Fully implemented)
alerts -> Only get
actionPlan (CRUD)
- select all prios
priorities (CRUD)
- select actionPlan
- Only allow changes to priority
content (CRUD)
- Howto handle upload?
POST /alert/[:alert_hook]
-> Check actionplan if hook exists and select current prios -> Write call request to XYXYX
1. create one or more alertContacts
2. create 4x content (all phases)
3. create actionplan with contents
4. create one or more priorities

File diff suppressed because one or more lines are too long

View File

@ -1,98 +0,0 @@
//// ------------------------------------------------------
//// THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
//// ------------------------------------------------------
Project "AssetFlow" {
database_type: ''
Note: ''
}
Table alerts {
id Int [pk, increment]
type alertType [not null]
message String
actionplan actionPlan
actionplanId Int
date DateTime [not null]
state alertState [not null]
acknowledged_by alertContacts [not null]
acknowledged_at DateTime
}
Table alertContacts {
id Int [pk, increment]
name String [not null]
phone String [unique, not null]
comment String
prios priorities [not null]
alerts alerts [not null]
}
Table actionPlan {
id Int [pk, increment]
name String [not null]
comment String
alert_hook String [unique, not null]
prio priorities [not null]
content content [not null]
alerts alerts [not null]
}
Table priorities {
id Int [pk, increment]
Contact alertContacts [not null]
contactId Int [not null]
priority Int [not null]
actionplan actionPlan [not null]
actionplanId Int [not null]
indexes {
(priority, actionplanId) [unique]
}
}
Table content {
id Int [pk, increment]
type contentType [not null]
name String [not null]
filename String [not null]
actionplan actionPlan [not null]
}
Table alertContactsToalerts {
acknowledged_byId Int [ref: > alertContacts.id]
alertsId Int [ref: > alerts.id]
}
Table actionPlanTocontent {
contentId Int [ref: > content.id]
actionplanId Int [ref: > actionPlan.id]
}
Enum contentType {
voice_alarm
voice_explainer
voice_acknowledgement
voice_ending
}
Enum alertType {
generic
fire
fault
intrusion
clear
}
Enum alertState {
incomming
running
failed
acknowledged
}
Ref: alerts.actionplanId > actionPlan.id
Ref: priorities.contactId > alertContacts.id
Ref: priorities.actionplanId > actionPlan.id

File diff suppressed because one or more lines are too long

3759
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@
"@types/express": "^5.0.0",
"@types/express-fileupload": "^1.5.1",
"@types/express-session": "^1.18.1",
"@types/joi": "^17.2.2",
"@types/lodash": "^4.17.14",
"@types/node": "^22.10.5",
"@types/passport": "^1.0.17",
@ -32,8 +33,6 @@
"eslint": "^9.18.0",
"eslint-config-prettier": "^9.1.0",
"prisma": "^6.2.1",
"prisma-dbml-generator": "^0.12.0",
"prisma-docs-generator": "^0.8.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
},
@ -45,8 +44,11 @@
"express": "^4.21.2",
"express-fileupload": "^1.5.1",
"express-session": "^1.18.1",
"helmet": "^8.0.0",
"joi": "^17.13.3",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"minio": "^8.0.4",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"signale": "^1.4.0",

View File

@ -13,23 +13,6 @@ datasource db {
url = env("DATABASE_URL")
}
// https://github.com/pantharshit00/prisma-docs-generator
generator docs {
provider = "node node_modules/prisma-docs-generator"
output = "../docs"
}
// https://github.com/notiz-dev/prisma-dbml-generator
// Viewer: https://dbdiagram.io/d
generator dbml {
provider = "prisma-dbml-generator"
output = "../docs"
outputName = "schema.dbml"
projectName = "AssetFlow"
}
enum contentType {
voice_alarm
voice_explainer
@ -46,71 +29,74 @@ enum alertType {
}
enum alertState {
incomming // Incomming alerts
running // Started calling
failed // Failed to get acknowledgement of any alertContacts
acknowledged // Some user acknowledged alert
incoming // Incoming alerts
running // Started calling
failed // Failed to get acknowledgement of any alertContacts
acknowledged // Some user acknowledged alert
}
model alerts {
id Int @id @unique @default(autoincrement())
type alertType
message String?
actionplan actionPlan? @relation(fields: [actionplanId], references: [id])
actionplanId Int?
date DateTime
id Int @id @unique @default(autoincrement())
type alertType
state alertState
description String?
date DateTime
actionplan actionPlan? @relation(fields: [actionplanId], references: [id])
actionplanId Int?
acknowledged_by alertContacts[]
acknowledged_at DateTime?
@@fulltext([message])
@@fulltext([description])
}
model alertContacts {
id Int @id @unique @default(autoincrement())
id Int @id @unique @default(autoincrement())
name String
phone String @unique
phone String @unique
comment String?
prios priorities[]
alerts alerts[]
alerts alerts[]
@@fulltext([name, phone, comment])
}
model actionPlan {
id Int @id @unique @default(autoincrement())
name String
comment String?
alert_hook String @unique
prio priorities[]
content content[] // aka. all voice files
id Int @id @unique @default(autoincrement())
name String @unique
comment String?
alerthook String @unique @default(ulid())
prio priorities[]
content content[] // aka. all voice files
alerts alerts[]
@@fulltext([name, comment])
}
model priorities {
id Int @id @unique @default(autoincrement())
Contact alertContacts @relation(fields: [contactId], references: [id])
contactId Int
priority Int
actionplan actionPlan @relation(fields: [actionplanId], references: [id])
actionplanId Int
id Int @id @unique @default(autoincrement())
Contact alertContacts @relation(fields: [contactId], references: [id])
contactId Int
priority Int
actionplan actionPlan @relation(fields: [actionplanId], references: [id])
actionplanId Int
@@unique([priority, actionplanId])
}
model content {
id Int @id @unique @default(autoincrement())
type contentType
name String
filename String
actionplan actionPlan[]
@@fulltext([name, filename])
//id Int @id @unique @default(autoincrement())
s3_key String @id @unique
name String @unique
type contentType
actionplan actionPlan[]
@@fulltext([name])
}
// https://spacecdn.de/file/bma_stoe_v1.mp3
// https://spacecdn.de/file/quittiert_v1.mp3
// https://spacecdn.de/file/angenehmen_tag_v1.mp3

View File

@ -1,14 +1,23 @@
import ConfigManager from '../libs/configManager.js';
import __path from "./path.js";
import __path from './path.js';
import _ from 'lodash';
import log from './log.js';
// Create a new config instance.
const config = new ConfigManager(__path + '/config.json', true, {
db_connection_string: 'mysql://USER:PASSWORD@HOST:3306/DATABASE',
http_listen_address: '0.0.0.0',
http_port: 3000,
debug: true,
http_domain: 'example.org',
http_enable_hsts: false,
devmode: true,
s3: {
endpoint: 'minio.example.org',
port: 443,
use_ssl: true,
access_key: '',
secret_key: ''
},
auth: {
cookie_secret: 'gen',
cookie_secure: true,
@ -25,9 +34,11 @@ const config = new ConfigManager(__path + '/config.json', true, {
// If no local User exists, create the default with a generated password
if (_.isEqual(config.global.auth.local.users, {})) {
config.global.auth.local.users = {
'administrator': 'gen',
administrator: 'gen'
};
config.save_config();
}
!config.global.devmode && log.core.error('devmode active! Do NOT use this in prod!');
export default config;

View File

@ -12,75 +12,29 @@ const prisma = new PrismaClient({
}
});
// FIXME: any
export function handlePrismaError(errorObj: any, res: Response) {
export function handlePrismaError(errorObj: any, res: Response, source: string) {
log.db.error(source, errorObj);
if (errorObj instanceof Prisma.PrismaClientKnownRequestError) {
switch (errorObj.code) {
log.core.debug(errorObj);
res.status(500).json({ status: 'ERROR', meta: errorObj.meta, errorcode: errorObj.code, message: errorObj.message });
// P2002 -> "Unique constraint failed on the {constraint}"
case 'P2002':
res.status(409).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'The object needs to be unique', meta: errorObj.meta });
break;
// P2003 -> "Foreign key constraint failed on the field: {field_name}"
case 'P2003':
res.status(404).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'Relation object does not exist', meta: errorObj.meta });
break;
// if(errorObj instanceof Prisma.PrismaClientKnownRequestError)
// switch (errorObj.code) {
// // P2002 -> "Unique constraint failed on the {constraint}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// case 'P2002': //
// log.db.error('');
// break;
// // P2003 -> "Foreign key constraint failed on the field: {field_name}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// // FIXME: Is this errormessage right?
// case 'P2003': //
// log.db.error('');
// break;
// case 'xxx': //
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// case 'xxx':
// log.db.error('');
// break;
// default:
// break;
// }
// // Check if an entry already exists.
// if (errorcode === 'P2002') {
// // P2002 -> "Unique constraint failed on the {constraint}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// res.status(409).json({ status: 'ERROR', errorcode: 'EXISTING', message: 'Item already exists' });
// } else if (errorcode == 'P2003') {
// // P2003 -> "Foreign key constraint failed on the field: {field_name}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// // FIXME: Is this errormessage right?
// res.status(404).json({ status: 'ERROR', errorcode: 'NOT_EXISTING', message: 'Item does not exist' });
// } else if (errorcode == 'P2000') {
// // P2000 -> "The provided value for the column is too long for the column's type. Column: {column_name}"
// // https://www.prisma.io/docs/reference/api-reference/error-reference
// res.status(404).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: 'One or more fields exceed the maximum length restriction' });
// } else {
// log.db.error(err);
// res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', error: err, message: 'An error occurred during the database operation' });
// }
default:
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'An error occurred during the database operation' });
break;
}
} else {
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'If you can read this something went terribly wrong!' });
}
}
export default prisma;

View File

@ -14,6 +14,7 @@ type log = {
core: Logger<unknown>
db: Logger<unknown>
web: Logger<unknown>
S3: Logger<unknown>
auth: Logger<unknown>
api?: Logger<unknown>
frontend?: Logger<unknown>
@ -24,6 +25,7 @@ let log: log = {
core: new Logger(loggerConfig("Core")),
db: new Logger(loggerConfig("DB")),
web: new Logger(loggerConfig("Web")),
S3: new Logger(loggerConfig("S3")),
auth: new Logger(loggerConfig("Auth")),
// helper: new Logger(loggerConfig("HELPER")),
};

19
src/handlers/s3.ts Normal file
View File

@ -0,0 +1,19 @@
import * as Minio from 'minio';
import log from './log.js';
import config from './config.js';
const minioClient = new Minio.Client({
endPoint: config.global.s3.endpoint,
port: config.global.s3.port,
useSSL: config.global.s3.use_ssl,
accessKey: config.global.s3.access_key,
secretKey: config.global.s3.secret_key
});
export async function test() {
log.S3.debug('GET', await minioClient.presignedGetObject('atas-dev', 'test', 500));
log.S3.debug('PUT', await minioClient.presignedPutObject('atas-dev', 'test', 500));
}
export default minioClient;

View File

@ -0,0 +1,22 @@
/**
* A function to create a sortBy compatible object from a string
*
* @export
* @param {string} SortField
* @param {string} Order
* @returns {object}
*/
export function parseDynamicSortBy(SortField: string, Order: string) {
return JSON.parse(`{ "${SortField}": "${Order}" }`);
}
/**
* Function to parse a string into a number or return undefined if it is not a number
* Deprecated since all empty strings in bodys are now undefined. This happens in api/v1 router
* @export
* @param {string || any} data
* @returns {object}
*/
export function parseIntOrUndefined(data: any) {
return isNaN(parseInt(data)) ? undefined : parseInt(data);
}

View File

@ -1,17 +1,18 @@
// MARK: Imports
import path from 'node:path';
import __path from "./handlers/path.js";
import log from "./handlers/log.js";
import db from "./handlers/db.js";
import __path from './handlers/path.js';
import log from './handlers/log.js';
import db from './handlers/db.js';
import config from './handlers/config.js';
// Express & more
import express from 'express';
import cors from 'cors'
import cors from 'cors';
import helmet from 'helmet';
import session from 'express-session';
import fileUpload from 'express-fileupload';
import bodyParser, { Options } from 'body-parser';
import { Eta } from "eta";
import { Eta } from 'eta';
import passport from 'passport';
import ChildProcess from 'child_process';
@ -20,29 +21,26 @@ import routes from './routes/index.js';
import fs from 'node:fs';
log.core.trace("Running from path: " + __path);
db.$disconnect();
log.core.trace('Running from path: ' + __path);
// MARK: Express
const app = express();
// Versioning
try {
const rawPkg = fs.readFileSync("package.json", 'utf8');
const rawPkg = fs.readFileSync('package.json', 'utf8');
const pkgJson = JSON.parse(rawPkg);
app.locals.version = pkgJson.version;
} catch (error) {
log.core.error("Failed to get version from package.json.");
app.locals.version = "0.0.0";
log.core.error('Failed to get version from package.json.');
app.locals.version = '0.0.0';
}
try {
try {
app.locals.versionRevLong = ChildProcess.execSync('git rev-parse HEAD').toString().trim();
app.locals.versionRev = app.locals.versionRevLong.substring(0, 7);
} catch (error) {
log.core.error("Failed to get git revision hash.");
log.core.error('Failed to get git revision hash.');
app.locals.versionRev = '0';
app.locals.versionRevLong = '0';
}
@ -50,7 +48,7 @@ try {
try {
app.locals.versionRevLatest = ChildProcess.execSync('git ls-remote --refs -q').toString().trim().split('\t')[0];
} catch (error) {
log.core.error("Failed to get latest git revision hash.");
log.core.error('Failed to get latest git revision hash.');
app.locals.versionRevLatest = '0';
}
@ -62,19 +60,31 @@ if (app.locals.versionRevLong === app.locals.versionRevLatest) {
app.locals.versionUpdateAvailable = true;
}
// ETA Init
const eta = new Eta({ views: path.join(__path, "views") })
app.engine("eta", buildEtaEngine())
app.set("view engine", "eta")
const eta = new Eta({ views: path.join(__path, 'views') });
app.engine('eta', buildEtaEngine());
app.set('view engine', 'eta');
// MARK: Express Middleware & Config
app.set('x-powered-by', false);
app.set('x-powered-by', false); // helmet does this too. But not in devmode
if (!config.global.devmode) {
app.use(
helmet({
strictTransportSecurity: config.global.http_enable_hsts,
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", config.global.http_domain],
objectSrc: ["'none'"],
upgradeInsecureRequests: config.global.devmode ? null : []
}
}
})
); // Add headers
}
app.use(fileUpload());
//app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
@ -91,16 +101,13 @@ app.use(
app.use(passport.authenticate('session'));
app.use(routes);
app.listen(config.global.http_port, config.global.http_listen_address, () => {
log.web.info(`Listening at http://${config.global.http_listen_address}:${config.global.http_port}`);
});
// MARK: Helper Functions
function buildEtaEngine() {
return (path:string, opts:Options, callback: CallableFunction) => {
return (path: string, opts: Options, callback: CallableFunction) => {
try {
const fileContent = eta.readFile(path);
const renderedTemplate = eta.renderString(fileContent, opts);
@ -110,4 +117,3 @@ function buildEtaEngine() {
}
};
}

View File

@ -1,116 +1,164 @@
import { Request, Response } from 'express';
import db, { handlePrismaError } from '../../../handlers/db.js'; // Database
import log from '../../../handlers/log.js';
import { parseDynamicSortBy } from '../../../helpers/prisma_helpers.js';
import { schema_get, schema_post, schema_patch, schema_del } from './alertContacts_schema.js';
///api/v1/alertContacts?action=count&filter=...
// GET without args -> Get all alertContacts
/**
* A function to create a sortBy compatible object from a string
*
* @export
* @param {string} SortField
* @param {string} Order
* @returns {object}
*/
export function parseDynamicSortBy(SortField: string, Order: string) {
return JSON.parse(`{ "${SortField}": "${Order}" }`);
}
/**
* Function to parse a string into a number or return undefined if it is not a number
*
* @export
* @param {string || any} data
* @returns {object}
*/
export function parseIntOrUndefined(data: any) {
return isNaN(parseInt(data)) ? undefined : parseInt(data);
}
// GET AlertContact
// MARK: GET alertContact
async function get(req: Request, res: Response) {
// Set sane defaults if undefined for sort
if (req.query.sort === undefined) {
req.query.sort = 'id';
}
if (req.query.order === undefined) {
req.query.order = 'asc';
}
// Prio 1 -> Get count (with or without filter)
// Prio 2 -> Get by id
// Prio 3 -> Get with filter
if ((req.query.search !== undefined && req.query.search.length > 0) || (req.query.id !== undefined && req.query.id.length > 0)) {
if (req.query.search !== undefined && req.query.search === '*') {
log.db.debug('Single * does not work with FullTextSearch');
req.query.search = '';
}
// When an ID is set, remove(disable) the search query
if (req.query.id !== undefined && req.query.id.length > 0) {
req.query.search = undefined;
}
const query = {
where: {
OR: [{ id: parseIntOrUndefined(req.query.id) }, { name: { search: req.query.search } }, { phone: { search: req.query.search } }, { comment: { search: req.query.search } }]
},
orderBy: parseDynamicSortBy(req.query.sort.toString(), req.query.order.toString())
};
if (req.query.count === undefined) {
// get all entrys
await db.alertContacts.findMany(query).then((result) => {
res.status(200).json(result);
});
} else {
// count all entrys (filtered or not)
await db.alertContacts.count(query).then((result) => {
res.status(200).json(result);
});
}
const { error, value } = schema_get.validate(req.query);
if (error) {
log.api?.debug('alertContact GET Error:', req.query, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
if (req.query.count === undefined) {
await db.alertContacts.findMany({ orderBy: parseDynamicSortBy(req.query.sort.toString(), req.query.order.toString()) }).then((result) => {
res.status(200).json(result);
});
} else {
await db.alertContacts.count().then((result) => {
res.status(200).json(result);
});
}
}
}
log.api?.debug('alertContact GET Success:', req.query, value);
// CREATE AlertContact
async function post(req: Request, res: Response) {
// Check if undefined or null
if (req.body.name != null && req.body.phone != null) {
await db.alertContacts
.create({
data: {
name: req.body.name,
phone: req.body.phone,
comment: req.body.comment,
},
select: {
id: true
// Query with FullTextSearch
if (value.search !== undefined || value.id !== undefined) {
// with FullTextSearch
if (!value.count) {
// get all entrys
log.api?.trace('get all entrys - with FullTextSearch');
await db.alertContacts
.findMany({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
.then((result) => {
if (result.length !== 0) {
res.status(200).json(result);
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
} else {
// count all entrys
log.api?.trace('count all entrys - with FullTextSearch');
await db.alertContacts
.count({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString())
})
.then((result) => {
res.status(200).json(result);
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
}
}).then((result) => {
res.status(201).json({ status: 'CREATED', message: 'Successfully created alertContact', id: result.id });
})
} else {
res.status(400).json({ status: 'ERROR', errorcode: "VALIDATION_ERROR", message: 'One or more required fields are missing or invalid' });
} else {
// without FullTextSearch
if (!value.count) {
// get all entrys
log.api?.trace('get all entrys - without FullTextSearch');
await db.alertContacts
.findMany({ orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()), take: value.take, skip: value.skip })
.then((result) => {
if (result.length !== 0) {
res.status(200).json(result);
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
} else {
// count all entrys without FullTextSearch
log.api?.trace('count all entrys - without FullTextSearch');
await db.alertContacts.count().then((result) => {
res.status(200).json(result);
}).catch((err) => {
handlePrismaError(err, res, 'GET alertContact');
});
}
}
}
}
// UPDATE AlertContact
async function patch(req: Request, res: Response) {}
// MARK: CREATE alertContact
async function post(req: Request, res: Response) {
const { error, value } = schema_post.validate(req.body);
if (error) {
log.api?.debug('alertContact POST Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('alertContact POST Success:', req.body, value);
await db.alertContacts
.create({
data: {
name: value.name,
phone: value.phone,
comment: value.comment
},
select: {
id: true
}
})
.then((result) => {
res.status(201).json({ status: 'CREATED', message: 'Successfully created alertContact', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'POST alertContact');
});
}
}
// DELETE AlertContact
async function del(req: Request, res: Response) {}
// MARK: UPDATE alertContact
async function patch(req: Request, res: Response) {
const { error, value } = schema_patch.validate(req.body);
if (error) {
log.api?.debug('alertContact PATCH Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('alertContact PATCH Success:', req.body, value);
await db.alertContacts
.update({
where: {
id: value.id
},
data: {
name: value.name,
phone: value.phone,
comment: value.comment
},
select: {
id: true
}
})
.then((result) => {
res.status(200).json({ status: 'UPDATED', message: 'Successfully updated alertContact', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'PATCH alertContact');
});
}
}
// MARK: DELETE alertContact
async function del(req: Request, res: Response) {
const { error, value } = schema_del.validate(req.body);
if (error) {
log.api?.debug('alertContact DELETE Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('alertContact DELETE Success:', req.body, value);
await db.alertContacts
.delete({
where: {
id: value.id
}
})
.then((result) => {
res.status(200).json({ status: 'DELETED', message: 'Successfully deleted alertContact', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'DEL alertContact');
});
}
}
export default { get, post, patch, del };

View File

@ -0,0 +1,55 @@
import { Request, Response } from 'express';
import validator from 'joi'; // DOCS: https://joi.dev/api
import { Prisma } from '@prisma/client';
// MARK: GET alertContact
const schema_get = validator.object({
//sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
sort: validator.string().valid(...Object.keys(Prisma.AlertContactsScalarFieldEnum)).default('id'),
order: validator.string().valid('asc', 'desc').default('asc'),
take: validator.number().min(1).max(512),
skip: validator.number().min(0),
// This regex ensures that the search string does not contain consecutive asterisks (**) and is at least 3 characters long.
search: validator.string().min(3).max(20).regex(new RegExp('^(?!.*\\*{2,}).*$')),
id: validator.number().positive().precision(0),
count: validator.boolean()
}).nand('id', 'search'); // Allow id or search. not both.
// MARK: CREATE alertContact
const schema_post = validator.object({
name: validator.string().min(1).max(32).required(),
phone: validator.string().pattern(new RegExp('^\\+(\\d{1,3})\\s*(?:\\(\\s*(\\d{2,5})\\s*\\)|\\s*(\\d{2,5})\\s*)\\s*(\\d{5,15})$')).required(),
comment: validator.string().max(64),
})
// MARK: UPDATE alertContact
const schema_patch = validator.object({
id: validator.number().positive().precision(0).required(),
name: validator.string().min(1).max(32),
phone: validator.string().pattern(new RegExp('^\\+(\\d{1,3})\\s*(?:\\(\\s*(\\d{2,5})\\s*\\)|\\s*(\\d{2,5})\\s*)\\s*(\\d{5,15})$')),
comment: validator.string().max(64),
}).or('name', 'phone', 'comment')
// MARK: DELETE alertContact
const schema_del = validator.object({
id: validator.number().positive().precision(0).required()
})
// Describe all schemas
const schema_get_desc = schema_get.describe();
const schema_post_desc = schema_post.describe();
const schema_patch_desc = schema_patch.describe();
const schema_del_desc = schema_del.describe();
// GET route
export default async function get(req: Request, res: Response) {
res.status(200).json({
GET: schema_get_desc,
POST: schema_post_desc,
PATCH: schema_patch_desc,
DELETE: schema_del_desc
});
}
export { schema_get, schema_post, schema_patch, schema_del }

View File

@ -3,35 +3,29 @@ import passport from 'passport';
// Route imports
import testRoute from './test.js';
import alertContactsRoute from './alertContacts.js';
//import categoryRoute from './categories.js';
//import storageUnitRoute from './storageUnits.js';
//import storageLocationRoute from './storageLocations.js';
//import contactInfo from './contactInfo.js';
import versionRoute from './version.js'
//import search_routes from './search/index.js';
import alertContactsRoute from './alertContacts.js';
import alertContactsRoute_schema from './alertContacts_schema.js';
// Router base is '/api/v1'
const Router = express.Router({ strict: false });
// All empty strings are null values.
// All empty strings are null values (body)
Router.use('*', function (req, res, next) {
for (let key in req.body) {
if (req.body[key] === '') {
req.body[key] = null;
req.body[key] = undefined;
}
}
next();
});
// All api routes lowercase! Yea I know but when strict: true it matters.
Router.route('/alertcontacts').get(alertContactsRoute.get).post(alertContactsRoute.post).patch(alertContactsRoute.patch).delete(alertContactsRoute.del);
//Router.route('/categories').get(categoryRoute.get).post(categoryRoute.post).patch(categoryRoute.patch).delete(categoryRoute.del);
// TODO: Migrate routes to lowercase.
//Router.route('/storageUnits').get(storageUnitRoute.get).post(storageUnitRoute.post).patch(storageUnitRoute.patch).delete(storageUnitRoute.del);
//Router.route('/storageLocations').get(storageLocationRoute.get).post(storageLocationRoute.post).patch(storageLocationRoute.patch).delete(storageLocationRoute.del);
//Router.route('/contactInfo').get(contactInfo.get).post(contactInfo.post).patch(contactInfo.patch).delete(contactInfo.del);
Router.route('/alertcontacts/describe').get(alertContactsRoute_schema);
Router.route('/version').get(versionRoute.get);
//Router.use('/search', search_routes);

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("contacts")
}
export default { get };

View File

@ -1,11 +1,9 @@
import express from 'express';
// Route imports
// import skuRoute from './:id.js';
// import skuRouteDash from './itemInfo.js'
// import testRoute from './test.js';
import dashboardRoute from './dashboard.js';
import testRoute from './test.js';
import contactRoute from './contact.js';
// import itemsRoute from './items.js';
// import manage_routes from './manage/index.js';
@ -22,5 +20,6 @@ const Router = express.Router({ strict: false });
Router.route('/').get(dashboardRoute.get);
Router.route('/dbTest').get(testRoute.get);
Router.route('/contact').get(contactRoute.get);
export default Router;

View File

@ -1,109 +1,237 @@
_wrapperVersion = "1.0.0"
_minApiVersion = "1.0.0"
_maxApiVersion = "1.0.0"
_wrapperVersion = '1.0.0';
_minApiVersion = '1.0.0';
_maxApiVersion = '1.0.0';
_defaultTTL = 60000;
_apiConfig = {
"basePath": "/api/v1/"
basePath: '/api/v1/'
};
if (!window.localStorage) {
console.warn('Local Storage is not available, some features may not work');
}
// Generic driver functions
let _api = {
"get": async function(path) {
get: async function (path) {
const options = {
headers: new Headers({'content-type': 'application/json'})
};
const response = await fetch(_apiConfig.basePath + path, options)
headers: new Headers({ 'content-type': 'application/json' })
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText)
return
console.error('Failed to fetch:', response.statusText);
_testPageFail(response.statusText);
return;
}
const result = await response.json()
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
_testPageFail("Invalid JSON response")
return
}
return result;
},
"post": async function(path, data) {
const options = {
method: 'POST',
headers: new Headers({'content-type': 'application/json'}),
body: JSON.stringify(data)
};
const response = await fetch(_apiConfig.basePath + path, options)
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText)
return
}
const result = await response.json()
// Handle the result, was json valid?
if (!result) {
_testPageFail("Invalid JSON response")
return
// Is it a number instead?
if (typeof result === 'number') {
return result;
}
console.error('Invalid JSON response');
_testPageFail('Invalid JSON response');
return;
}
return result;
},
"getAsync": function(path, callback) {
post: async function (path, data) {
const options = {
headers: new Headers({'content-type': 'application/json'})
};
fetch(_apiConfig.basePath + path, options).then(response => response.json()).then(data => callback(data)).catch(error => _testPageFail(error))
method: 'POST',
headers: new Headers({ 'content-type': 'application/json' }),
body: JSON.stringify(data)
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText);
return;
}
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
_testPageFail('Invalid JSON response');
return;
}
return result;
},
delete: async function (path, data) {
const options = {
method: 'DELETE',
headers: new Headers({ 'content-type': 'application/json' }),
body: JSON.stringify(data)
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText);
return;
}
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
_testPageFail('Invalid JSON response');
return;
}
return result;
},
patch: async function (path, data) {
const options = {
method: 'PATCH',
headers: new Headers({ 'content-type': 'application/json' }),
body: JSON.stringify(data)
};
const response = await fetch(_apiConfig.basePath + path, options);
// Handle the response
if (!response.ok) {
_testPageFail(response.statusText);
return;
}
const result = await response.json();
// Handle the result, was json valid?
if (!result) {
_testPageFail('Invalid JSON response');
return;
}
return result;
}
};
function updateRow(tableName, id, data) {
invalidateCache(tableName);
return _api.patch(`${tableName}`, { id: id, ...data });
}
function deleteRow(tableName, id) {
invalidateCache(tableName);
return _api.delete(`${tableName}`, { id: id });
}
function getApiDescriptionByTable(tableName) {
const keyDesc = `desc:${tableName}`;
const keyTime = `${keyDesc}:time`;
const keyTTL = `${keyDesc}:ttl`;
// Retrieve cached data
const description = JSON.parse(localStorage.getItem(keyDesc));
const timeCreated = parseInt(localStorage.getItem(keyTime));
const ttl = parseInt(localStorage.getItem(keyTTL));
// Check if valid cached data exists
if (description && timeCreated && ttl) {
const currentTime = Date.now();
const age = currentTime - parseInt(timeCreated, 10);
if (age < parseInt(ttl, 10)) {
// Return cached data immediately
return Promise.resolve(description);
} else {
console.warn('Cached description expired; fetching new data');
// Fetch new data, update cache, and return it
return fetchAndUpdateCache(tableName);
}
} else {
console.warn('No cached description; fetching from server');
// Fetch data, update cache, and return it
return fetchAndUpdateCache(tableName);
}
function fetchAndUpdateCache(tableName) {
return _api
.get(`${tableName}/describe`)
.then((data) => {
if (data) {
// Update local storage with new data
localStorage.setItem(keyDesc, JSON.stringify(data));
localStorage.setItem(keyTime, Date.now().toString());
localStorage.setItem(keyTTL, '60000'); // 60 seconds TTL
}
return data; // Return the fetched data
})
.catch((error) => {
console.error('Failed to fetch description:', error);
// Fallback to cached data if available (even if expired)
return description || null;
});
}
}
function returnTableDataByTableName(tableName) {
return _api.get(tableName)
}
function returnTableDataByTableNameWithSearch(tableName, search) {
return _api.get(tableName + "?search=" + search)
}
function returnTableDataByTableNameAsync(tableName, callback) {
_api.getAsync(tableName, callback)
}
async function getCountByTable(tableName) {
let result = await(_api.get(tableName + "?count"))
if(typeof result !== 'number') {
_testPageWarn("Count was not a number, was: " + result)
console.warn("Count was not a number, was: " + result)
return -1
function returnTableDataByTableName(tableName, search="", orderBy="asc", sort="", take=-1, skip=0) {
var orderBy = orderBy.toLowerCase();
if(orderBy == "") {
orderBy = "asc";
}
var baseString = tableName + "?order=" + orderBy;
if(sort && sort.length > 0) {
baseString += "&sort=" + sort;
}
if(take > 0) {
baseString += "&take=" + take;
}
if(skip > 0) {
baseString += "&skip=" + skip;
}
if (search && search.length > 0) {
return _api.get(baseString + '&search=' + search);
} else {
return _api.get(baseString);
}
return result
}
function getRowsByTableAndColumnList(tableName, columnList) {
//return _api.get(tableName + '/rows/' + columnList.join(','))
return undefined
async function getCountByTable(tableName, search="") {
let baseString = tableName + '?count=true';
if (search && search.length > 0) {
baseString += '&search=' + search;
}
// Stored in `data:count:${tableName}`
let result = await _api.get(baseString);
console.debug('Count result:', result);
if (typeof result !== 'number') {
_testPageWarn('Count was not a number, was: ' + result);
console.warn('Count was not a number, was: ' + result);
return -1;
}
return result;
}
function _testPageFail(reason) {
document.getElementById("heroStatus").classList.remove("is-success")
document.getElementById("heroStatus").classList.add("is-danger")
document.getElementById('heroStatus').classList.remove('is-success');
document.getElementById('heroStatus').classList.add('is-danger');
document.getElementById("heroExplainer").innerHTML = "API Wrapper Test Failed, reason: " + reason
document.getElementById('heroExplainer').innerHTML = 'API Wrapper Test Failed, reason: ' + reason;
}
function _testPageWarn(reason) {
document.getElementById("heroStatus").classList.remove("is-success")
document.getElementById("heroStatus").classList.add("is-warning")
document.getElementById('heroStatus').classList.remove('is-success');
document.getElementById('heroStatus').classList.add('is-warning');
document.getElementById("heroExplainer").innerHTML = "API Wrapper Test Warning, reason: " + reason
document.getElementById('heroExplainer').innerHTML = 'API Wrapper Test Warning, reason: ' + reason;
}
function getServerVersion() {
return _api.get('version')
return _api.get('version');
}
function createEntry(tableName, data) {
return _api.post(tableName, data)
invalidateCache(tableName);
return _api.post(tableName, data);
}
function invalidateCache(tableName) {
const keyDesc = `desc:${tableName}`;
const keyTime = `${keyDesc}:time`;
const keyTTL = `${keyDesc}:ttl`;
localStorage.removeItem(keyDesc);
localStorage.removeItem(keyTime);
localStorage.removeItem(keyTTL);
}

View File

@ -1,184 +1,258 @@
_pageDriverVersion = "1.0.1";
_pageDriverVersion = '1.0.1';
// Handle color for icon svg with id="logo" based on the current theme
const logo = document.getElementById("logo");
if(logo) {
logo.style.fill = getComputedStyle(document.documentElement).getPropertyValue("--bulma-text");
const logo = document.getElementById('logo');
if (logo) {
logo.style.fill = getComputedStyle(document.documentElement).getPropertyValue('--bulma-text');
}
if(_wrapperVersion === undefined) {
console.error("API Wrapper not found; Please include the API Wrapper before including the Page Driver");
if (_wrapperVersion === undefined) {
console.error('API Wrapper not found; Please include the API Wrapper before including the Page Driver');
exit();
} else {
console.log("API Wrapper found; Page Driver is ready to use");
console.log('API Wrapper found; Page Driver is ready to use');
}
// Find all tables on the page which have data-dataSource attribute
var tables = document.querySelectorAll("table[data-dataSource]");
var tables = document.querySelectorAll('table[data-dataSource]');
//var tables = []
// Get all single values with data-dataSource, data-dataCol and data-dataAction
var singleValues = document.querySelectorAll("span[data-dataSource]");
// Get all single values with data-dataSource, data-dataCol and data-dataAction
var singleValues = document.querySelectorAll('span[data-dataSource]');
// Find all search fields with data-searchTargetId
var searchFields = document.querySelectorAll("input[data-searchTargetId]");
var searchFields = document.querySelectorAll('input[data-searchTargetId]');
// Find all modalForms
var modalForms = document.querySelectorAll("form[data-targetTable]");
var modalForms = document.querySelectorAll('form[data-targetTable]');
// Iterate over all tables
tables.forEach(async table => {
console.log("Table found: ", table);
// Get THEAD and TBODY elements
const thead = table.querySelector("thead");
const tbody = table.querySelector("tbody");
// get index per column
const columns = thead.querySelectorAll("th");
const columnIndices = [];
columns.forEach((column, index) => {
columnIndices[column.getAttribute("data-dataCol")] = index;
});
// All required cols
let requiredCols = [];
columns.forEach(column => {
requiredCols.push(column.getAttribute("data-dataCol"));
});
console.log("Required columns: ", requiredCols);
// Get data from API
//let result = getRowsByTableAndColumnList(table.getAttribute("data-dataSource"), requiredCols);
let result = await returnTableDataByTableName(table.getAttribute("data-dataSource"))
// for (resultIndex in result) {
// const row = result[resultIndex];
// const tr = document.createElement("tr");
// requiredCols.forEach(column => {
// const td = document.createElement("td");
// td.innerHTML = row[column];
// tr.appendChild(td);
// });
// tbody.appendChild(tr);
// }
writeDataToTable(table, result);
console.log("Column indices: ", columnIndices);
});
console.info("Processing single values");
console.info('Processing single values');
console.info(singleValues);
// Iterate over all single values
singleValues.forEach(async singleValue => {
singleValues.forEach(async (singleValue) => {
writeSingelton(singleValue);
});
// Iterate over all tables
tables.forEach(async (table) => {
// Get THs and attach onClick event to sort
const ths = table.querySelectorAll('th');
ths.forEach((th) => {
if(th.getAttribute('fnc') == "actions") {
return;
}
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
th.addEventListener('click', async function () {
const table = th.closest('table');
const order = th.getAttribute('data-order');
// Clear all other order attributes
ths.forEach((th) => {
if (th != this) {
th.removeAttribute('data-order');
th.classList.remove("bi-caret-up-fill")
th.classList.remove("bi-caret-down-fill")
}
});
if (order == 'ASC') {
th.setAttribute('data-order', 'DESC');
th.classList.add("bi-caret-down-fill")
th.classList.remove("bi-caret-up-fill")
} else {
th.setAttribute('data-order', 'ASC');
th.classList.add("bi-caret-up-fill")
th.classList.remove("bi-caret-down-fill")
}
refreshTable(table);
});
});
refreshTable(table);
});
async function writeSingelton(element) {
const table = element.getAttribute("data-dataSource");
console.log("Table: ", table, " Action: ", element.getAttribute("data-dataAction"), " Element: ", element);
switch(element.getAttribute("data-dataAction")) {
case "COUNT": {
console.log("Count action found");
element.innerHTML = (await getCountByTable(table))
const table = element.getAttribute('data-dataSource');
console.log('Table: ', table, ' Action: ', element.getAttribute('data-dataAction'), ' Element: ', element);
switch (element.getAttribute('data-dataAction')) {
case 'COUNT': {
console.log('Count action found');
element.innerHTML = await getCountByTable(table);
break;
}
case "SPECIAL": {
if(table == "version") {
element.innerHTML = (await getServerVersion())["version"];
break
case 'SPECIAL': {
if (table == 'version') {
element.innerHTML = (await getServerVersion())['version'];
break;
}
}
default: {
console.error("Unknown action found: ", element.getAttribute("data-dataAction"));
console.error('Unknown action found: ', element.getAttribute('data-dataAction'));
break;
}
}
element.classList.remove("is-skeleton");
element.classList.remove('is-skeleton');
}
// Attach listeners to search fields
searchFields.forEach(searchField => {
searchField.addEventListener("input", async function() {
console.log("Search field changed: ", searchField);
const targetId = searchField.getAttribute("data-searchTargetId");
const target = document.getElementById(targetId);
const table = target.getAttribute("data-dataSource");
const column = target.getAttribute("data-dataCol");
const value = searchField.value;
console.log("Searching for ", value, " in ", table, " column ", column);
const result = await returnTableDataByTableNameWithSearch(table, value);
console.log("Result: ", result);
clearTable(target);
writeDataToTable(target, result);
searchFields.forEach((searchField) => {
// Apply restrictions to search field (min, max, chars, etc)
getApiDescriptionByTable(document.getElementById(searchField.getAttribute('data-searchTargetId')).getAttribute('data-dataSource')).then((desc) => {
desc = desc['GET']['keys']['search'];
var rules = desc['rules'];
rules.forEach((rule) => {
switch (rule['name']) {
case 'min': {
searchField.setAttribute('minlength', rule['args']['limit']);
break;
}
case 'max': {
searchField.setAttribute('maxlength', rule['args']['limit']);
break;
}
}
});
});
searchField.addEventListener('input', async function () {
console.log('Search field changed: ', searchField);
if (searchField.checkValidity() == false) {
console.log('Invalid input');
searchField.classList.add('is-danger');
return;
} else {
searchField.classList.remove('is-danger');
const targetId = searchField.getAttribute('data-searchTargetId');
const target = document.getElementById(targetId);
const table = target.getAttribute('data-dataSource');
const column = target.getAttribute('data-dataCol');
const value = searchField.value;
console.log('Searching for ', value, ' in ', table, ' column ', column);
refreshTableByName(table);
}
});
});
// Attach listeners to modal forms
modalForms.forEach(modalForm => {
modalForm.addEventListener("submit", async function(event) {
modalForms.forEach((modalForm) => {
// Add validation to form by using API description (everything is assumed POST for now)
modalForm.addEventListener('input', async function (event) {
if (event.target.checkValidity() == false) {
modalForm.querySelector("input[type='submit']").setAttribute('disabled', true);
event.target.classList.add('is-danger');
return;
} else {
modalForm.querySelector("input[type='submit']").removeAttribute('disabled');
event.target.classList.remove('is-danger');
}
});
getApiDescriptionByTable(modalForm.getAttribute('data-targetTable')).then((desc) => {
console.log('Description: ', desc);
const keys = desc['POST']['keys'];
// Apply resitrictions and types to form fields
for (key in keys) {
const field = modalForm.querySelector("input[name='" + key + "']");
if (field) {
const rules = keys[key]['rules'];
const flags = keys[key]['flags'];
console.log('Field: ', field, ' Rules: ', rules, ' Flags: ', flags);
rules.forEach((rule) => {
switch (rule['name']) {
case 'min': {
field.setAttribute('minlength', rule['args']['limit']);
break;
}
case 'max': {
field.setAttribute('maxlength', rule['args']['limit']);
break;
}
case 'pattern': {
field.setAttribute('pattern', rule['args']['regex'].substring(1, rule['args']['regex'].length - 1));
//field.setAttribute("pattern", "^[\\+]?[\\(]?[0-9]{3}[\\)]?[\\-\\s\\.]?[0-9]{3}[\\-\\s\\.]?[0-9]{4,9}$");
break;
}
case 'type': {
//field.setAttribute("type", rule["args"]["type"]);
console.log('Type: ', rule['args']['type']);
break;
}
}
});
if (flags) {
flags['presence'] == 'required' ? field.setAttribute('required', true) : field.removeAttribute('required');
}
}
}
console.log('Keys: ', keys);
});
modalForm.addEventListener('submit', async function (event) {
event.preventDefault();
// Check what button submitted the form and if it has data-actionBtn = save
// If not, close modal
const pressedBtn = event.submitter;
if(pressedBtn.getAttribute("data-actionBtn") != "save") {
modalForm.closest(".modal").classList.remove('is-active');
if (pressedBtn.getAttribute('data-actionBtn') != 'save') {
modalForm.closest('.modal').classList.remove('is-active');
return;
}
// Find .entryPhase and hide it
const entryPhase = modalForm.querySelector(".entryPhase");
const loadPhase = modalForm.querySelector(".loadPhase");
if(entryPhase) {
entryPhase.classList.add("is-hidden");
const entryPhase = modalForm.querySelector('.entryPhase');
const loadPhase = modalForm.querySelector('.loadPhase');
if (entryPhase) {
entryPhase.classList.add('is-hidden');
}
if(loadPhase) {
loadPhase.classList.remove("is-hidden");
if (loadPhase) {
loadPhase.classList.remove('is-hidden');
}
console.log("Form submitted: ", modalForm);
const table = modalForm.getAttribute("data-targetTable");
console.log('Form submitted: ', modalForm);
const table = modalForm.getAttribute('data-targetTable');
const data = new FormData(modalForm);
// Convert to JSON object
let jsonData = {};
data.forEach((value, key) => {
jsonData[key] = value;
});
console.log("JSON Data: ", jsonData);
let resp = await createEntry(table, jsonData);
console.log("Response: ", resp);
if(resp["status"] == "CREATED") {
console.log("Entry created successfully");
modalForm.closest(".modal").classList.remove('is-active');
console.log('JSON Data: ', jsonData);
let resp = {};
if(modalForm.getAttribute('data-action') == 'edit') {
Rid = modalForm.getAttribute('data-rid');
resp = await updateRow(table, Rid,jsonData);
modalForm.setAttribute('data-action', 'create');
} else {
resp = await createEntry(table, jsonData);
}
console.log('Response: ', resp);
if (resp['status'] == 'CREATED' || resp['status'] == 'UPDATED') {
console.log('Entry created successfully');
modalForm.closest('.modal').classList.remove('is-active');
modalForm.reset();
// Hide loadPhase
if(loadPhase) {
loadPhase.classList.add("is-hidden");
if (loadPhase) {
loadPhase.classList.add('is-hidden');
}
// Show entryPhase
if(entryPhase) {
entryPhase.classList.remove("is-hidden");
if (entryPhase) {
entryPhase.classList.remove('is-hidden');
}
} else {
// Hide loadPhase
if(loadPhase) {
loadPhase.classList.add("is-hidden");
if (loadPhase) {
loadPhase.classList.add('is-hidden');
}
// Show entryPhase
if(entryPhase) {
entryPhase.classList.remove("is-hidden");
if (entryPhase) {
entryPhase.classList.remove('is-hidden');
}
// TODO: Show error message
}
// const target = document.getElementById(table);
// writeDataToTable(target, result);
// Find all tables with data-searchTargetId set to table
setTimeout(() => {
refreshTableByName(table);
@ -191,64 +265,309 @@ modalForms.forEach(modalForm => {
async function refreshTable(table) {
// Refresh a table while keeping (optionally set) search value
const searchField = document.querySelector("input[data-searchTargetId='" + table.id + "']");
if(searchField) {
const value = searchField.value;
const dbTable = table.getAttribute("data-dataSource");
const result = await returnTableDataByTableNameWithSearch(dbTable, value);
clearTable(table);
writeDataToTable(table, result);
} else {
const result = await returnTableDataByTableName(table.getAttribute("data-dataSource"));
clearTable(table);
writeDataToTable(table, result);
// Get state of order and sort
const ths = table.querySelectorAll('th');
const columnIndices = [];
ths.forEach((th, index) => {
columnIndices[th.getAttribute('data-dataCol')] = index;
});
let order = '';
let column = '';
ths.forEach((th) => {
if (th.hasAttribute('data-order')) {
order = th.getAttribute('data-order');
column = th.getAttribute('data-dataCol');
}
});
console.log('Order: ', order, ' Column: ', column);
const maxLinesPerPage = table.getAttribute('data-pageSize');
let currentPage = table.getAttribute('data-currentPage');
if(currentPage == null) {
table.setAttribute('data-currentPage', 1);
currentPage = 1;
}
const start = (currentPage - 1) * maxLinesPerPage;
const end = start + maxLinesPerPage;
let paginationPassOnPre = {
'start': start,
'end': end,
'currentPage': currentPage,
'maxLinesPerPage': maxLinesPerPage
};
if (searchField) {
const value = searchField.value;
const dbTable = table.getAttribute('data-dataSource');
const result = await returnTableDataByTableName(dbTable, value, order, column, take= maxLinesPerPage, skip= start);
const totalResultCount = await getCountByTable(dbTable, value);
paginationPassOnPre['dataLength'] = totalResultCount;
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
var data = magMiddl[0];
var paginationPassOn = magMiddl[1];
clearTable(table);
writeDataToTable(table, data, paginationPassOn);
} else {
const result = await returnTableDataByTableName(table.getAttribute('data-dataSource'), undefined, order, column, take= maxLinesPerPage, skip= start);
const resultCount = await getCountByTable(table.getAttribute('data-dataSource'));
paginationPassOnPre['dataLength'] = resultCount;
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
var data = magMiddl[0];
var paginationPassOn = magMiddl[1];
clearTable(table);
writeDataToTable(table, data, paginationPassOn);
}
}
function managePaginationMiddleware(data, paginationPassOnPre) {
const maxLinesPerPage = paginationPassOnPre['maxLinesPerPage'];
const dataLength = paginationPassOnPre['dataLength'];
const maxPages = Math.ceil(dataLength / maxLinesPerPage);
paginationPassOn = paginationPassOnPre;
paginationPassOn['maxPages'] = maxPages;
// paginationPassOn['dataLength'] = dataLength;
return [data, paginationPassOn];
}
async function refreshTableByName(name) {
const dirtyTables = document.querySelectorAll("table[data-dataSource='" + name + "']");
for(dirty of dirtyTables) {
for (dirty of dirtyTables) {
refreshTable(dirty);
}
}
async function updateSingeltonsByTableName(name) {
const dirtySingles = document.querySelectorAll("span[data-dataSource='" + name + "']");
for(dirty of dirtySingles) {
for (dirty of dirtySingles) {
writeSingelton(dirty);
}
}
function clearTable(table) {
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
const tbody = table.querySelector('tbody');
tbody.innerHTML = '';
}
function writeDataToTable(table, data) {
console.log("Writing data to table: ", table, data);
function writeDataToTable(table, data, paginationPassOn) {
if(data == undefined || data == null || data.length == 0) {
return;
}
console.log('Writing data to table: ', table, data);
// Get THEAD and TBODY elements
const thead = table.querySelector("thead");
const tbody = table.querySelector("tbody");
const thead = table.querySelector('thead');
const tbody = table.querySelector('tbody');
// get index per column
const columns = thead.querySelectorAll("th");
const columns = thead.querySelectorAll('th');
const columnIndices = [];
columns.forEach((column, index) => {
columnIndices[column.getAttribute("data-dataCol")] = index;
columnIndices[column.getAttribute('data-dataCol')] = index;
});
// All required cols
let requiredCols = [];
columns.forEach(column => {
requiredCols.push(column.getAttribute("data-dataCol"));
let actionFields = [];
columns.forEach((column) => {
// console.log('Column: ', column, ' FNC: ', column.getAttribute('data-fnc'), column.attributes);
if(column.getAttribute('data-fnc') == "actions") {
console.log('!!! Found actions column !!!');
actionFields.push(column);
return;
}
requiredCols.push(column.getAttribute('data-dataCol'));
});
// Get paginationPassOn
const start = paginationPassOn['start'];
const end = paginationPassOn['end'];
const currentPage = paginationPassOn['currentPage'];
const maxLinesPerPage = paginationPassOn['maxLinesPerPage'];
const maxPages = paginationPassOn['maxPages'];
const dataLength = paginationPassOn['dataLength'];
// Find nav with class pagination and data-targetTable="table.id"
const paginationElement = document.querySelector("nav.pagination[data-targetTable='" + table.id + "']");
const paginationList = paginationElement.querySelector('ul.pagination-list');
console.log('Data length: ', dataLength, ' Max pages: ', maxPages);
if(maxPages > 1) {
// Clear pagination list
paginationList.innerHTML = '';
for (let i = 1; i <= maxPages; i++) {
const li = document.createElement('li');
li.innerHTML = '<a class="pagination-link" aria-label="Goto page ' + i + '" data-page="' + i + '">' + i + '</a>';
if(i == currentPage) {
li.querySelector('a').classList.add('is-current');
}
paginationList.appendChild(li);
}
// Remove unused pages, only leave first, last, current and 2 neighbors
let pages = paginationList.querySelectorAll('li');
let friends = []
// Always add first and last
friends.push(0);
friends.push(pages.length - 1);
friends.push(currentPage-1);
// Add direct neighbors
// friends.push(currentPage - 2);
friends.push(currentPage);
friends.push(currentPage - 2);
// Deduplicate friends
friends = [...new Set(friends)];
// Sort friends
friends.sort((a, b) => a - b);
// Parse friends (string to int)
friends = friends.map((x) => parseInt(x));
console.log('Friends: ', friends, ' Pages: ', pages.length, ' Current: ', currentPage);
// Remove everyone who is not a friend
for(let i = 0; i < pages.length; i++) {
if(friends.includes(i)) {
continue;
}
pages[i].remove();
}
// Find all gaps (step size bigger then 1) and add an ellipsis in between the two numbers
let last = 0;
for(let i = 0; i < friends.length; i++) {
if(friends[i] - last > 1) {
const li = document.createElement('li');
li.innerHTML = '<span class="pagination-ellipsis">&hellip;</span>';
paginationList.insertBefore(li, pages[friends[i]]);
}
last = friends[i];
}
// Append on click event to all pagination links
paginationList.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', async function() {
const page = link.getAttribute('data-page');
table.setAttribute('data-currentPage', page);
refreshTable(table);
});
});
paginationElement.classList.remove('is-hidden');
} else {
paginationElement.classList.add('is-hidden');
}
for (resultIndex in data) {
const row = data[resultIndex];
const tr = document.createElement("tr");
requiredCols.forEach(column => {
const td = document.createElement("td");
td.innerHTML = row[column];
const tr = document.createElement('tr');
requiredCols.forEach((column) => {
const td = document.createElement('td');
td.innerText = row[column];
tr.appendChild(td);
});
// Add action fields
actionFields.forEach((actionField) => {
const td = document.createElement('td');
const actions = actionField.getAttribute('data-actions').split(',');
actions.forEach((action) => {
const button = document.createElement('button');
let icon = '';
let color = 'is-primary';
switch(action) {
case 'edit': {
icon = '<i class="bi bi-pencil"></i>';
break;
}
case 'delete': {
icon = '<i class="bi bi-trash"></i>';
color = 'is-danger';
break;
}
}
// Add classes
button.classList.add('button');
button.classList.add('is-small');
button.classList.add(color);
button.classList.add('is-outlined');
button.innerHTML = ` <span class="icon is-small">${icon}</span> `;
button.style.marginRight = '5px';
// Add data-action and data-id
button.setAttribute('data-action', action);
button.setAttribute("data-id", row["id"]);
// Add event listener
button.addEventListener('click', async function() {
const table = actionField.closest('table');
const row = button.closest('tr');
const columns = table.querySelectorAll('th');
const columnIndices = [];
columns.forEach((column, index) => {
columnIndices[column.getAttribute('data-dataCol')] = index;
});
const data = [];
columns.forEach((column) => {
data[column.getAttribute('data-dataCol')] = row.children[columnIndices[column.getAttribute('data-dataCol')]].innerText;
});
console.log('Data: ', data);
switch(action) {
case 'edit': {
// Open modal with form
const form = document.querySelector("form[data-targetTable='" + table.getAttribute('data-dataSource') + "']");
const formTitle = form.querySelector('.title');
const entryPhase = form.querySelector('.entryPhase');
const loadPhase = form.querySelector('.loadPhase');
const fields = form.querySelectorAll('input');
// Set modal to edit mode
form.setAttribute('data-action', 'edit');
form.setAttribute('data-rid', button.getAttribute('data-id'));
formTitle.innerText = 'Edit entry';
fields.forEach((field) => {
// Skip for submit button
if(field.getAttribute('type') == 'submit') {
return;
}
field.value = data[field.getAttribute('name')];
});
form.closest('.modal').classList.add('is-active');
// TBD
break;
}
case 'delete': {
// confirm
const confirm = window.confirm('Do you really want to delete this entry?');
// Delete entry
if(confirm) {
const table = actionField.closest('table');
const id = button.getAttribute('data-id');
const resp = await deleteRow(table.getAttribute('data-dataSource'), id);
if(resp['status'] == 'DELETED') {
refreshTable(table);
updateSingeltonsByTableName(table.getAttribute('data-dataSource'));
} else {
// Show error message
// TODO: Show error message
}
}
break;
}
}
}
);
td.appendChild(button);
});
tr.appendChild(td);
});
tbody.appendChild(tr);
@ -256,46 +575,46 @@ function writeDataToTable(table, data) {
}
// Handle modal
// Handle modal
document.addEventListener('DOMContentLoaded', () => {
// Functions to open and close a modal
function openModal($el) {
$el.classList.add('is-active');
$el.classList.add('is-active');
}
function closeModal($el) {
$el.classList.remove('is-active');
$el.classList.remove('is-active');
}
function closeAllModals() {
(document.querySelectorAll('.modal') || []).forEach(($modal) => {
closeModal($modal);
});
(document.querySelectorAll('.modal') || []).forEach(($modal) => {
closeModal($modal);
});
}
// Add a click event on buttons to open a specific modal
(document.querySelectorAll('.js-modal-trigger') || []).forEach(($trigger) => {
const modal = $trigger.dataset.target;
const $target = document.getElementById(modal);
$trigger.addEventListener('click', () => {
openModal($target);
});
const modal = $trigger.dataset.target;
const $target = document.getElementById(modal);
$trigger.addEventListener('click', () => {
openModal($target);
});
});
// Add a click event on various child elements to close the parent modal
(document.querySelectorAll('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach(($close) => {
const $target = $close.closest('.modal');
$close.addEventListener('click', () => {
closeModal($target);
});
(document.querySelectorAll('.modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach(($close) => {
const $target = $close.closest('.modal');
$close.addEventListener('click', () => {
closeModal($target);
});
});
// Add a keyboard event to close all modals
document.addEventListener('keydown', (event) => {
if(event.key === "Escape") {
closeAllModals();
}
if (event.key === 'Escape') {
closeAllModals();
}
});
});
});

View File

@ -96,7 +96,7 @@
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */

119
views/contacts.eta Normal file
View File

@ -0,0 +1,119 @@
<%~ include("partials/base_head.eta", {"title": "Kontakte"}) %>
<%~ include("partials/nav.eta") %>
<section class="hero is-primary" id="heroStatus">
<div class="hero-body">
<p class="title" data-tK="start-hero-header-welcome">Kontaktverwaltung</p>
<p class="subtitle" data-tK="start-hero-header-subtitle-default" id="heroExplainer">Erklärungstext</p>
</div>
</section>
<section class="section">
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Kontakte</p>
<p class="title"><span data-dataSource="AlertContacts" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading"><button class="js-modal-trigger button" data-target="modal-js-example">
Neuen Konakt anlegen
</button></p>
</div>
</div>
</nav>
</section>
<!-- TODO: Mark required fields as required; add handling for validation -->
<div id="modal-js-example" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box entryPhase is-hidden">
<h2 class="title">Neuer Kontakt</h1>
<i class="bi bi-arrow-clockwise title"></i>
</div>
<div class="box entryPhase">
<form data-targetTable="AlertContacts">
<h2 class="title">Neuer Kontakt</h1>
<div class="field">
<label class="label">Name</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="John Doe" value="" name="name">
<span class="icon is-small is-left">
<i class="bi bi-file-earmark-person-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Telefonummer</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="+49 1234 5678912" value="" name="phone">
<span class="icon is-small is-left">
<i class="bi bi-telephone-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Anmerkung</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="" value="" name="comment">
<span class="icon is-small is-left">
<i class="bi bi-chat-fill"></i>
</span>
</div>
</div>
<br>
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link" value="Save" data-actionBtn="save">
</div>
<!--<div class="control">
<button type="button" class="button is-link is-light" data-actionBtn="cancel">Cancel</button>
</div>-->
</div>
</form>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
<section class="section">
<h1 class="title" data-tK="start-recent-header">Kontaktübersicht</h1>
<input class="input" type="text" data-searchTargetId="contactTable" placeholder="Search..." />
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="AlertContacts" id="contactTable" data-pageSize="5">
<thead>
<tr>
<th data-dataCol = "id"><abbr title="Position">Pos</abbr></th>
<th data-dataCol = "name">Name</th>
<th data-dataCol = "phone">Telefonnummer</th>
<th data-dataCol = "comment">Kommentar</th>
<th data-fnc="actions" data-actions="edit,delete">Aktionen</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<nav class="pagination is-hidden" role="navigation" aria-label="pagination" data-targetTable="contactTable">
<ul class="pagination-list">
</ul>
</nav>
</section>
<%~ include("partials/footer.eta") %>
<%~ include("partials/base_foot.eta") %>

View File

@ -20,6 +20,7 @@
<div class="navbar-start">
<a class="navbar-item" href="/">Home</a>
<a class="navbar-item" href="/dbTest">API Integration <span class="tag is-info">Dev</span></a>
<a class="navbar-item" href="/contact">Kontakte <span class="tag is-primary">Neu!</span></a>
<!--<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">More</a>
<div class="navbar-dropdown">
@ -32,7 +33,7 @@
</div>-->
</div>
<!--<div class="navbar-end">
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary">
@ -41,6 +42,6 @@
<a class="button is-light">Log in</a>
</div>
</div>
</div>-->
</div>
</div>
</nav>

View File

@ -32,86 +32,123 @@
</section>
<section class="section">
<h1 class="title" data-tK="start-sysinfo-header">File test</h1>
<form method="put" action="/api/upload" enctype="multipart/form-data" id="uploadForm">
<div class="field">
<label class="label">File</label>
<div class="control">
<input class="input" type="file" name="file" id="file">
</div>
</div>
<!-- URL field -->
<div class="field">
<label class="label">URL</label>
<div class="control">
<input class="input" type="text" name="url" id="url">
</div>
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link" value="Upload" data-actionBtn="upload">
</div>
</div>
<script>
document.getElementById("url").addEventListener("input", function() {
document.getElementById("uploadForm").action = document.getElementById("url").value;
});
</script>
</form>
</section>
<!-- TODO: Mark required fields as required; add handling for validation -->
<div id="modal-js-example" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box entryPhase is-hidden">
<h2 class="title">New Contact</h1>
<i class="bi bi-arrow-clockwise title"></i>
</div>
<div class="box entryPhase">
<h2 class="title">New Contact</h1>
<div class="box entryPhase">
<form data-targetTable="AlertContacts">
<h2 class="title">New Contact</h1>
<div class="field">
<label class="label">Name</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="John Doe" value="" name="name">
<span class="icon is-small is-left">
<input class="input" type="text" placeholder="John Doe" value="" name="name">
<span class="icon is-small is-left">
<i class="bi bi-file-earmark-person-fill"></i>
</span>
</span>
</div>
</div>
<div class="field">
</div>
<div class="field">
<label class="label">Telephone</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="+49 1234 5678912" value="" name="phone">
<span class="icon is-small is-left">
<input class="input" type="text" placeholder="+49 1234 5678912" value="" name="phone">
<span class="icon is-small is-left">
<i class="bi bi-telephone-fill"></i>
</span>
</span>
</div>
</div>
<div class="field">
</div>
<div class="field">
<label class="label">Comment</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="" value="" name="comment">
<span class="icon is-small is-left">
<input class="input" type="text" placeholder="" value="" name="comment">
<span class="icon is-small is-left">
<i class="bi bi-chat-fill"></i>
</span>
</span>
</div>
</div>
</div>
<br>
<div class="field is-grouped">
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link" value="Save" data-actionBtn="save">
</div>
<div class="control">
<button class="button is-link is-light" data-actionBtn="cancel">Cancel</button>
<!--<div class="control">
<button type="button" class="button is-link is-light" data-actionBtn="cancel">Cancel</button>
</div>-->
</div>
</div>
</form>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
</div>
<button class="js-modal-trigger button" data-target="modal-js-example">
<button class="js-modal-trigger button" data-target="modal-js-example">
Create new Contact
</button>
</button>
<section class="section">
<h1 class="title" data-tK="start-recent-header">Alarm Kontakte</h1>
<input class="input" type="text" data-searchTargetId="contactTable" placeholder="Search..." />
<table class="table is-striped is-fullwidth" data-dataSource="AlertContacts" id="contactTable">
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="AlertContacts" id="contactTable" data-pageSize="5">
<thead>
<tr>
<th data-dataCol = "id"><abbr title="Position">Pos</abbr></th>
<th data-dataCol = "name">Name</th>
<th data-dataCol = "phone">Telefon Nummer</th>
<th data-dataCol = "comment">Kommentar</th>
<th data-fnc="actions" data-actions="edit,delete">Aktionen</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<nav class="pagination is-hidden" role="navigation" aria-label="pagination" data-targetTable="contactTable">
<ul class="pagination-list">
</ul>
</nav>
</section>
<%~ include("partials/footer.eta") %>