From: charleswrayjr Date: Fri, 26 Sep 2025 05:48:21 +0000 (-0500) Subject: #PA-1 setting up new attachment models, routes, and controllers. X-Git-Url: https://git.phasecustomsoft.com/static/git-favicon.png?a=commitdiff_plain;h=d1bc605191963274df188352ed74190ebb5f48b1;p=phs-api.git #PA-1 setting up new attachment models, routes, and controllers. --- diff --git a/src/controllers/post.controller.js b/src/controllers/post.controller.js index 09f21a2..adfc6bb 100644 --- a/src/controllers/post.controller.js +++ b/src/controllers/post.controller.js @@ -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} */ - 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} */ - 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} */ - 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} */ - 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} */ - 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} */ - 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} + */ + 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} + */ + 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} + */ + 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 index 0000000..8f236ea --- /dev/null +++ b/src/controllers/post_files.controller.js @@ -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} + */ + 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} + */ + 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} + */ + 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 index 0000000..8367473 --- /dev/null +++ b/src/controllers/post_users.controller.js @@ -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} + */ + 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} + */ + 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} + */ + 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 diff --git a/src/models/post.model.js b/src/models/post.model.js index cba10d8..b3742eb 100644 --- a/src/models/post.model.js +++ b/src/models/post.model.js @@ -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} [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_data - Post data + * @param {Omit & {specific_users?: number[], file_ids?: number[]}} post_data - Post data * @returns {Promise} 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 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 index 0000000..afce676 --- /dev/null +++ b/src/models/post_files.model.js @@ -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} [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} 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} 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} 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 index 0000000..3392360 --- /dev/null +++ b/src/models/post_reactions.model.js @@ -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} [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} 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} 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} 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 index 0000000..1044674 --- /dev/null +++ b/src/models/post_users.model.js @@ -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} [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} 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} 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} 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 diff --git a/src/routes/post.routes.js b/src/routes/post.routes.js index 830a44c..eb01433 100644 --- a/src/routes/post.routes.js +++ b/src/routes/post.routes.js @@ -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 index 0000000..ec63827 --- /dev/null +++ b/src/routes/post_files.routes.js @@ -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 index 0000000..3da9f6c --- /dev/null +++ b/src/routes/post_users.routes.js @@ -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