]> PHS Git Server - phs-api.git/commitdiff
#PA-1 setting up new attachment models, routes, and controllers.
authorcharleswrayjr <charleswrayjr@gmail.com>
Fri, 26 Sep 2025 05:48:21 +0000 (00:48 -0500)
committercharleswrayjr <charleswrayjr@gmail.com>
Fri, 26 Sep 2025 05:48:21 +0000 (00:48 -0500)
src/controllers/post.controller.js
src/controllers/post_files.controller.js [new file with mode: 0644]
src/controllers/post_users.controller.js [new file with mode: 0644]
src/models/post.model.js
src/models/post_files.model.js [new file with mode: 0644]
src/models/post_reactions.model.js [new file with mode: 0644]
src/models/post_users.model.js [new file with mode: 0644]
src/routes/post.routes.js
src/routes/post_files.routes.js [new file with mode: 0644]
src/routes/post_users.routes.js [new file with mode: 0644]

index 09f21a247c6345ed8f7c3ece21b286caea56bc3c..adfc6bbe42a829a1bf68e10b123bbc7ee1f7d27e 100644 (file)
@@ -1,10 +1,10 @@
 /**
  * @file Post controller for handling post-related API requests
+ * @module PostController
  */
 
-const db = require('../models');
-const createError = require('http-errors');
-const logger = global.logger;
+const db = require( '../models' );
+const createError = require( 'http-errors' );
 
 /**
  * Post controller
@@ -18,14 +18,37 @@ module.exports = {
    * @param {Function} next - Express next middleware function
    * @returns {Promise<void>}
    */
