Initial commit

This commit is contained in:
2025-08-19 09:55:57 -04:00
commit dae1a31b4c
69 changed files with 8243 additions and 0 deletions

141
server/models/db.js Normal file
View File

@ -0,0 +1,141 @@
import dotenv from 'dotenv';
import {Pool} from 'pg';
dotenv.config();
const pool = new Pool(process.env.DATABASE_URL ? {connectionString: process.env.DATABASE_URL} : {
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT || '5432', 10),
});
/**
* Generic query function for PostgreSQL database.
* @template T
* @param {string} text
* @param {any[]} [params]
* @return {Promise<T[]>}
*/
async function query(text, params= []) {
const start = Date.now();
const res = await pool.query(text, params).then(qr => qr);
const duration = Date.now() - start;
if (params) for (let i = 0; i < params.length; i++) text = text.replace("$".concat(String(i + 1)), params[i]);
console.log('executed query', {text, duration, rows: res.rowCount});
/**@type T[]*/
return res.rows;
}
/**
* function to insert data into a table.
* @template T
* @param {string} table The name of the table to insert data into.
* @param {Object.<string, any>} map The {keys: values} to insert data into.
* @returns {Promise<T[] | Error>} Returns inserted value if the insertion was successful, error otherwise.
* */
async function insert(table, map) {
const valuePlaceholders = Object.keys(map).map((_, index) => `$${index + 1}`).join(", ");
const queryText = `INSERT INTO ${table} (${Object.keys(map).join(", ")}) VALUES (${valuePlaceholders}) RETURNING *`;
return await query(queryText, Object.values(map))
.then(resRows => {
if (resRows.length === 0) {
console.error('Insert failed: No rows affected');
return new Error('Insert failed: No rows affected');
}
console.log('Insert successful:', resRows);
return resRows;
})
.catch(err => {
console.error('Insert error:', err);
return err;
});
}
/**
* Function to update data in a table.
* @template T
* @param {string} table
* @param {Object.<string, any>} updateMap The {keys: values} to update.
* @param {Object.<string, any>} conditions The conditions to match to update.
* @returns {Promise<T[] | Error>} Returns updated rows if the insertion was successful, error otherwise.
*/
async function update(table, updateMap, conditions) {
if (Object.keys(updateMap).length === 0) {
console.error('Update failed: No fields to update');
return new Error('Update failed: No fields to update');
}
if (Object.keys(conditions).length === 0) {
console.error('Update failed: No conditions specified');
return new Error('Update failed: No conditions specified');
}
const setClause = Object.keys(updateMap).map((key, index) => `${key} = ${index + 1}`).join(', ');
const whereClause = Object.entries(conditions).map(([key, value]) => `${key} = ${value}`).join(' AND ');
return await query(`UPDATE ${table} SET ${setClause} WHERE ${whereClause} RETURNING *`, Object.values(updateMap))
.then(resRows => {
if (resRows.length === 0) {
console.error('Update failed: No rows affected');
return new Error('Update failed: No rows affected');
}
console.log('Update successful:', resRows);
return resRows;
})
.catch(err => {
console.error('Update error:', err);
return err;
});
}
/**
* Function to delete a row from a table by ID.
* @template T
* @param {string} table The name of the table to delete from.
* @param {Object.<string, any>} conditions The conditions to match to delete row/rows.
* @return {Promise<T[] | Error>} Returns true if the deletion was successful, false otherwise.
*/
async function remove(table, conditions = {}) {
if (!conditions || Object.keys(conditions).length === 0) {
console.error('Delete failed: No identifier specified');
return new Error('Delete failed: No identifier specified');
}
const whereClause = Object.entries(conditions).map(([key, _], index) => `${key} = ${index + 1}`).join(' AND ');
const queryText = `DELETE FROM ${table} WHERE ${whereClause} RETURNING *`;
return await query(queryText, Object.values(conditions))
.then(resRows => {
if (resRows.length === 0) {
console.error('Delete failed: No rows affected');
return new Error('Delete failed: No rows affected');
}
console.log('Delete successful:', resRows);
return resRows;
})
.catch(err => {
console.error('Delete error:', err);
return err;
});
}
/**
* Function to get rows from a table based on conditions.
* @template T
* @param {string} table The name of the table to query.
* @param {{}} conditions The conditions to match rows.
* @return {Promise<T[]>} Returns an array of rows that match the conditions.
*/
async function get(table, conditions = {}) {
if (Object.keys(conditions).length === 0) {
return await query(`SELECT * FROM "${table}"`);
}
const whereClause = Object.entries(conditions).map(([key, _], index) => `${key} = $${index + 1}`).join(' AND ');
return await query(`SELECT * FROM ${table} WHERE ${whereClause}`, Object.values(conditions));
}
export default {
pool,
query,
insert,
update,
remove,
get,
};

