From: charleswrayjr Date: Fri, 12 Sep 2025 01:10:36 +0000 (-0500) Subject: Initial models loaded for db tables. X-Git-Url: https://git.phasecustomsoft.com/static/gitweb.css?a=commitdiff_plain;h=faaa191c9c07d6fda10d86e078aea48f27e65c35;p=phs-api.git Initial models loaded for db tables. --- diff --git a/src/middleware/passport.js b/src/middleware/passport.js index 0dcbede..9e0975c 100755 --- a/src/middleware/passport.js +++ b/src/middleware/passport.js @@ -19,7 +19,7 @@ function hookJWTStrategy(passport) { logger.debug(`JWT-JWTPayload: ${JSON.stringify(JWTPayload)}`); /*return new User().findOne({ email: JWTPayload.email, is_active: true })*/ // return new User().findOne({ id: 1, is_active: true }) - return phsdb.query(`select * from phs.users where id = $1;`, [1], { plain: true }) + return phsdb.query(`select * from phase.users where id = $1;`, [1], { plain: true }) .then(async user => { logger.debug('passport: ' + user); if (!user?.id) { diff --git a/src/models/address.model.js b/src/models/address.model.js new file mode 100644 index 0000000..76ea918 --- /dev/null +++ b/src/models/address.model.js @@ -0,0 +1,127 @@ +/** + * @file Address model for phase.addresses table + */ + +const { Model, ValidationError } = require('./model'); + +/** + * @typedef {Object} Address + * @property {number} id - Address ID (primary key) + * @property {string} street_1 - Street address line 1 + * @property {string|null} street_2 - Street address line 2 + * @property {string} city - City + * @property {string} state - State + * @property {string} zip_code - Zip code + * @property {number|null} created_by_id - ID of user who created this address + * @property {Date} created_at - Creation timestamp + * @property {boolean} is_deleted - Soft delete flag + * @property {number|null} deleted_by_id - ID of user who deleted this address + * @property {Date|null} deleted_at - Deletion timestamp + */ + +/** + * Address model class + * @extends Model + */ +class Address extends Model { + /** + * Create an Address instance + * @param {Partial
} [props] - Address properties + */ + constructor(props) { + super(props); + this.table = 'phase.addresses'; + this.prepend = 'a'; + this.default_columns = [ + 'id', 'street_1', 'street_2', 'city', 'state', 'zip_code', + 'created_by_id', 'created_at', 'is_deleted', 'deleted_by_id', 'deleted_at' + ]; + this.update_exclude_columns = ['id', 'created_at', 'is_deleted', 'deleted_at', 'deleted_by_id']; + this.base_query = ` + SELECT a.id, a.street_1, a.street_2, a.city, a.state, a.zip_code, + a.created_by_id, a.created_at, a.is_deleted, a.deleted_by_id, a.deleted_at + FROM phase.addresses a + WHERE a.is_deleted = false + `; + this.base_list_query = ` + SELECT a.id, a.street_1, a.street_2, a.city, a.state, a.zip_code, + a.created_by_id, a.created_at + FROM phase.addresses a + WHERE a.is_deleted = false + `; + this.default_order_by = 'ORDER BY a.zip_code ASC'; + this.instance = _props => new Address(_props); + } + + /** + * Create a new address + * @param {Omit} address_data - Address data + * @returns {Promise
} Created address instance + * @throws {ValidationError} If required fields are missing + */ + static async create(address_data) { + const { street_1, street_2 = null, city, state, zip_code, created_by_id = null } = address_data; + if (!street_1 || !city || !state || !zip_code) { + throw new ValidationError('Missing required fields: street_1, city, state, zip_code'); + } + const query_str = ` + INSERT INTO phase.addresses (street_1, street_2, city, state, zip_code, created_by_id) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; + `; + const values = [street_1, street_2, city, state, zip_code, created_by_id]; + const result = await phsdb.query(query_str, values, { plain: true }); + if (!result) throw new ValidationError('Failed to create address'); + return new Address(result); + } + + /** + * Create the relation between a user and an address + * @param user_id + * @returns {Promise<{user_id: number, address_id: number}>} + */ + static async add_user(user_id) { + const done = await require( 'user_address.model' ).add_relation( user_id, this.id ); + if (!done) throw new ValidationError('Failed to add phone number'); + return done; + }; + + /** + * Find address by zip code + * @param {string} zip_code - Zip code to search for + * @param {string[]} [excludes] - Fields to exclude from result + * @returns {Promise[]} Address instance or null + */ + static async find_by_zip_code(zip_code, excludes = []) { + return await new Address().find_many( { zip_code }, excludes ); + } + + /** + * Soft delete address + * @param {number|string} deleted_by_id - ID of user performing deletion + * @returns {Promise
} Updated address instance + * @throws {ValidationError} If deleted_by_id is invalid + */ + async soft_delete(deleted_by_id) { + const deleted_by_id_int = parseInt(deleted_by_id, 10); + if (isNaN(deleted_by_id_int)) { + throw new ValidationError('deleted_by_id must be a valid integer'); + } + return await this.update({ + is_deleted: true, + deleted_at: new Date().toISOString(), + deleted_by_id: deleted_by_id_int + }); + } + + /** + * Get the user who created this address + * @returns {Promise} Creator user instance or null + */ + async get_created_by() { + if (!this.created_by_id) return null; + const User = require('./user.model'); + return await new User().find_one({ id: this.created_by_id }, []); + } +} + +module.exports = Address; \ No newline at end of file diff --git a/src/models/model.js b/src/models/model.js index 355741f..3a91a6c 100755 --- a/src/models/model.js +++ b/src/models/model.js @@ -1,215 +1,277 @@ -// noinspection JSUnusedGlobalSymbols +const HttpError = require('http-errors'); + +/** + * Custom error for not found records + * @extends HttpError + * @property {string} name - Error name + */ +class NotFoundError extends HttpError { + /** + * @param {string} message - Error message + */ + constructor(message) { + super( 404, message ); + this.name = 'NotFoundError'; + } +} + +/** + * Custom error for validation failures + * @extends HttpError + */ +class ValidationError extends HttpError { + /** + * @param {string} message - Error message + */ + constructor(message) { + super(400, message); + this.name = 'ValidationError'; + } +} + +// noinspection DuplicatedCode +/** + * Base model class for database operations + */ class Model { - constructor( props ) { - props && Object.keys( props ).forEach( c => { - this[c] = props[c]; - } ); + /** + * Create a model instance + * @param {Object} [props] - Properties to initialize the model + */ + constructor(props) { + props && Object.keys(props).forEach(c => { this[c] = props[c]; }); + /** @type {string} Database table name */ this.table = ''; - this.defaultColumns = []; - this.updateExcludeColumns = ['id']; + /** @type {string[]} Allowed columns for queries */ + this.default_columns = []; + /** @type {string[]} Columns excluded from updates */ + this.update_exclude_columns = ['id']; + /** @type {string} Table alias for queries */ this.prepend = ''; - this.baseQuery = ''; - this.baseListQuery = ''; - this.defaultOrderBy = undefined; - this.instance = _props => new Model( _props ); - }; + /** @type {string} Base query for single record retrieval */ + this.base_query = ''; + /** @type {string} Base query for multiple record retrieval */ + this.base_list_query = ''; + /** @type {string|undefined} Default ORDER BY clause */ + this.default_order_by = undefined; + /** @type {Function} Function to instantiate a model */ + this.instance = _props => new Model(_props); + /** @type {string|undefined} GROUP BY clause */ + this.group_by = undefined; + } - whereClause = ( keys, prepend ) => keys?.length > 0 ? `where ${ keys.map( ( k, index ) => `${ prepend }.${ k } = $${ index + 1 }` ).join( ' and ' ) }` : ''; + /** + * Build WHERE clause for query + * @param {string[]} keys - Column names + * @param {string} prepend - Table alias + * @returns {string} WHERE clause + */ + where_clause(keys, prepend) { + if (!keys?.length) return ''; + return `WHERE ${keys.map((k, index) => `${prepend}.${k} = $${index + 1}`).join(' AND ')}`; + } /** - * Build where clause for query - * - * @param {*} where - * @param {*} defaultColumns - * @returns + * Filter WHERE object to valid columns + * @param {Object} where - Conditions for the WHERE clause + * @param {string[]} default_columns - Allowed columns + * @returns {{keys: string[], values: any[]}} Keys and values for the query */ - buildWhere = function ( where, defaultColumns ) { + build_where(where, default_columns) { const keys = []; const values = []; - where && Object.keys( where ).forEach( k => { - if (defaultColumns.includes( k )) { - keys.push( k ); - values.push( where[k] ); - } - } ); + if (where) { + Object.keys(where).forEach(k => { + if (default_columns.includes(k)) { + keys.push(k); + values.push(where[k]); + } + }); + } return { keys, values }; - }; + } /** * Find one record - * - * @param {*} where - * @param {*} [excludes] - * @returns + * @param {Object} where - Conditions for the WHERE clause + * @param {string[]} [excludes] - Fields to exclude from result + * @returns {Promise} Found record or null */ - findOne = function ( where, excludes = [] ) { - const self = this; - const { keys, values } = self.buildWhere( where, self.defaultColumns ); - return phsdb.query( ` - ${ self.baseQuery } - ${ self.whereClause( keys, self.prepend ) } - ${ self.groupBy ? self.groupBy : '' } - `, values, { plain:true } ).then( result => { - const found = self.instance( result ); - excludes?.map( e => delete found[e] ); - return found; - } ); - }; + async find_one(where, excludes = []) { + const { keys, values } = this.build_where(where, this.default_columns); + const result = await phsdb.query( + `${this.base_query} ${this.where_clause(keys, this.prepend)} ${this.group_by ? this.group_by : ''}`, + values, + { plain: true } + ); + if (!result) return null; + const found = this.instance(result); + excludes?.forEach(e => delete found[e]); + return found; + } + // noinspection JSUnusedGlobalSymbols /** - * Find one record - * - * @param {*} where - * @param {*} [excludes] - * @returns + * Find one record (raw data) + * @param {Object} where - Conditions for the WHERE clause + * @param {string[]} [excludes] - Fields to exclude from result + * @returns {Promise} Found record or null */ - findOneSimple = function ( where, excludes = [] ) { - const self = this; - const { keys, values } = self.buildWhere( where, self.defaultColumns ); - return phsdb.query( ` - ${ self.baseQuery } - ${ self.whereClause( keys, self.prepend ) } - ${ self.groupBy ? self.groupBy : '' } - `, values, { plain:true } ).then( result => { - excludes?.map( e => delete result[e] ); - return result?.length > 0 ? result : null; - } ); - }; + async find_one_simple(where, excludes = []) { + const { keys, values } = this.build_where(where, this.default_columns); + const result = await phsdb.query( + `${this.base_query} ${this.where_clause(keys, this.prepend)} ${this.group_by ? this.group_by : ''}`, + values, + { plain: true } + ); + if (!result) return null; + excludes?.forEach(e => delete result[e]); + return result; + } /** - * @typedef {Object} orderBy - * @property {string} name - * @property {('asc', 'desc')} direction + * Get base list query + * @returns {string} Base list query */ - - getBaseListQuery = function () { - const self = this; - return ` - ${ self.baseListQuery } - `; - }; + get_base_list_query() { + return `${this.base_list_query}`; + } /** * Find many records - * - * @param {Object|undefined} [where] - * @param {string[]|undefined} [excludes] - * @param {orderBy|undefined} [orderBy] This can needs to be passed as an object with a name and optionally a direction. You can look at the vehicle controller and model for a good example. - * @returns + * @param {Object} [where] - Conditions for the WHERE clause + * @param {string[]} [excludes] - Fields to exclude from result + * @param {{name: string, direction?: 'asc'|'desc'}||null} [order_by] - Order by configuration + * @param {number} [limit=100] - Maximum number of records + * @param {number} [offset=0] - Number of records to skip + * @returns {Promise} Array of records */ - findMany = function ( where, excludes, orderBy ) { - const self = this; - const { keys, values } = self.buildWhere( where, self.defaultColumns ); - return phsdb.query( ` - ${ self.getBaseListQuery( self.whereClause( keys, self.prepend ) ) } - ${ self.whereClause( keys, self.prepend ) } - ${ self.groupBy ? self.groupBy : '' } - ${ orderBy ? `order by ${ orderBy.name } ${ orderBy.direction ?? 'asc' }` : self.defaultOrderBy ?? '' }; - `, values ).then( async results => { - const res = []; - await results.forEach( result => { - const found = self.instance( result ); - excludes?.map( e => delete found[e] ); - return res.push( found ); - } ); - return res; - } ); - }; + async find_many(where, excludes = [], order_by = null, limit = 100, offset = 0) { + const { keys, values } = this.build_where(where, this.default_columns); + values.push(limit, offset); + const results = await phsdb.query( + ` + ${this.get_base_list_query()} + ${this.where_clause(keys, this.prepend)} + ${this.group_by ? this.group_by : ''} + ${order_by ? `ORDER BY ${order_by.name} ${order_by.direction ?? 'asc'}` : this.default_order_by ?? ''} + LIMIT $${values.length - 1} OFFSET $${values.length} + `, + values + ); + const res = []; + for (const result of results) { + const found = this.instance(result); + excludes?.forEach(e => delete found[e]); + res.push(found); + } + return res; + } + // noinspection JSUnusedGlobalSymbols /** - * Find many records - * - * @param {Object|undefined} [where] - * @param {string[]|undefined} [excludes] - * @param {orderBy|undefined} [orderBy] This can needs to be passed as an object with a name and optionally a direction. You can look at the vehicle controller and model for a good example. - * @returns + * Find many records (raw data) + * @param {Object} [where] - Conditions for the WHERE clause + * @param {string[]} [excludes] - Fields to exclude from result + * @param {{name: string, direction?: 'asc'|'desc'}||null} [order_by] - Order by configuration + * @param {number} [limit=100] - Maximum number of records + * @param {number} [offset=0] - Number of records to skip + * @returns {Promise} Array of records */ - findManyLite = function ( where, excludes, orderBy ) { - const self = this; - const { keys, values } = self.buildWhere( where, self.defaultColumns ); - return phsdb.query( ` - ${ self.getBaseListQuery( self.whereClause( keys, self.prepend ) ) } - ${ self.whereClause( keys, self.prepend ) } - ${ self.groupBy ? self.groupBy : '' } - ${ orderBy ? `order by ${ orderBy.name } ${ orderBy.direction ?? 'asc' }` : self.defaultOrderBy ?? '' }; - `, values ).then( async results => { - const res = []; - await results.forEach( result => { - const dataValues = {}; - Object.keys( result ).map( k => dataValues[k] = result[k] ); - excludes?.map( e => delete dataValues[e] ); - return res.push( dataValues ); - } ); - return res; - } ); - }; + async find_many_lite(where, excludes = [], order_by = null, limit = 100, offset = 0) { + const { keys, values } = this.build_where(where, this.default_columns); + values.push(limit, offset); + const results = await phsdb.query( + ` + ${this.get_base_list_query()} + ${this.where_clause(keys, this.prepend)} + ${this.group_by ? this.group_by : ''} + ${order_by ? `ORDER BY ${order_by.name} ${order_by.direction ?? 'asc'}` : this.default_order_by ?? ''} + LIMIT $${values.length - 1} OFFSET $${values.length} + `, + values + ); + const res = []; + for (const result of results) { + const data_values = {}; + Object.keys(result).forEach(k => data_values[k] = result[k]); + excludes?.forEach(e => delete data_values[e]); + res.push(data_values); + } + return res; + } /** - * Update the record. Will update only the fields present - * in the params - * - * @param {*} params - * @returns + * Update the record + * @param {Object} params - Fields to update + * @param {string} [identifier='id'] - Identifier field for update + * @returns {Promise} Updated record + * @throws {ValidationError} If no valid fields are provided or ID is missing + * @throws {NotFoundError} If record is not found */ - update = async function ( params ) { - const self = this; - - // build list of update values - const { updates, values, position } = self.createUpdateFields( params ); - - // add id for where query - values.push( this.id ); - - // build query from values - const query = 'update ' + this.table + ' set ' + updates + ' where id=$' + position + ' returning *;'; - logger.debug( `query: ${ query }` ); - - return await phsdb.query( query, values, { plain:true } ) - .then( () => { - return self.findOne( { id:this.id } ); - } ); - }; - - toJSON = function () { - const { - defaultColumns, - prepend, - baseQuery, - baseListQuery, - table, - updateExcludeColumns, - agingDownloadQuery, - baseListExtendedQuery, - baseListQueryForTech, - instance, - db, - defaultOrderBy, - excludes, - ...rest - } = this; - return rest; - }; + async update(params, identifier = 'id') { + if (!this[identifier]) throw new ValidationError('Record ID is required for update'); + const { updates, values, position } = this.create_update_fields(params); + values.push(this[identifier]); + const query_str = `UPDATE ${this.table} SET ${updates} WHERE ${identifier}=$${position} RETURNING *;`; + logger.debug(`Update query: ${query_str}`); + const result = await phsdb.query(query_str, values, { plain: true }); + if (!result) throw new NotFoundError(`Record not found in ${this.table}`); + return this.instance(result); + } /** - * Create update fields string and values based on - * the params and the column configuration + * Create update fields string and values + * @param {Object} params - Update parameters + * @returns {{updates: string, values: any[], position: number}} Update query components + * @throws {ValidationError} If no valid fields are provided */ - createUpdateFields( params ) { + create_update_fields(params) { let position = 1; - let values = []; + const values = []; let updates = ''; - for (const column of this.defaultColumns) { - if (params.hasOwnProperty( column ) && !this.updateExcludeColumns.includes( column )) { + for (const column of this.default_columns) { + if (params.hasOwnProperty(column) && !this.update_exclude_columns.includes(column)) { const value = params[column]; this[column] = value; - values.push( value ); - updates += ((updates ? ',' : '') + column + '=$' + position++); + values.push(value); + updates += (updates ? ',' : '') + `${column}=$${position++}`; } } - + if (!updates) throw new ValidationError('No valid fields provided for update'); return { updates, values, position }; } + /** + * Serialize to JSON, excluding internal properties + * @returns {Object} Serialized object + */ + toJSON() { + const { default_columns, prepend, base_query, base_list_query, table, update_exclude_columns, instance, ...rest } = this; + return rest; + } + + // noinspection JSUnusedGlobalSymbols + /** + * Execute transaction + * @param {Function} callback - Transaction callback + * @returns {Promise} Transaction result + * @throws {Error} If transaction fails + */ + async with_transaction(callback) { + const client = await require('../phsdb').get_client(); + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + await require('../phsdb').client_release(client); + } + } } -module.exports = Model; \ No newline at end of file +module.exports = { Model, NotFoundError, ValidationError }; \ No newline at end of file diff --git a/src/models/nickname.model.js b/src/models/nickname.model.js new file mode 100644 index 0000000..3f7a9f9 --- /dev/null +++ b/src/models/nickname.model.js @@ -0,0 +1,152 @@ +/** + * @file Nickname model for phase.nicknames table + */ + +const { Model, ValidationError } = require('./model'); +const User = require( './user.model' ); + +/** + * @typedef {Object} Nickname + * @property {number} id - Nickname ID (primary key) + * @property {string} nickname - Nickname value + * @property {number|null} created_by_id - ID of user who created this nickname + * @property {Date} created_at - Creation timestamp + * @property {boolean} is_deleted - Soft delete flag + * @property {number|null} deleted_by_id - ID of user who deleted this nickname + * @property {Date|null} deleted_at - Deletion timestamp + */ + +/** + * Nickname model class + * @extends Model + */ +class Nickname extends Model { + /** + * Create a Nickname instance + * @param {Partial} [props] - Nickname properties + */ + constructor(props) { + super(props); + this.table = 'phase.nicknames'; + this.prepend = 'n'; + this.default_columns = [ + 'id', 'nickname', 'created_by_id', 'created_at', + 'is_deleted', 'deleted_by_id', 'deleted_at' + ]; + this.update_exclude_columns = ['id', 'created_at', 'is_deleted', 'deleted_at', 'deleted_by_id']; + this.base_query = ` + SELECT n.id, n.nickname, n.created_by_id, n.created_at, + n.is_deleted, n.deleted_by_id, n.deleted_at + FROM phase.nicknames n + WHERE n.is_deleted = false + `; + this.base_list_query = ` + SELECT n.id, n.nickname, n.created_by_id, n.created_at + FROM phase.nicknames n + WHERE n.is_deleted = false + `; + this.default_order_by = 'ORDER BY n.nickname ASC'; + this.instance = _props => new Nickname(_props); + } + + /** + * Create a new nickname + * @param {Omit} nickname_data - Nickname data + * @returns {Promise} Created nickname instance + * @throws {ValidationError} If required fields are missing + */ + static async create(nickname_data) { + const { nickname, created_by_id = null } = nickname_data; + if (!nickname) throw new ValidationError('Missing required field: nickname'); + const query_str = ` + INSERT INTO phase.nicknames (nickname, created_by_id) + VALUES ($1, $2) RETURNING *; + `; + const values = [nickname, created_by_id]; + const result = await phsdb.query(query_str, values, { plain: true }); + if (!result) throw new ValidationError('Failed to create nickname'); + return new Nickname(result); + }; + + /** + * Create the relation between a user and a nickname + * @param user_id + * @returns {Promise<{user_id: number, nickname_id: number}>} + */ + static async add_user(user_id) { + const done = await require( 'user_nickname.model' ).add_relation( user_id, this.id ); + if (!done) throw new ValidationError('Failed to add nickname'); + return done; + }; + + async get_with_associated_users( id = null, excludes = [] ) { + const id_int = parseInt( id, 10 ); + let query_str = ` + SELECT n.id, n.nickname, n.created_by_id, n.created_at, concat(db.first_name, ' ', db.last_name) as deleted_by, + n.is_deleted, n.deleted_by_id, n.deleted_at, concat(cr.first_name, ' ', cr.last_name) as created_by + FROM phase.nicknames n + LEFT JOIN phase.users cr ON n.created_by_id = cr.id + LEFT JOIN phase.users db ON n.deleted_by_id = db.id + WHERE n.is_deleted = false + `; + const values = []; + if (id_int) { + query_str += ` AND n.id = $1;`; + values.push( id_int ); + } + const result = await phsdb.query(query_str, values, { plain: !isNaN(id_int) } ); + if (!result) throw new ValidationError('Failed to get nickname'); + else return result; + }; + + /** + * Find nickname by value + * @param {string} nickname - Nickname to search for + * @param {string[]} [excludes] - Fields to exclude from result + * @returns {Promise} Nickname instance or null + */ + static async find_by_nickname(nickname, excludes = []) { + return await new Nickname().find_one({ nickname }, excludes); + } + + /** + * Soft delete a nickname and remove the relation between the nickname and any users that it is associated with. + * @param {number|string} deleted_by_id - ID of user performing deletion + * @returns {Promise} Updated nickname instance + * @throws {ValidationError} If deleted_by_id is invalid + */ + async soft_delete(deleted_by_id) { + const deleted_by_id_int = parseInt(deleted_by_id, 10); + if (isNaN(deleted_by_id_int)) { + throw new ValidationError('deleted_by_id must be a valid integer'); + } + await require( 'user_nickname.model' ).remove_relation( this.id ); + return await this.update({ + is_deleted: true, + deleted_at: new Date().toISOString(), + deleted_by_id: deleted_by_id_int + }); + } + + /** + * Get the user who created this nickname + * @returns {Promise} Creator user instance or null + */ + async get_created_by() { + if (!this.created_by_id) return null; + const User = require('./user.model'); + return await new User().find_one({ id: this.created_by_id }, []); + } + + /** + * Get the user who deleted this nickname + * @returns {Promise} Deleter user instance or null + */ + async get_deleted_by() { + if (!this.deleted_by_id) return null; + const User = require('./user.model'); + return await new User().find_one({ id: this.deleted_by_id }, []); + } +} + +module.exports = Nickname; \ No newline at end of file diff --git a/src/models/phone_number.model.js b/src/models/phone_number.model.js new file mode 100644 index 0000000..4ed2c4a --- /dev/null +++ b/src/models/phone_number.model.js @@ -0,0 +1,118 @@ +/** + * @file Phone number model for phase.phone_numbers table + */ + +const { Model, ValidationError } = require('./model'); + +/** + * @typedef {Object} PhoneNumber + * @property {number} id - Phone number ID (primary key) + * @property {string} name - Name of the phone number (e.g., 'mobile') + * @property {string} type - Type of phone number (e.g., 'mobile') + * @property {string} number - Phone number + * @property {number|null} created_by_id - ID of user who created this phone number + * @property {Date} created_at - Creation timestamp + * @property {boolean} is_deleted - Soft delete flag + * @property {number|null} deleted_by_id - ID of user who deleted this phone number + * @property {Date|null} deleted_at - Deletion timestamp + */ + +/** + * Phone number model class + * @extends Model + */ +class PhoneNumber extends Model { + /** + * Create a PhoneNumber instance + * @param {Partial} [props] - Phone number properties + */ + constructor(props) { + super(props); + this.table = 'phase.phone_numbers'; + this.prepend = 'pn'; + this.default_columns = [ + 'id', 'name', 'type', 'number', 'created_by_id', 'created_at', + 'is_deleted', 'deleted_by_id', 'deleted_at' + ]; + this.update_exclude_columns = ['id', 'created_at', 'is_deleted', 'deleted_at', 'deleted_by_id']; + this.base_query = ` + SELECT pn.id, pn.name, pn.type, pn.number, pn.created_by_id, pn.created_at, + pn.is_deleted, pn.deleted_by_id, pn.deleted_at + FROM phase.phone_numbers pn + WHERE pn.is_deleted = false + `; + this.base_list_query = ` + SELECT pn.id, pn.name, pn.type, pn.number, pn.created_by_id, pn.created_at + FROM phase.phone_numbers pn + WHERE pn.is_deleted = false + `; + this.default_order_by = 'ORDER BY pn.number ASC'; + this.instance = _props => new PhoneNumber(_props); + } + + /** + * Create a new phone number + * @param {Omit} phone_data - Phone number data + * @returns {Promise} Created phone number instance + * @throws {ValidationError} If required fields are missing + */ + static async create(phone_data) { + const { name = 'mobile', type = 'mobile', number, created_by_id = null } = phone_data; + if (!number) throw new ValidationError('Missing required field: number'); + const query_str = ` + INSERT INTO phase.phone_numbers (name, type, number, created_by_id) + VALUES ($1, $2, $3, $4) RETURNING *; + `; + const values = [name, type, number, created_by_id]; + const result = await phsdb.query(query_str, values, { plain: true }); + if (!result) throw new ValidationError('Failed to create phone number'); + return new PhoneNumber(result); + }; + + static async add_user(user_id) { + const done = await require( 'user_phone_number.model' ).add_relation( user_id, this.id ); + if (!done) throw new ValidationError('Failed to add phone number'); + return done; + }; + + // noinspection JSUnusedGlobalSymbols + /** + * Find phone number by number + * @param {string} number - Phone number to search for + * @param {string[]} [excludes] - Fields to exclude from result + * @returns {Promise} Phone number instance or null + */ + static async find_by_number(number, excludes = []) { + return await new PhoneNumber().find_one({ number }, excludes); + } + + /** + * Soft delete phone number + * @param {number|string} deleted_by_id - ID of user performing deletion + * @returns {Promise} Updated phone number instance + * @throws {ValidationError} If deleted_by_id is invalid + */ + async soft_delete(deleted_by_id) { + const deleted_by_id_int = parseInt(deleted_by_id, 10); + if (isNaN(deleted_by_id_int)) { + throw new ValidationError('deleted_by_id must be a valid integer'); + } + return await this.update({ + is_deleted: true, + deleted_at: new Date().toISOString(), + deleted_by_id: deleted_by_id_int + }); + } + + /** + * Get the user who created this phone number + * @returns {Promise} Creator user instance or null + */ + async get_created_by() { + if (!this.created_by_id) return null; + const User = require('./user.model'); + return await new User().find_one({ id: this.created_by_id }, []); + } +} + +module.exports = PhoneNumber; \ No newline at end of file diff --git a/src/models/user.model.js b/src/models/user.model.js new file mode 100644 index 0000000..e7c560e --- /dev/null +++ b/src/models/user.model.js @@ -0,0 +1,262 @@ +/** + * @file User model for the users table + */ + +const { Model, ValidationError } = require('./model'); + +/** + * @typedef {Object} User + * @property {number} id - User ID (primary key) + * @property {string} email - User's email (unique) + * @property {string} first_name - User's first name + * @property {string} middle_name - User's middle name + * @property {string} last_name - User's last name + * @property {string|null} initials - User's initials + * @property {number|null} created_by_id - ID of user who created this user + * @property {Date} created_at - Creation timestamp + * @property {boolean} is_deleted - Soft delete flag + * @property {number|null} deleted_by_id - ID of user who deleted this user + * @property {Date|null} deleted_at - Deletion timestamp + * @property {boolean} is_active - Whether user is active + * @property {number|null} deactivated_by_id - ID of user who deactivated this user + * @property {Date|null} deactivated_at - Deactivation timestamp + */ + +/** + * User model class + * @extends Model + */ +class User extends Model { + /** + * Create a User instance + * @param {Partial} [props] - User properties + */ + constructor(props) { + super(props); + this.table = 'phase.users'; + this.prepend = 'u'; + this.default_columns = [ + 'id', 'email', 'first_name', 'middle_name', 'last_name', 'initials', + 'created_by_id', 'created_at', 'is_deleted', 'deleted_by_id', 'deleted_at', + 'is_active', 'deactivated_by_id', 'deactivated_at' + ]; + this.update_exclude_columns = ['id', 'created_at', 'is_deleted', 'deleted_at', 'deleted_by_id']; + this.base_query = ` + SELECT u.id, u.email, u.first_name, u.middle_name, u.last_name, u.initials, + u.created_by_id, u.created_at, u.is_deleted, u.deleted_by_id, u.deleted_at, + u.is_active, u.deactivated_by_id, u.deactivated_at + FROM phase.users u + WHERE u.is_deleted = false + `; + this.base_list_query = ` + SELECT u.id, u.email, u.first_name, u.middle_name, u.last_name, u.initials, + u.created_by_id, u.created_at, u.is_active, u.deactivated_by_id, u.deactivated_at + FROM phase.users u + WHERE u.is_deleted = false + `; + this.default_order_by = 'ORDER BY u.email ASC'; + this.instance = _props => new User(_props); + } + + /** + * Create a new user + * @param {Omit} user_data - User data + * @returns {Promise} Created user instance + * @throws {ValidationError} If required fields are missing + */ + static async create(user_data) { + const { + email, first_name, middle_name = '', last_name, initials = null, + created_by_id = null, is_active = true, deactivated_by_id = null, deactivated_at = null + } = user_data; + if (!email || !first_name || !last_name) { + throw new ValidationError('Missing required fields: email, first_name, last_name'); + } + const query_str = ` + INSERT INTO phase.users ( + email, first_name, middle_name, last_name, initials, created_by_id, + is_active, deactivated_by_id, deactivated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; + `; + const values = [ + email, first_name, middle_name, last_name, initials, created_by_id, + is_active, deactivated_by_id, deactivated_at + ]; + const result = await phsdb.query(query_str, values, { plain: true }); + if (!result) throw new ValidationError('Failed to create user'); + await this.createPhoneNumbers(); + await this.createAddresses(); + await this.createNickNames(); + return new User(result); + }; + + static async createPhoneNumbers( phone_numbers) { + if (phone_numbers?.length > 0) { + const pn = Array.from( new Set( phone_numbers ) ); + const phoneArray = []; + for (let phone_number of pn) { + const phone = await require('./phone_number.model').create(phone_number); + await phone.add_user(this.id) + .then( result => phoneArray.push(result) ) + .catch( err => throw new ValidationError( err.message ) ); + } + } else return true; + }; + + static async createAddresses( addresses) { + if (addresses?.length > 0) { + const addr = Array.from( new Set( addresses ) ); + const addrArray = []; + for (let address of addr) { + const _address = await require('./address.model').create(address); + await _address.add_user(this.id) + .then( result => addrArray.push(result) ) + .catch( err => throw new ValidationError( err.message ) ); + } + } else return true; + }; + + static async createNickNames( nicknames) { + if (nicknames?.length > 0) { + const names = Array.from( new Set( nicknames ) ); + const nameArray = []; + for (let name of names) { + const nickname = await require('./nickname.model').create(name); + await nickname.add_relation(this.id) + .then( result => nameArray.push(result) ) + .catch( err => throw new ValidationError( err.message ) ); + } + } else return true; + }; + + /** + * Find user by email + * @param {string} email - Email to search for + * @param {string[]} [excludes] - Fields to exclude from result + * @returns {Promise} User instance or null + */ + static async find_by_email(email, excludes = []) { + return await new User().find_one({ email }, excludes); + } + + /** + * Find active users + * @param {string[]} [excludes] - Fields to exclude from result + * @param {{name: string, direction?: 'asc'|'desc'}||null} [order_by] - Order by configuration + * @param {number} [limit=100] - Maximum number of records + * @param {number} [offset=0] - Number of records to skip + * @returns {Promise} Array of active users + */ + static async find_active(excludes = [], order_by = null, limit = 100, offset = 0) { + return await new User().find_many({ is_active: true }, excludes, order_by, limit, offset); + } + + // noinspection JSUnusedGlobalSymbols + /** + * Find deleted users + * @param {string[]} [excludes] - Fields to exclude from result + * @param {{name: string, direction?: 'asc'|'desc'}||null} [order_by] - Order by configuration + * @param {number} [limit=100] - Maximum number of records + * @param {number} [offset=0] - Number of records to skip + * @returns {Promise} Array of deleted users + */ + static async find_deleted(excludes = [], order_by = null, limit = 100, offset = 0) { + const query_str = this.prototype.base_list_query.replace('WHERE u.is_deleted = false', 'WHERE u.is_deleted = true'); + const instance = this.prototype.instance; + const { keys, values } = this.prototype.build_where({}, this.prototype.default_columns); + values.push(limit, offset); + const results = await phsdb.query( + ` + ${query_str} + ${this.prototype.where_clause(keys, this.prototype.prepend)} + ${order_by ? `ORDER BY ${order_by.name} ${order_by.direction ?? 'asc'}` : this.prototype.default_order_by ?? ''} + LIMIT $${values.length - 1} OFFSET $${values.length} + `, + values + ); + return results.map(result => { + const found = instance(result); + excludes?.forEach(e => delete found[e]); + return found; + }); + } + + /** + * Deactivate user + * @param {number|string} deactivated_by_id - ID of user performing deactivation + * @returns {Promise} Updated user instance + * @throws {ValidationError} If deactivated_by_id is invalid + */ + async deactivate(deactivated_by_id) { + const deactivated_by_id_int = parseInt(deactivated_by_id, 10); + if (isNaN(deactivated_by_id_int)) { + throw new ValidationError('deactivated_by_id must be a valid integer'); + } + return await this.update({ + is_active: false, + deactivated_at: new Date().toISOString(), + deactivated_by_id: deactivated_by_id_int + }); + } + + /** + * Reactivate user + * @returns {Promise} Updated user instance + */ + async reactivate() { + return await this.update({ + is_active: true, + deactivated_at: null, + deactivated_by_id: null + }); + } + + /** + * Soft delete user + * @param {number|string} deleted_by_id - ID of user performing deletion + * @returns {Promise} Updated user instance + * @throws {ValidationError} If deleted_by_id is invalid + */ + async soft_delete(deleted_by_id) { + const deleted_by_id_int = parseInt(deleted_by_id, 10); + if (isNaN(deleted_by_id_int)) { + throw new ValidationError('deleted_by_id must be a valid integer'); + } + return await this.update({ + is_deleted: true, + deleted_at: new Date().toISOString(), + deleted_by_id: deleted_by_id_int + }); + } + + /** + * Check if user is active + * @returns {boolean} True if user is active + */ + is_active() { + return this.is_active === true; + } + + // noinspection JSUnusedGlobalSymbols + /** + * Get the user who created this user + * @returns {Promise} Creator user instance or null + */ + async get_created_by() { + if (!this.created_by_id) return null; + return await new User().find_one({ id: this.created_by_id }, []); + } + + // noinspection JSUnusedGlobalSymbols + /** + * Get user data without sensitive information + * @returns {Omit} User data + */ + to_safe_json() { + const { password, ...safe_data } = this.toJSON(); + // noinspection JSValidateTypes + return safe_data; + } +} + +module.exports = User; \ No newline at end of file diff --git a/src/models/user_address.model.js b/src/models/user_address.model.js new file mode 100644 index 0000000..8312340 --- /dev/null +++ b/src/models/user_address.model.js @@ -0,0 +1,87 @@ +/** + * @file User addresses model for phase.user_addresses table + */ + +const { Model, ValidationError } = require( './model' ); + +/** + * @typedef {Object} UserAddress + * @property {number} user_id - User ID (foreign key) + * @property {number} address_id - Address ID (foreign key) + */ + +/** + * User address model class + * @extends Model + */ +class UserAddress extends Model { + /** + * Create a UserAddress instance + * @param {Partial} [props] - User address properties + */ + constructor( props ) { + super( props ); + this.table = 'phase.user_addresses'; + this.prepend = 'ua'; + this.default_columns = ['user_id', 'address_id']; + this.update_exclude_columns = ['user_id', 'address_id']; + this.base_query = ` + SELECT ua.user_id, ua.address_id + FROM phase.user_addresses ua + `; + this.base_list_query = this.base_query; + this.default_order_by = 'ORDER BY ua.user_id ASC'; + this.instance = _props => new UserAddress( _props ); + }; + + /** + * Add a user-address relation + * @param {number|string} user_id - User ID + * @param {number|string} address_id - Address ID + * @returns {Promise} Created relation instance + * @throws {ValidationError} If IDs are invalid + */ + static async add_relation( user_id, address_id ) { + const user_id_int = parseInt( user_id, 10 ); + const address_id_int = parseInt( address_id, 10 ); + if (isNaN( user_id_int ) || isNaN( address_id_int )) { + throw new ValidationError( 'user_id and address_id must be valid integers' ); + } + const query_str = ` + INSERT INTO phase.user_addresses (user_id, address_id) + VALUES ($1, $2) + RETURNING *; + `; + const values = [user_id_int, address_id_int]; + const result = await phsdb.query( query_str, values, { plain:true } ); + if (!result) throw new ValidationError( 'Failed to add user-address relation' ); + return new UserAddress( result ); + }; + + /** + * Remove a user-address relation + * @param {number|string} address_id - Address ID + * @param {number|string|undefined} [user_id = undefined] - User ID + * @returns {Promise} Deleted relation instance or null + * @throws {ValidationError} If IDs are invalid + */ + static async remove_relation( address_id, user_id = undefined ) { + const user_id_int = parseInt( user_id, 10 ); + const address_id_int = parseInt( address_id, 10 ); + if (isNaN( address_id_int )) { + throw new ValidationError( 'user_id and address_id must be valid integers' ); + } + const query_str = ` + DELETE + FROM phase.user_addresses + WHERE address_id = $1 ${ user_id_int ? 'AND user_id = $2' : '' } + RETURNING *; + `; + const values = [address_id_int]; + if (!isNaN( user_id_int )) values.push( user_id_int ); + const result = await phsdb.query( query_str, values, { plain:!isNaN( user_id_int ) } ); + return result ? new UserAddress( result ) : null; + }; +} + +module.exports = UserAddress; \ No newline at end of file diff --git a/src/models/user_nickname.model.js b/src/models/user_nickname.model.js new file mode 100644 index 0000000..b7b15e3 --- /dev/null +++ b/src/models/user_nickname.model.js @@ -0,0 +1,87 @@ +/** + * @file User nicknames model for phase.user_nicknames table + */ + +const { Model, ValidationError } = require( './model' ); + +/** + * @typedef {Object} UserNickname + * @property {number} user_id - User ID (foreign key) + * @property {number} nickname_id - Nickname ID (foreign key) + */ + +/** + * User nickname model class + * @extends Model + */ +class UserNickname extends Model { + /** + * Create a UserNickname instance + * @param {Partial} [props] - User nickname properties + */ + constructor( props ) { + super( props ); + this.table = 'phase.user_nicknames'; + this.prepend = 'un'; + this.default_columns = ['user_id', 'nickname_id']; + this.update_exclude_columns = ['user_id', 'nickname_id']; + this.base_query = ` + SELECT un.user_id, un.nickname_id + FROM phase.user_nicknames un + `; + this.base_list_query = this.base_query; + this.default_order_by = 'ORDER BY un.user_id ASC'; + this.instance = _props => new UserNickname( _props ); + }; + + /** + * Add a user-nickname relation + * @param {number|string} user_id - User ID + * @param {number|string} nickname_id - Nickname ID + * @returns {Promise} Created relation instance + * @throws {ValidationError} If IDs are invalid + */ + static async add_relation( user_id, nickname_id ) { + const user_id_int = parseInt( user_id, 10 ); + const nickname_id_int = parseInt( nickname_id, 10 ); + if (isNaN( user_id_int ) || isNaN( nickname_id_int )) { + throw new ValidationError( 'user_id and nickname_id must be valid integers' ); + } + const query_str = ` + INSERT INTO phase.user_nicknames (user_id, nickname_id) + VALUES ($1, $2) + RETURNING *; + `; + const values = [user_id_int, nickname_id_int]; + const result = await phsdb.query( query_str, values, { plain:true } ); + if (!result) throw new ValidationError( 'Failed to add user-nickname relation' ); + return new UserNickname( result ); + }; + + /** + * Remove a user-nickname relation + * @param {number|string} nickname_id - Nickname ID + * @param {number|string|undefined} [user_id = undefined] - User ID + * @returns {Promise} Deleted relation instance or null + * @throws {ValidationError} If IDs are invalid + */ + static async remove_relation( nickname_id, user_id = undefined ) { + const user_id_int = parseInt( user_id, 10 ); + const nickname_id_int = parseInt( nickname_id, 10 ); + if (isNaN( nickname_id_int )) { + throw new ValidationError( 'nickname_id must be valid integers' ); + } + const query_str = ` + DELETE + FROM phase.user_nicknames + WHERE nickname_id = $1 ${ user_id ? 'AND user_id = $2' : '' } + RETURNING *; + `; + const values = [nickname_id_int]; + if (!isNaN( user_id_int )) values.push( user_id_int ); + const result = await phsdb.query( query_str, values, { plain: !isNaN( user_id_int ) } ); + return result ? new UserNickname( result ) : null; + }; +} + +module.exports = UserNickname; \ No newline at end of file diff --git a/src/models/user_phone_number.model.js b/src/models/user_phone_number.model.js new file mode 100644 index 0000000..4a00732 --- /dev/null +++ b/src/models/user_phone_number.model.js @@ -0,0 +1,87 @@ +/** + * @file User phone numbers model for phase.user_phone_numbers table + */ + +const { Model, ValidationError } = require( './model' ); + +/** + * @typedef {Object} UserPhoneNumber + * @property {number} user_id - User ID (foreign key) + * @property {number} phone_number_id - Phone number ID (foreign key) + */ + +/** + * User phone number model class + * @extends Model + */ +class UserPhoneNumber extends Model { + /** + * Create a UserPhoneNumber instance + * @param {Partial} [props] - User phone number properties + */ + constructor( props ) { + super( props ); + this.table = 'phase.user_phone_numbers'; + this.prepend = 'upn'; + this.default_columns = ['user_id', 'phone_number_id']; + this.update_exclude_columns = ['user_id', 'phone_number_id']; + this.base_query = ` + SELECT upn.user_id, upn.phone_number_id + FROM phase.user_phone_numbers upn + `; + this.base_list_query = this.base_query; + this.default_order_by = 'ORDER BY upn.user_id ASC'; + this.instance = _props => new UserPhoneNumber( _props ); + }; + + /** + * Add a user-phone number relation + * @param {number|string} user_id - User ID + * @param {number|string} phone_number_id - Phone number ID + * @returns {Promise} Created relation instance + * @throws {ValidationError} If IDs are invalid + */ + static async add_relation( user_id, phone_number_id ) { + const user_id_int = parseInt( user_id, 10 ); + const phone_number_id_int = parseInt( phone_number_id, 10 ); + if (isNaN( user_id_int ) || isNaN( phone_number_id_int )) { + throw new ValidationError( 'user_id and phone_number_id must be valid integers' ); + } + const query_str = ` + INSERT INTO phase.user_phone_numbers (user_id, phone_number_id) + VALUES ($1, $2) + RETURNING *; + `; + const values = [user_id_int, phone_number_id_int]; + const result = await phsdb.query( query_str, values, { plain:true } ); + if (!result) throw new ValidationError( 'Failed to add user-phone number relation' ); + return new UserPhoneNumber( result ); + }; + + /** + * Remove a user-phone number relation + * @param {number|string} phone_number_id - Phone number ID + * @param {number|string|undefined} [user_id = undefined] - User ID + * @returns {Promise} Deleted relation instance or null + * @throws {ValidationError} If IDs are invalid + */ + static async remove_relation( phone_number_id, user_id = undefined ) { + const user_id_int = parseInt( user_id, 10 ); + const phone_number_id_int = parseInt( phone_number_id, 10 ); + if (isNaN( phone_number_id_int )) { + throw new ValidationError( 'phone_number_id must be valid integers' ); + } + let query_str = ` + DELETE + FROM phase.user_phone_numbers + WHERE phone_number_id = $1 ${ user_id_int ? 'AND user_id = $2' : '' } + RETURNING *; + `; + const values = [phone_number_id_int]; + if (!isNaN( user_id_int )) values.push( user_id_int ); + const result = await phsdb.query( query_str, values, { plain:!isNaN( user_id_int ) } ); + return result ? new UserPhoneNumber( result ) : null; + }; +} + +module.exports = UserPhoneNumber; \ No newline at end of file diff --git a/src/routes/index.js b/src/routes/index.js index ab3c644..db18efa 100755 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -6,5 +6,13 @@ module.exports.APIRoutes = ( passport ) => { router.use( '/git', require( './git.routes' )( passport ) ); router.use( '/docker', require( './docker.routes' )( passport ) ); router.use( '/vpn', require( './vpn.routes' )( passport ) ); + router.use( '/users', require( './user.routes' )( passport ) ); + router.use( '/phone_numbers', require( './phone_numbers.routes' )( passport ) ); + router.use( '/user_phone_numbers', require( './user_phone_numbers.routes' )( passport ) ); + router.use( '/nicknames', require( './nicknames.routes' )( passport ) ); + router.use( '/user_nicknames', require( './user_nicknames.routes' )( passport ) ); + router.use( '/addresses', require( './addresses.routes' )( passport ) ); + router.use( '/user_addresses', require( './user_addresses.routes' )( passport ) ); + router.use( '/authentication', require( './authentication.routes' )( passport ) ); return router; }; diff --git a/src/routes/user.routes.js b/src/routes/user.routes.js new file mode 100644 index 0000000..914e9ae --- /dev/null +++ b/src/routes/user.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); +const router = express.Router(); +const { validate_auth } = require('../middleware/routeHelpers'); +const users_controller = require('../controllers/users.controller'); + +module.exports = (passport) => { + router.post('/create', validate_auth(passport), users_controller.create); + router.get('/email/:email', validate_auth(passport), users_controller.find_by_email); + router.get('/active', validate_auth(passport), users_controller.find_active); + router.get('/:id', validate_auth(passport), users_controller.find_one); + router.get('/', validate_auth(passport), users_controller.find_many); + router.put('/:id', validate_auth(passport), users_controller.update); + router.put('/:id/deactivate', validate_auth(passport), users_controller.deactivate); + router.put('/:id/reactivate', validate_auth(passport), users_controller.reactivate); + router.put('/:id/soft_delete', validate_auth(passport), users_controller.soft_delete); + return router; +}; \ No newline at end of file