-  async create(req, res, next) {
+  async create( req, res, next ) {
     try {
-      const post_data = req.body;
-      const post = await db.post.create(post_data);
-      res.json(post);
-    } catch (error) {
-      logger.error(`Create post error: ${error.message}`);
-      next(createError(error.status || 409, error.message));
+      const { title, content, post_type, visibility, specific_users = [] } = req.body;
+      const files = req.files || [];
+      const post_data = {
+        author_id: req.user.id,
+        title,
+        content,
+        post_type,
+        visibility,
+        specific_users,
+        created_by_id: req.user.id,
+        file_ids: []
+      };
+      for ( const file of files ) {
+        const file_data = {
+          user_id: req.user.id,
+          file_path: `/uploads/${ file.filename }`,
+          file_type: file.mimetype.startsWith( 'image' ) ? 'image' : 'video',
+          visibility,
+          specific_users,
+          created_by_id: req.user.id
+        };
+        const uploaded_file = await db.file.create( file_data );
+        post_data.file_ids.push( uploaded_file.id );
+      }
+      const post = await db.post.create( post_data );
+      res.json( post );
+    } catch ( error ) {
+      logger.error( `Create post error: ${ error.message }` );
+      next( createError( error.status || 409, error.message ) );
     }
   },
 
@@ -36,15 +59,15 @@ module.exports = {
    * @param {Function} next - Express next middleware function
    * @returns {Promise<void>}
    */
-  async find_by_title(req, res, next) {
+  async find_by_title( req, res, next ) {
     try {
       const { title } = req.params;
-      const post = await db.post.find_by_title(title);
-      if (!post) return next(createError(404, 'Post not found'));
-      res.json(post);
-    } catch (error) {
-      logger.error(`Find post by title error: ${error.message}`);
-      next(createError(error.status || 500, error.message));
+      const post = await db.post.find_by_title( title );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      res.json( post );
+    } catch ( error ) {
+      logger.error( `Find post by title error: ${ error.message }` );
+      next( createError( error.status || 500, error.message ) );
     }
   },
 
@@ -55,15 +78,15 @@ module.exports = {
    * @param {Function} next - Express next middleware function
    * @returns {Promise<void>}
    */
-  async find_one(req, res, next) {
+  async find_one( req, res, next ) {
     try {
       const { id } = req.params;
-      const post = await db.post.find_one({ id: parseInt(id) });
-      if (!post) return next(createError(404, 'Post not found'));
-      res.json(post);
-    } catch (error) {
-      logger.error(`Find post error: ${error.message}`);
-      next(createError(error.status || 500, error.message));
+      const post = await db.post.find_one( { id: parseInt( id ) } );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      res.json( post );
+    } catch ( error ) {
+      logger.error( `Find post error: ${ error.message }` );
+      next( createError( error.status || 500, error.message ) );
     }
   },
 
@@ -74,14 +97,22 @@ module.exports = {
    * @param {Function} next - Express next middleware function
    * @returns {Promise<void>}
    */
-  async find_many(req, res, next) {
+  async find_many( req, res, next ) {
     try {
-      const { limit = 100, offset = 0, ...where } = req.query;
-      const posts = await db.post.find_many(where, [], null, parseInt(limit), parseInt(offset));
-      res.json(posts);
-    } catch (error) {
-      logger.error(`Find many posts error: ${error.message}`);
-      next(createError(error.status || 500, error.message));
+      const { limit = '100', offset = '0', ...where } = req.query;
+      where.is_deleted = false;
+      if ( req.user ) {
+        where[ '$or' ] = [
+          { visibility: 'family' },
+          { author_id: req.user.id },
+          { specific_users: { $contains: [ req.user.id ] } }
+        ];
+      }
+      const posts = await db.post.find_many( where, [], null, parseInt( limit ), parseInt( offset ) );
+      res.json( posts );
+    } catch ( error ) {
+      logger.error( `Find many posts error: ${ error.message }` );
+      next( createError( error.status || 500, error.message ) );
     }
   },
 
@@ -92,17 +123,20 @@ module.exports = {
    * @param {Function} next - Express next middleware function
    * @returns {Promise<void>}
    */
-  async update(req, res, next) {
+  async update( req, res, next ) {
     try {
       const { id } = req.params;
       const post_data = req.body;
-      const post = await db.post.instance().find_one({ id: parseInt(id) });
-      if (!post) return next(createError(404, 'Post not found'));
-      const updated_post = await post.update(post_data);
-      res.json(updated_post);
-    } catch (error) {
-      logger.error(`Update post error: ${error.message}`);
-      next(createError(error.status || 400, error.message));
+      const post = await db.post.instance().find_one( { id: parseInt( id ) } );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      if ( post.author_id !== req.user.id && !req.user.roles.includes( 'admin' ) ) {
+        return next( createError( 403, 'Unauthorized to update this post' ) );
+      }
+      const updated_post = await post.update( post_data );
+      res.json( updated_post );
+    } catch ( error ) {
+      logger.error( `Update post error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
     }
   },
 
@@ -113,17 +147,83 @@ module.exports = {
    * @param {Function} next - Express next middleware function
    * @returns {Promise<void>}
    */
-  async soft_delete(req, res, next) {
+  async soft_delete( req, res, next ) {
     try {
       const { id } = req.params;
       const { deleted_by_id } = req.body;
-      const post = await db.post.instance().find_one({ id: parseInt(id) });
-      if (!post) return next(createError(404, 'Post not found'));
-      const deleted_post = await post.soft_delete(deleted_by_id);
-      res.json(deleted_post);
-    } catch (error) {
-      logger.error(`Soft delete post error: ${error.message}`);
-      next(createError(error.status || 400, error.message));
+      const post = await db.post.instance().find_one( { id: parseInt( id ) } );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      if ( post.author_id !== req.user.id && !req.user.roles.includes( 'admin' ) ) {
+        return next( createError( 403, 'Unauthorized to delete this post' ) );
+      }
+      const deleted_post = await post.soft_delete( deleted_by_id || req.user.id );
+      res.json( deleted_post );
+    } catch ( error ) {
+      logger.error( `Soft delete post error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
+    }
+  },
+
+  /**
+   * Add a comment to a post
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async add_comment( req, res, next ) {
+    try {
+      const { id } = req.params;
+      const { content, parent_id } = req.body;
+      const comment_data = {
+        post_id: parseInt( id ),
+        user_id: req.user.id,
+        parent_id: parent_id ? parseInt( parent_id ) : null,
+        content
+      };
+      const comment = await db.comments.create( comment_data );
+      res.json( comment );
+    } catch ( error ) {
+      logger.error( `Add comment error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
+    }
+  },
+
+  /**
+   * Add a reaction to a post
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async add_reaction( req, res, next ) {
+    try {
+      const { id } = req.params;
+      const { reaction } = req.body;
+      const reaction_data = await db.post_reactions.add( id, req.user.id, reaction );
+      res.json( reaction_data );
+    } catch ( error ) {
+      logger.error( `Add post reaction error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
+    }
+  },
+
+  /**
+   * Add a reaction to a comment
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async add_comment_reaction( req, res, next ) {
+    try {
+      const { commentId } = req.params;
+      const { reaction } = req.body;
+      const reaction_data = await db.comment_reactions.add( commentId, req.user.id, reaction );
+      res.json( reaction_data );
+    } catch ( error ) {
+      logger.error( `Add comment reaction error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
     }
   }
 };
\ No newline at end of file
diff --git a/src/controllers/post_files.controller.js b/src/controllers/post_files.controller.js
new file mode 100644 (file)
index 0000000..8f236ea
--- /dev/null
@@ -0,0 +1,82 @@
+/**
+ * @file Post files controller for handling post-files relation API requests
+ * @module PostFilesController
+ */
+
+const db = require( '../models' );
+const createError = require( 'http-errors' );
+
+/**
+ * Post files controller
+ * @type {Object}
+ */
+module.exports = {
+  /**
+   * Add a post-file relation
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async add_relation( req, res, next ) {
+    try {
+      const { post_id, file_id } = req.body;
+      const post = await db.post.find_one( { id: parseInt( post_id ) } );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      if ( post.author_id !== req.user.id && !req.user.roles.includes( 'admin' ) ) {
+        return next( createError( 403, 'Unauthorized to add relation to this post' ) );
+      }
+      const file = await db.file.find_one( { id: parseInt( file_id ) } );
+      if ( !file ) return next( createError( 404, 'File not found' ) );
+      const relation = await db.post_files.add_relation( post_id, file_id );
+      res.json( relation );
+    } catch ( error ) {
+      logger.error( `Add post-file relation error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
+    }
+  },
+
+  /**
+   * Remove a post-file relation
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async remove_relation( req, res, next ) {
+    try {
+      const { post_id, file_id } = req.params;
+      const post = await db.post.find_one( { id: parseInt( post_id ) } );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      if ( post.author_id !== req.user.id && !req.user.roles.includes( 'admin' ) ) {
+        return next( createError( 403, 'Unauthorized to remove relation from this post' ) );
+      }
+      const file = await db.file.find_one( { id: parseInt( file_id ) } );
+      if ( !file ) return next( createError( 404, 'File not found' ) );
+      const relation = await db.post_files.remove_relation( post_id, file_id );
+      res.json( relation || { message: 'Relation removed' } );
+    } catch ( error ) {
+      logger.error( `Remove post-file relation error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
+    }
+  },
+
+  /**
+   * Find post-file relations by post ID
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async find_by_post_id( req, res, next ) {
+    try {
+      const { post_id } = req.params;
+      const { limit = '100', offset = '0' } = req.query;
+      const relations = await db.post_files.find_by_post_id( parseInt( post_id ), [], null, parseInt( limit ), parseInt( offset ) );
+      res.json( relations );
+    } catch ( error ) {
+      logger.error( `Find post-file relations by post ID error: ${ error.message }` );
+      next( createError( error.status || 500, error.message ) );
+    }
+  }
+};
\ No newline at end of file
diff --git a/src/controllers/post_users.controller.js b/src/controllers/post_users.controller.js
new file mode 100644 (file)
index 0000000..8367473
--- /dev/null
@@ -0,0 +1,82 @@
+/**
+ * @file Post users controller for handling post-users relation API requests
+ * @module PostUsersController
+ */
+
+const db = require( '../models' );
+const createError = require( 'http-errors' );
+
+/**
+ * Post users controller
+ * @type {Object}
+ */
+module.exports = {
+  /**
+   * Add a post-user relation
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async add_relation( req, res, next ) {
+    try {
+      const { post_id, user_id } = req.body;
+      const post = await db.post.find_one( { id: parseInt( post_id ) } );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      if ( post.author_id !== req.user.id && !req.user.roles.includes( 'admin' ) ) {
+        return next( createError( 403, 'Unauthorized to add relation to this post' ) );
+      }
+      const user = await db.user.find_one( { id: parseInt( user_id ) } );
+      if ( !user ) return next( createError( 404, 'User not found' ) );
+      const relation = await db.post_users.add_relation( post_id, user_id );
+      res.json( relation );
+    } catch ( error ) {
+      logger.error( `Add post-user relation error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
+    }
+  },
+
+  /**
+   * Remove a post-user relation
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async remove_relation( req, res, next ) {
+    try {
+      const { post_id, user_id } = req.params;
+      const post = await db.post.find_one( { id: parseInt( post_id ) } );
+      if ( !post ) return next( createError( 404, 'Post not found' ) );
+      if ( post.author_id !== req.user.id && !req.user.roles.includes( 'admin' ) ) {
+        return next( createError( 403, 'Unauthorized to remove relation from this post' ) );
+      }
+      const user = await db.user.find_one( { id: parseInt( user_id ) } );
+      if ( !user ) return next( createError( 404, 'User not found' ) );
+      const relation = await db.post_users.remove_relation( post_id, user_id );
+      res.json( relation || { message: 'Relation removed' } );
+    } catch ( error ) {
+      logger.error( `Remove post-user relation error: ${ error.message }` );
+      next( createError( error.status || 400, error.message ) );
+    }
+  },
+
+  /**
+   * Find post-user relations by post ID
+   * @param {Object} req - Express request object
+   * @param {Object} res - Express response object
+   * @param {Function} next - Express next middleware function
+   * @returns {Promise<void>}
+   */
+  async find_by_post_id( req, res, next ) {
+    try {
+      const { post_id } = req.params;
+      const { limit = '100', offset = '0' } = req.query;
+      const relations = await db.post_users.find_by_post_id( parseInt( post_id ), [], null, parseInt( limit ), parseInt( offset ) );
+      res.json( relations );
+    } catch ( error ) {
+      logger.error( `Find post-user relations by post ID error: ${ error.message }` );
+      next( createError( error.status || 500, error.message ) );
+    }
+  }
+};
\ No newline at end of file
index cba10d872cb921e1091408111c3654e57ab9bc41..b3742eb9176b7ac3ca67b330b7f58de86c750ce9 100644 (file)
@@ -1,22 +1,27 @@
 /**
  * @file Post model for phase.posts table
+ * @module Post
  */
 
-const { Model, NotFoundError, ValidationError, FailedToCreateError } = require('./model');
+const { Model, NotFoundError, ValidationError, FailedToCreateError } = require( './model' );
 
 /**
  * @typedef {Object} Post
  * @property {number} id - Post ID (primary key)
- * @property {number} user_id - User ID (foreign key)
+ * @property {number} author_id - User ID (foreign key)
  * @property {string} title - Post title
  * @property {string} content - Post content
  * @property {string} post_type - Post type (e.g., 'blog', 'vlog')
- * @property {string} visibility - Visibility (e.g., 'private', 'family', 'public')
+ * @property {string} visibility - Visibility (e.g., 'private', 'family', 'specific')
  * @property {number|null} created_by_id - ID of user who created this post
  * @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 post
  * @property {Date|null} deleted_at - Deletion timestamp
+ * @property {Object[]} attached_files - Array of attached file IDs
+ * @property {Object[]} specific_users - Array of user IDs for specific visibility
+ * @property {Object[]} reactions - Array of post reactions
+ * @property {Object[]} comments - Array of comments
  */
 
 /**
@@ -28,61 +33,120 @@ class Post extends Model {
    * Create a Post instance
    * @param {Partial<Post>} [props] - Post properties
    */
-  constructor(props) {
-    super(props);
+  constructor( props ) {
+    super( props );
+    /** @type {string} Database table name */
     this.table = 'phase.posts';
+    /** @type {string} Table alias for queries */
     this.prepend = 'p';
-    this.default_columns = [
-      'id', 'user_id', 'title', 'content', 'post_type', 'visibility',
-      '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'];
+    /** @type {string[]} Allowed columns for queries */
+    this.default_columns = [ 'id', 'author_id', 'title', 'content', 'post_type', 'visibility', 'created_by_id', 'created_at', 'is_deleted', 'deleted_by_id', 'deleted_at' ];
+    /** @type {string[]} Columns excluded from updates */
+    this.update_exclude_columns = [ 'id', 'created_at', 'is_deleted', 'deleted_at', 'deleted_by_id' ];
+    /** @type {string} Base query for single record retrieval */
     this.base_query = `
-      SELECT p.id, p.user_id, p.title, p.content, p.post_type, p.visibility,
-             p.created_by_id, p.created_at, p.is_deleted, p.deleted_by_id, p.deleted_at
-      FROM phase.posts p
-      WHERE p.is_deleted = false
-    `;
-    this.base_list_query = `
-      SELECT p.id, p.user_id, p.title, p.content, p.post_type, p.visibility,
-             p.created_by_id, p.created_at
-      FROM phase.posts p
-      WHERE p.is_deleted = false
+        SELECT p.id,
+               p.author_id,
+               p.title,
+               p.content,
+               p.post_type,
+               p.visibility,
+               p.created_by_id,
+               p.created_at,
+               p.is_deleted,
+               p.deleted_by_id,
+               p.deleted_at,
+               concat(u.first_name, ' ', u.last_name) as   user_name,
+               concat(u2.first_name, ' ', u2.last_name) as created_by_name,
+               (SELECT json_agg(json_build_object('file_id', pf.file_id))
+                FROM phase.post_files pf
+                WHERE pf.post_id = p.id) as                attached_files,
+               (SELECT json_agg(json_build_object('user_id', pu.user_id))
+                FROM phase.post_users pu
+                WHERE pu.post_id = p.id) as                specific_users,
+               (SELECT json_agg(json_build_object('user_id', pr.user_id, 'reaction', pr.reaction))
+                FROM phase.post_reactions pr
+                WHERE pr.post_id = p.id) as                reactions,
+               (SELECT json_agg(json_build_object(
+                       'id', c.id, 'post_id', c.post_id, 'user_id', c.user_id, 'user_name',
+                       concat(cu.first_name, ' ', cu.last_name),
+                       'content', c.content, 'created_at', c.created_at, 'parent_id', c.parent_id,
+                       'reactions', (SELECT json_agg(json_build_object('user_id', cr.user_id, 'reaction', cr.reaction))
+                                     FROM phase.comment_reactions cr
+                                     WHERE cr.comment_id = c.id)
+                                ))
+                FROM phase.comments c
+                         JOIN phase.users cu ON c.user_id = cu.id
+                WHERE c.post_id = p.id) as                 comments
+        FROM phase.posts p
+                 INNER JOIN phase.users u ON p.author_id = u.id
+                 INNER JOIN phase.users u2 ON u2.id = p.created_by_id
     `;
+    /** @type {string} Base query for multiple record retrieval */
+    this.base_list_query = this.base_query;
+    /** @type {string} Default ORDER BY clause */
     this.default_order_by = 'ORDER BY p.created_at DESC';
-    this.instance = _props => new Post(_props);
+    /** @type {Function} Function to instantiate a model */
+    this.instance = _props => new Post( _props );
   }
 
   /**
    * Create a new post
-   * @param {Omit<Post, 'id'|'created_at'|'is_deleted'|'deleted_by_id'|'deleted_at'>} post_data - Post data
+   * @param {Omit<Post, 'id'|'created_at'|'is_deleted'|'deleted_by_id'|'deleted_at'> & {specific_users?: number[], file_ids?: number[]}} post_data - Post data
    * @returns {Promise<Post>} Created post instance
    * @throws {ValidationError} If required fields are missing
    * @throws {FailedToCreateError} If creation fails
    */
-  static async create(post_data) {
-    const { user_id, title, content, post_type, visibility = 'private', created_by_id = null } = post_data;
-    if (!user_id || !title || !content || !post_type) {
-      throw new ValidationError('Missing required fields: user_id, title, content, post_type');
+  static async create( post_data ) {
+    const {
+      author_id,
+      title,
+      content,
+      post_type,
+      visibility = 'family',
+      created_by_id = null,
+      specific_users = [],
+      file_ids = []
+    } = post_data;
+    if ( !author_id || !title || !content || !post_type ) {
+      throw new ValidationError( 'Missing required fields: author_id, title, content, post_type' );
     }
-    const query_str = `
-      INSERT INTO phase.posts (user_id, title, content, post_type, visibility, created_by_id)
-      VALUES ($1, $2, $3, $4, $5, $6) RETURNING *;
-    `;
-    const values = [user_id, title, content, post_type, visibility, created_by_id];
-    const result = await phsdb.query(query_str, values, { plain: true });
-    if (!result) throw new FailedToCreateError('Failed to create post');
-    return new Post(result);
+    return await this.prototype.with_transaction( async ( client ) => {
+      const query_str = `
+          INSERT INTO phase.posts (author_id, title, content, post_type, visibility, created_by_id)
+          VALUES ($1, $2, $3, $4, $5, $6)
+          RETURNING *;
+      `;
+      const values = [ author_id, title, content, post_type, visibility, created_by_id || author_id ];
+      const result = await phsdb.client_query( client, query_str, values, { plain: true } );
+      if ( !result ) throw new FailedToCreateError( 'Failed to create post' );
+      const post = new Post( result );
+      if ( visibility === 'specific' && specific_users?.length ) {
+        const user_query = `INSERT INTO phase.post_users (post_id, user_id)
+                            VALUES ($1, $2);`;
+        for ( const user_id of specific_users ) {
+          await phsdb.client_query( client, user_query, [ post.id, user_id ] );
+        }
+      }
+      if ( file_ids?.length ) {
+        const file_query = `INSERT INTO phase.post_files (post_id, file_id)
+                            VALUES ($1, $2);`;
+        for ( const file_id of file_ids ) {
+          await phsdb.client_query( client, file_query, [ post.id, file_id ] );
+        }
+      }
+      return post;
+    } );
   }
 
   /**
    * Find post by title
    * @param {string} title - Title to search for
-   * @param {string[]} [excludes] - Fields to exclude from result
+   * @param {string[]} [excludes=[]] - Fields to exclude from result
    * @returns {Promise<Post|null>} Post instance or null
    */
-  static async find_by_title(title, excludes = []) {
-    return await new Post().find_one({ title }, excludes);
+  static async find_by_title( title, excludes = [] ) {
+    return await new Post().find_one( { title }, excludes );
   }
 
   /**
@@ -92,16 +156,16 @@ class Post extends Model {
    * @throws {ValidationError} If deleted_by_id is invalid
    * @throws {NotFoundError} If record not found
    */
-  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');
+  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({
+    return await this.update( {
       is_deleted: true,
       deleted_at: new Date().toISOString(),
       deleted_by_id: deleted_by_id_int
-    });
+    } );
   }
 }
 
diff --git a/src/models/post_files.model.js b/src/models/post_files.model.js
new file mode 100644 (file)
index 0000000..afce676
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * @file Post files model for phase.post_files table
+ * @module PostFile
+ */
+
+const { Model, ValidationError, FailedToCreateError } = require( './model' );
+
+/**
+ * @typedef {Object} PostFile
+ * @property {number} post_id - Post ID (foreign key to posts)
+ * @property {number} file_id - File ID (foreign key to files)
+ */
+
+/**
+ * Post files model class
+ * @extends Model
+ */
+class PostFile extends Model {
+  /**
+   * Create a PostFile instance
+   * @param {Partial<PostFile>} [props] - Post file properties
+   */
+  constructor( props ) {
+    super( props );
+    /** @type {string} Database table name */
+    this.table = 'phase.post_files';
+    /** @type {string} Table alias for queries */
+    this.prepend = 'pf';
+    /** @type {string[]} Allowed columns for queries */
+    this.default_columns = [ 'post_id', 'file_id' ];
+    /** @type {string[]} Columns excluded from updates */
+    this.update_exclude_columns = [ 'post_id', 'file_id' ];
+    /** @type {string} Base query for single record retrieval */
+    this.base_query = `SELECT pf.post_id, pf.file_id
+                       FROM phase.post_files pf`;
+    /** @type {string} Base query for multiple record retrieval */
+    this.base_list_query = this.base_query;
+    /** @type {string} Default ORDER BY clause */
+    this.default_order_by = 'ORDER BY pf.post_id ASC';
+    /** @type {Function} Function to instantiate a model */
+    this.instance = _props => new PostFile( _props );
+  }
+
+  /**
+   * Add a post-file relation
+   * @param {number|string} post_id - Post ID
+   * @param {number|string} file_id - File ID
+   * @returns {Promise<PostFile>} Created relation instance
+   * @throws {ValidationError} If IDs are invalid
+   * @throws {FailedToCreateError} If creation fails
+   */
+  static async add_relation( post_id, file_id ) {
+    const post_id_int = parseInt( post_id, 10 );
+    const file_id_int = parseInt( file_id, 10 );
+    if ( isNaN( post_id_int ) || isNaN( file_id_int ) ) {
+      throw new ValidationError( 'post_id and file_id must be valid integers' );
+    }
+    const query_str = `INSERT INTO phase.post_files (post_id, file_id)
+                       VALUES ($1, $2)
+                       RETURNING *;`;
+    const values = [ post_id_int, file_id_int ];
+    const result = await phsdb.query( query_str, values, { plain: true } );
+    if ( !result ) throw new FailedToCreateError( 'Failed to add post-file relation' );
+    return new PostFile( result );
+  }
+
+  /**
+   * Remove a post-file relation
+   * @param {number|string} post_id - Post ID
+   * @param {number|string} file_id - File ID
+   * @returns {Promise<PostFile|null>} Deleted relation instance or null
+   * @throws {ValidationError} If IDs are invalid
+   */
+  static async remove_relation( post_id, file_id ) {
+    const post_id_int = parseInt( post_id, 10 );
+    const file_id_int = parseInt( file_id, 10 );
+    if ( isNaN( post_id_int ) || isNaN( file_id_int ) ) {
+      throw new ValidationError( 'post_id and file_id must be valid integers' );
+    }
+    const query_str = `DELETE
+                       FROM phase.post_files
+                       WHERE post_id = $1
+                         AND file_id = $2
+                       RETURNING *;`;
+    const values = [ post_id_int, file_id_int ];
+    const result = await phsdb.query( query_str, values, { plain: true } );
+    return result ? new PostFile( result ) : null;
+  }
+
+  /**
+   * Find post-file relations by post ID
+   * @param {number|string} post_id - Post ID
+   * @param {string[]} [excludes=[]] - Fields to exclude from result
+   * @param {{name: string, direction?: 'asc'|'desc'}|null} [order_by=null] - Order by configuration
+   * @param {number} [limit=100] - Maximum number of records
+   * @param {number} [offset=0] - Number of records to skip
+   * @returns {Promise<PostFile[]>} Array of relations
+   */
+  static async find_by_post_id( post_id, excludes = [], order_by = null, limit = 100, offset = 0 ) {
+    return await new PostFile().find_many( { post_id }, excludes, order_by, limit, offset );
+  }
+}
+
+module.exports = PostFile;
\ No newline at end of file
diff --git a/src/models/post_reactions.model.js b/src/models/post_reactions.model.js
new file mode 100644 (file)
index 0000000..3392360
--- /dev/null
@@ -0,0 +1,109 @@
+/**
+ * @file Post reactions model for phase.post_reactions table
+ * @module PostReaction
+ */
+
+const { Model, ValidationError, FailedToCreateError } = require( './model' );
+
+/**
+ * @typedef {Object} PostReaction
+ * @property {number} post_id - Post ID (foreign key to posts)
+ * @property {number} user_id - User ID (foreign key to users)
+ * @property {string} reaction - Reaction type (e.g., 'like', 'heart', 'laugh')
+ */
+
+/**
+ * Post reactions model class
+ * @extends Model
+ */
+class PostReaction extends Model {
+  /**
+   * Create a PostReaction instance
+   * @param {Partial<PostReaction>} [props] - Post reaction properties
+   */
+  constructor( props ) {
+    super( props );
+    /** @type {string} Database table name */
+    this.table = 'phase.post_reactions';
+    /** @type {string} Table alias for queries */
+    this.prepend = 'pr';
+    /** @type {string[]} Allowed columns for queries */
+    this.default_columns = [ 'post_id', 'user_id', 'reaction' ];
+    /** @type {string[]} Columns excluded from updates */
+    this.update_exclude_columns = [ 'post_id', 'user_id' ];
+    /** @type {string} Base query for single record retrieval */
+    this.base_query = `SELECT pr.post_id, pr.user_id, pr.reaction
+                       FROM phase.post_reactions pr`;
+    /** @type {string} Base query for multiple record retrieval */
+    this.base_list_query = this.base_query;
+    /** @type {string} Default ORDER BY clause */
+    this.default_order_by = 'ORDER BY pr.post_id ASC';
+    /** @type {Function} Function to instantiate a model */
+    this.instance = _props => new PostReaction( _props );
+  }
+
+  /**
+   * Add a post reaction
+   * @param {number|string} post_id - Post ID
+   * @param {number|string} user_id - User ID
+   * @param {string} reaction - Reaction type
+   * @returns {Promise<PostReaction>} Created or updated reaction instance
+   * @throws {ValidationError} If required fields are missing
+   * @throws {FailedToCreateError} If creation fails
+   */
+  static async add( post_id, user_id, reaction ) {
+    const post_id_int = parseInt( post_id, 10 );
+    const user_id_int = parseInt( user_id, 10 );
+    if ( isNaN( post_id_int ) || isNaN( user_id_int ) || !reaction ) {
+      throw new ValidationError( 'post_id, user_id, and reaction are required' );
+    }
+    const query_str = `
+        INSERT INTO phase.post_reactions (post_id, user_id, reaction)
+        VALUES ($1, $2, $3)
+        ON CONFLICT (post_id, user_id) DO UPDATE SET reaction = EXCLUDED.reaction
+        RETURNING *;
+    `;
+    const values = [ post_id_int, user_id_int, reaction ];
+    const result = await phsdb.query( query_str, values, { plain: true } );
+    if ( !result ) throw new FailedToCreateError( 'Failed to add post reaction' );
+    return new PostReaction( result );
+  }
+
+  /**
+   * Remove a post reaction
+   * @param {number|string} post_id - Post ID
+   * @param {number|string} user_id - User ID
+   * @returns {Promise<PostReaction|null>} Deleted reaction instance or null
+   * @throws {ValidationError} If IDs are invalid
+   */
+  static async remove( post_id, user_id ) {
+    const post_id_int = parseInt( post_id, 10 );
+    const user_id_int = parseInt( user_id, 10 );
+    if ( isNaN( post_id_int ) || isNaN( user_id_int ) ) {
+      throw new ValidationError( 'post_id and user_id must be valid integers' );
+    }
+    const query_str = `DELETE
+                       FROM phase.post_reactions
+                       WHERE post_id = $1
+                         AND user_id = $2
+                       RETURNING *;`;
+    const values = [ post_id_int, user_id_int ];
+    const result = await phsdb.query( query_str, values, { plain: true } );
+    return result ? new PostReaction( result ) : null;
+  }
+
+  /**
+   * Find post reactions by post ID
+   * @param {number|string} post_id - Post ID
+   * @param {string[]} [excludes=[]] - Fields to exclude from result
+   * @param {{name: string, direction?: 'asc'|'desc'}|null} [order_by=null] - Order by configuration
+   * @param {number} [limit=100] - Maximum number of records
+   * @param {number} [offset=0] - Number of records to skip
+   * @returns {Promise<PostReaction[]>} Array of reactions
+   */
+  static async find_by_post_id( post_id, excludes = [], order_by = null, limit = 100, offset = 0 ) {
+    return await new PostReaction().find_many( { post_id }, excludes, order_by, limit, offset );
+  }
+}
+
+module.exports = PostReaction;
\ No newline at end of file
diff --git a/src/models/post_users.model.js b/src/models/post_users.model.js
new file mode 100644 (file)
index 0000000..1044674
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * @file Post users model for phase.post_users table
+ * @module PostUser
+ */
+
+const { Model, ValidationError, FailedToCreateError } = require( './model' );
+
+/**
+ * @typedef {Object} PostUser
+ * @property {number} post_id - Post ID (foreign key to posts)
+ * @property {number} user_id - User ID (foreign key to users)
+ */
+
+/**
+ * Post users model class
+ * @extends Model
+ */
+class PostUser extends Model {
+  /**
+   * Create a PostUser instance
+   * @param {Partial<PostUser>} [props] - Post user properties
+   */
+  constructor( props ) {
+    super( props );
+    /** @type {string} Database table name */
+    this.table = 'phase.post_users';
+    /** @type {string} Table alias for queries */
+    this.prepend = 'pu';
+    /** @type {string[]} Allowed columns for queries */
+    this.default_columns = [ 'post_id', 'user_id' ];
+    /** @type {string[]} Columns excluded from updates */
+    this.update_exclude_columns = [ 'post_id', 'user_id' ];
+    /** @type {string} Base query for single record retrieval */
+    this.base_query = `SELECT pu.post_id, pu.user_id
+                       FROM phase.post_users pu`;
+    /** @type {string} Base query for multiple record retrieval */
+    this.base_list_query = this.base_query;
+    /** @type {string} Default ORDER BY clause */
+    this.default_order_by = 'ORDER BY pu.post_id ASC';
+    /** @type {Function} Function to instantiate a model */
+    this.instance = _props => new PostUser( _props );
+  }
+
+  /**
+   * Add a post-user relation
+   * @param {number|string} post_id - Post ID
+   * @param {number|string} user_id - User ID
+   * @returns {Promise<PostUser>} Created relation instance
+   * @throws {ValidationError} If IDs are invalid
+   * @throws {FailedToCreateError} If creation fails
+   */
+  static async add_relation( post_id, user_id ) {
+    const post_id_int = parseInt( post_id, 10 );
+    const user_id_int = parseInt( user_id, 10 );
+    if ( isNaN( post_id_int ) || isNaN( user_id_int ) ) {
+      throw new ValidationError( 'post_id and user_id must be valid integers' );
+    }
+    const query_str = `INSERT INTO phase.post_users (post_id, user_id)
+                       VALUES ($1, $2)
+                       RETURNING *;`;
+    const values = [ post_id_int, user_id_int ];
+    const result = await phsdb.query( query_str, values, { plain: true } );
+    if ( !result ) throw new FailedToCreateError( 'Failed to add post-user relation' );
+    return new PostUser( result );
+  }
+
+  /**
+   * Remove a post-user relation
+   * @param {number|string} post_id - Post ID
+   * @param {number|string} user_id - User ID
+   * @returns {Promise<PostUser|null>} Deleted relation instance or null
+   * @throws {ValidationError} If IDs are invalid
+   */
+  static async remove_relation( post_id, user_id ) {
+    const post_id_int = parseInt( post_id, 10 );
+    const user_id_int = parseInt( user_id, 10 );
+    if ( isNaN( post_id_int ) || isNaN( user_id_int ) ) {
+      throw new ValidationError( 'post_id and user_id must be valid integers' );
+    }
+    const query_str = `DELETE
+                       FROM phase.post_users
+                       WHERE post_id = $1
+                         AND user_id = $2
+                       RETURNING *;`;
+    const values = [ post_id_int, user_id_int ];
+    const result = await phsdb.query( query_str, values, { plain: true } );
+    return result ? new PostUser( result ) : null;
+  }
+
+  /**
+   * Find post-user relations by post ID
+   * @param {number|string} post_id - Post ID
+   * @param {string[]} [excludes=[]] - Fields to exclude from result
+   * @param {{name: string, direction?: 'asc'|'desc'}|null} [order_by=null] - Order by configuration
+   * @param {number} [limit=100] - Maximum number of records
+   * @param {number} [offset=0] - Number of records to skip
+   * @returns {Promise<PostUser[]>} Array of relations
+   */
+  static async find_by_post_id( post_id, excludes = [], order_by = null, limit = 100, offset = 0 ) {
+    return await new PostUser().find_many( { post_id }, excludes, order_by, limit, offset );
+  }
+}
+
+module.exports = PostUser;
\ No newline at end of file
index 830a44ce9a40b5c0b24f2375babbcc4ff7ee2499..eb01433dd7b2d48019a37585656301c052246ed8 100644 (file)
@@ -1,23 +1,27 @@
 /**
  * @file Post routes configuration
+ * @module PostRoutes
  */
 
-const express = require('express');
+const express = require( 'express' );
 const router = express.Router();
-const { validate_auth } = require('../middleware/routeHelpers');
-const post_controller = require('../controllers/post.controller');
+const { validate_auth, restrictToRoles } = require( '../middleware/routeHelpers' );
+const post_controller = require( '../controllers/post.controller' );
 
 /**
  * Configure post routes
  * @param {Object} passport - Passport instance for authentication
  * @returns {Object} Express router with post routes
  */
-module.exports = (passport) => {
-  router.post('/create', validate_auth(passport), post_controller.create);
-  router.get('/title/:title', validate_auth(passport), post_controller.find_by_title);
-  router.get('/:id', validate_auth(passport), post_controller.find_one);
-  router.get('/', post_controller.find_many);
-  router.put('/:id', validate_auth(passport), post_controller.update);
-  router.put('/:id/soft_delete', validate_auth(passport), post_controller.soft_delete);
+module.exports = ( passport ) => {
+  router.post( '/create', validate_auth( passport ), post_controller.create );
+  router.get( '/title/:title', validate_auth( passport ), post_controller.find_by_title );
+  router.get( '/:id', validate_auth( passport ), post_controller.find_one );
+  router.get( '/', post_controller.find_many );
+  router.put( '/:id', validate_auth( passport ), restrictToRoles( [ 'admin', 'family' ] ), post_controller.update );
+  router.put( '/:id/soft_delete', validate_auth( passport ), restrictToRoles( [ 'admin', 'family' ] ), post_controller.soft_delete );
+  router.post( '/:id/comments', validate_auth( passport ), post_controller.add_comment );
+  router.post( '/:id/reactions', validate_auth( passport ), post_controller.add_reaction );
+  router.post( '/comments/:commentId/reactions', validate_auth( passport ), post_controller.add_comment_reaction );
   return router;
 };
\ No newline at end of file
diff --git a/src/routes/post_files.routes.js b/src/routes/post_files.routes.js
new file mode 100644 (file)
index 0000000..ec63827
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * @file Post files routes configuration
+ * @module PostFilesRoutes
+ */
+
+const express = require( 'express' );
+const router = express.Router();
+const { validate_auth, restrictToRoles } = require( '../middleware/routeHelpers' );
+const post_files_controller = require( '../controllers/post_files.controller' );
+
+/**
+ * Configure post files routes
+ * @param {Object} passport - Passport instance for authentication
+ * @returns {Object} Express router with post files routes
+ */
+module.exports = ( passport ) => {
+  router.post( '/add', validate_auth( passport ), restrictToRoles( [ 'admin', 'family' ] ), post_files_controller.add_relation );
+  router.delete( '/:post_id/:file_id', validate_auth( passport ), restrictToRoles( [ 'admin', 'family' ] ), post_files_controller.remove_relation );
+  router.get( '/post/:post_id', validate_auth( passport ), post_files_controller.find_by_post_id );
+  return router;
+};
\ No newline at end of file
diff --git a/src/routes/post_users.routes.js b/src/routes/post_users.routes.js
new file mode 100644 (file)
index 0000000..3da9f6c
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * @file Post users routes configuration
+ * @module PostUsersRoutes
+ */
+
+const express = require( 'express' );
+const router = express.Router();
+const { validate_auth, restrictToRoles } = require( '../middleware/routeHelpers' );
+const post_users_controller = require( '../controllers/post_users.controller' );
+
+/**
+ * Configure post users routes
+ * @param {Object} passport - Passport instance for authentication
+ * @returns {Object} Express router with post users routes
+ */
+module.exports = ( passport ) => {
+  router.post( '/add', validate_auth( passport ), restrictToRoles( [ 'admin', 'family' ] ), post_users_controller.add_relation );
+  router.delete( '/:post_id/:user_id', validate_auth( passport ), restrictToRoles( [ 'admin', 'family' ] ), post_users_controller.remove_relation );
+  router.get( '/post/:post_id', validate_auth( passport ), post_users_controller.find_by_post_id );
+  return router;
+};
\ No newline at end of file