146
server/models/users.js Normal file
View File

@ -0,0 +1,146 @@
import jwt from "jsonwebtoken";
import db from "./db.js";
import bcrypt from "bcryptjs";
/** @typedef User
* @property {number} id - The unique identifier for the user.
* @property {string} username - The username of the user.
* @property {string} password - The hashed password of the user.
* @property {string} role - The role of the user, e.g., 'user' or 'admin'.
*/
/**
* Fetches all users from the database.
* @return {Promise<User[]>}
*/
async function getAllUsers() {
try {
return await db.get('users').then((users) => users);
} catch (error) {
console.error('Error fetching users:', error);
throw error;
}
}
/**
* gets user by id or username
* @param {string | number} identifier can be either user id or username
* @return {Promise<{id: number, username: string, password: string, role: string}>}
*/
async function getUser(identifier) {
/** @type {User[]} users */
let users;
if (/^\d+$/.test(identifier)) users = await db.get('users',{"id": identifier});
else users = await db.get('users', {'username': identifier});
if (users.length === 0) throw new Error("User not found");
else if (users.length > 1) throw new Error("Multiple users found with the same identifier, something has gone wrong");
return users[0];
}
/**
* adds new user to db as non-admin. only direct db manipulation can make users admins
* @param {string} username
* @param {string} password
* @return {Promise<User|void>}
*/
async function addNewUser(username, password) {
if (!username || !password) throw new Error('Username and password are required');
// Check if the user already exists
if ((await db.get('users', {'username': username})).length > 0) throw new Error('User already exists with this username');
// Hash the password before storing it
const hashedPassword = await bcrypt.hash(password, 10);
/** @type {Promise<User|Error>} */
const result = await db.insert('users', {username, password: hashedPassword, role: 'user'});
if (!result) throw new Error('User could not be created');
if (result instanceof Error) throw result;
return result;
}
/**
* Logs in a user by validating their credentials and generating a JWT token.
* @param {string} username
* @param {string} password
* @return {Promise<{id: number, username: string, role: string, token: (*)}>}
*/
async function login (username, password) {
/** @type {User} user*/
const user = await db.get('users', {'username': username}).then(response => {
if (response instanceof Error) throw response;
if (response.length === 0) throw new Error('User not found');
if (response.length > 1) throw new Error('Multiple users found with the same username, something has gone wrong');
return response[0];
});
if (!user || !bcrypt.compareSync(password, user.password)) {
throw new Error('Invalid username or password');
}
console.log(user);
const token = jwt.sign({id: user.id, role: user.role}, process.env.JWT_SECRET, {expiresIn: "1d"});
return {
id: user.id,
username: user.username,
role: user.role,
token: token
}
}
/**
* Updates a user's information.
* @param {{id?: number, username?: string, password?: string, newPassword?: string}} mapping
* @return {Promise<User>}
*/
async function updateUser(mapping) {
if (!mapping) throw new Error('User mapping is required');
if (!mapping.id && !mapping.username) throw new Error('User id or username is required for update');
let user;
if (mapping.id) user = await getUser(mapping.id);
else user = await getUser(mapping.username);
if (!user) throw new Error('User not found');
if (mapping.newPassword) {
// validate the old password to make sure the user is authenticated
if (!mapping.password) throw new Error('Old password is required to update password');
if (!bcrypt.compareSync(mapping.password, user.password)) throw new Error('Old password is incorrect');
// Hash the new password before updating
mapping.password = await bcrypt.hash(mapping.newPassword, 10);
delete mapping.newPassword; // Remove newPassword from the mapping
}
/** @type {User[] | Error} */
let result;
if (mapping.id) {
// remove id from mapping to avoid updating it
const id = mapping.id;
delete mapping.id;
result = await db.update('users', mapping, {'id': id});
} else {
// remove username from mapping to avoid updating it
const username = mapping.username;
delete mapping.username;
result = await db.update('users', mapping, {'username': username});
}
if (result instanceof Error) throw result;
if (result.length > 1) throw new Error('Multiple users updated, something has gone wrong');
return result[0];
}
/**
* Deletes a user by their ID.
* @param {string | number} identifier can be either user id or username
* @return {Promise<User>}
*/
async function deleteUser(identifier) {
if (!identifier) throw new Error('User identifier is required');
/** @type {User[] | Error} */
let result;
if (/^\d+$/.test(identifier)) result = await db.remove('users', {"id": identifier});
else result = await db.remove('users', {'username': identifier});
if (result instanceof Error) throw result;
return result[0];
}
export default {
getAllUsers,
getUser,
addNewUser,
login,
updateUser,
deleteUser
}