]> PHS Git Server - phs-api.git/commitdiff
Initial models loaded for db tables.
authorcharleswrayjr <charleswrayjr@gmail.com>
Fri, 12 Sep 2025 01:10:36 +0000 (20:10 -0500)
committercharleswrayjr <charleswrayjr@gmail.com>
Fri, 12 Sep 2025 01:10:36 +0000 (20:10 -0500)
src/middleware/passport.js
src/models/address.model.js [new file with mode: 0644]
src/models/model.js
src/models/nickname.model.js [new file with mode: 0644]
src/models/phone_number.model.js [new file with mode: 0644]
src/models/user.model.js [new file with mode: 0644]
src/models/user_address.model.js [new file with mode: 0644]
src/models/user_nickname.model.js [new file with mode: 0644]
src/models/user_phone_number.model.js [new file with mode: 0644]
src/routes/index.js
src/routes/user.routes.js [new file with mode: 0644]

index 0dcbede77e972d09f2af668bd360d75aa0647372..9e0975c7638a639de4ede8f13fc0b7033e5de183 100755 (executable)
@@ -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 (file)
index 0000000..76ea918
--- /dev/null
@@ -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<Address>} [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, 'id'|'created_at'|'is_deleted'|'deleted_by_id'|'deleted_at'>} address_data - Address data
+   * @returns {Promise<Address>} 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|null>[]} 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<Address>} 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<User|null>} 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
index 355741f324f907a2553db2d1c959616c6d974560..3a91a6c5d05d0a94e7e56ee063dbc8648d94f27d 100755 (executable)
-// 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<Object|null>} 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<Object|null>} 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<Object[]>} 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<Object[]>} 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<Object>} 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<any>} 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<any>} 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 (file)
index 0000000..3f7a9f9
--- /dev/null
@@ -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<Nickname>} [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, 'id'|'created_at'|'is_deleted'|'deleted_by_id'|'deleted_at'>} nickname_data - Nickname data
+   * @returns {Promise<Nickname>} 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|null>} 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<Nickname>} 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<User|null>} 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<User|null>} 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 (file)
index 0000000..4ed2c4a
--- /dev/null
@@ -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<PhoneNumber>} [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<PhoneNumber, 'id'|'created_at'|'is_deleted'|'deleted_by_id'|'deleted_at'>} phone_data - Phone number data
+   * @returns {Promise<PhoneNumber>} 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<PhoneNumber|null>} 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<PhoneNumber>} 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<User|null>} 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 (file)
index 0000000..e7c560e
--- /dev/null
@@ -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<User>} [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, 'id'|'created_at'|'is_deleted'|'deleted_by_id'|'deleted_at'>} user_data - User data
+   * @returns {Promise<User>} 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|null>} 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<User[]>} 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<User[]>} 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<User>} 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<User>} 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<User>} 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<User|null>} 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, 'password'>} 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 (file)
index 0000000..8312340
--- /dev/null
@@ -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<UserAddress>} [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<UserAddress>} 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<UserAddress|null>} 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 (file)
index 0000000..b7b15e3
--- /dev/null
@@ -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<UserNickname>} [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<UserNickname>} 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<UserNickname|null>} 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 (file)
index 0000000..4a00732
--- /dev/null
@@ -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<UserPhoneNumber>} [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<UserPhoneNumber>} 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<UserPhoneNumber|null>} 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
index ab3c644a9e7de1447a48c82d46be0fb0a22c50ee..db18efa37efeb5b02a105d192a8fea703c24278c 100755 (executable)
@@ -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 (file)
index 0000000..914e9ae
--- /dev/null
@@ -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