diff --git a/client/src/components/cards/Card/ProjectContent.jsx b/client/src/components/cards/Card/ProjectContent.jsx index 0f74693a..3582b73a 100644 --- a/client/src/components/cards/Card/ProjectContent.jsx +++ b/client/src/components/cards/Card/ProjectContent.jsx @@ -114,6 +114,7 @@ const ProjectContent = React.memo(({ cardId }) => { card.description || card.dueDate || card.stopwatch || + card.commentsTotal > 0 || attachmentsTotal > 0 || notificationsTotal > 0 || listName; @@ -226,6 +227,14 @@ const ProjectContent = React.memo(({ cardId }) => { )} + {card.commentsTotal > 0 && ( + + + + {card.commentsTotal} + + + )} )} {!isCompact && usersNode} diff --git a/client/src/models/Card.js b/client/src/models/Card.js index 8fe5b4bb..6da30db5 100755 --- a/client/src/models/Card.js +++ b/client/src/models/Card.js @@ -20,6 +20,9 @@ export default class extends BaseModel { description: attr(), dueDate: attr(), stopwatch: attr(), + commentsTotal: attr({ + getDefault: () => 0, + }), createdAt: attr({ getDefault: () => new Date(), }), diff --git a/client/src/models/Comment.js b/client/src/models/Comment.js index cda99f60..eea83d5c 100755 --- a/client/src/models/Comment.js +++ b/client/src/models/Comment.js @@ -42,31 +42,47 @@ export default class extends BaseModel { break; case ActionTypes.COMMENT_CREATE: - case ActionTypes.COMMENT_CREATE_HANDLE: - case ActionTypes.COMMENT_UPDATE__SUCCESS: - case ActionTypes.COMMENT_UPDATE_HANDLE: - Comment.upsert(payload.comment); + case ActionTypes.COMMENT_CREATE_HANDLE: { + const commentModel = Comment.upsert(payload.comment); + + if (commentModel.card) { + commentModel.card.commentsTotal += 1; + } break; + } case ActionTypes.COMMENT_CREATE__SUCCESS: Comment.withId(payload.localId).delete(); Comment.upsert(payload.comment); break; - case ActionTypes.COMMENT_CREATE__FAILURE: - Comment.withId(payload.localId).delete(); + case ActionTypes.COMMENT_CREATE__FAILURE: { + const commentModel = Comment.withId(payload.localId); + commentModel.delete(); + + if (commentModel.card) { + commentModel.card.commentsTotal -= 1; + } break; + } case ActionTypes.COMMENT_UPDATE: Comment.withId(payload.id).update(payload.data); break; - case ActionTypes.COMMENT_DELETE: - Comment.withId(payload.id).delete(); + case ActionTypes.COMMENT_UPDATE__SUCCESS: + case ActionTypes.COMMENT_UPDATE_HANDLE: + Comment.upsert(payload.comment); break; - case ActionTypes.COMMENT_DELETE__SUCCESS: - case ActionTypes.COMMENT_DELETE_HANDLE: { + case ActionTypes.COMMENT_DELETE: { + const commentModel = Comment.withId(payload.id); + commentModel.delete(); + commentModel.card.commentsTotal -= 1; + + break; + } + case ActionTypes.COMMENT_DELETE__SUCCESS: { const commentModel = Comment.withId(payload.comment.id); if (commentModel) { @@ -75,6 +91,19 @@ export default class extends BaseModel { break; } + case ActionTypes.COMMENT_DELETE_HANDLE: { + const commentModel = Comment.withId(payload.comment.id); + + if (commentModel) { + commentModel.delete(); + + if (commentModel.card) { + commentModel.card.commentsTotal -= 1; + } + } + + break; + } default: } } diff --git a/server/api/hooks/query-methods/models/Comment.js b/server/api/hooks/query-methods/models/Comment.js index 91f0712f..7d4a528f 100644 --- a/server/api/hooks/query-methods/models/Comment.js +++ b/server/api/hooks/query-methods/models/Comment.js @@ -10,7 +10,25 @@ const defaultFind = (criteria, { limit } = {}) => /* Query methods */ -const createOne = (values) => Comment.create({ ...values }).fetch(); +const createOne = (values) => + sails.getDatastore().transaction(async (db) => { + const comment = await Comment.create({ ...values }) + .fetch() + .usingConnection(db); + + const queryResult = await sails + .sendNativeQuery( + 'UPDATE card SET comments_total = comments_total + 1, updated_at = $1 WHERE id = $2', + [new Date().toISOString(), comment.cardId], + ) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + throw 'cardNotFound'; + } + + return comment; + }); const getByIds = (ids) => defaultFind(ids); @@ -35,9 +53,65 @@ const update = (criteria, values) => Comment.update(criteria).set(values).fetch( const updateOne = (criteria, values) => Comment.updateOne(criteria).set({ ...values }); // eslint-disable-next-line no-underscore-dangle -const delete_ = (criteria) => Comment.destroy(criteria).fetch(); +const delete_ = (criteria) => + sails.getDatastore().transaction(async (db) => { + const comments = await Comment.destroy(criteria).fetch().usingConnection(db); -const deleteOne = (criteria) => Comment.destroyOne(criteria); + if (comments.length > 0) { + const commentsByCardId = _.groupBy(comments, 'cardId'); + + const cardIdsByTotal = Object.entries(commentsByCardId).reduce( + (result, [cardId, commentsItem]) => ({ + ...result, + [commentsItem.length]: [...(result[commentsItem.length] || []), cardId], + }), + {}, + ); + + const queryValues = []; + let query = 'UPDATE card SET comments_total = comments_total - CASE '; + + Object.entries(cardIdsByTotal).forEach(([total, cardIds]) => { + const inValues = cardIds.map((cardId) => { + queryValues.push(cardId); + return `$${queryValues.length}`; + }); + + queryValues.push(total); + query += `WHEN id IN (${inValues.join(', ')}) THEN $${queryValues.length}::int `; + }); + + const inValues = Object.keys(commentsByCardId).map((cardId) => { + queryValues.push(cardId); + return `$${queryValues.length}`; + }); + + queryValues.push(new Date().toISOString()); + query += `END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')})`; + + await sails.sendNativeQuery(query, queryValues).usingConnection(db); + } + + return comments; + }); + +const deleteOne = (criteria) => + sails.getDatastore().transaction(async (db) => { + const comment = await Comment.destroyOne(criteria).usingConnection(db); + + const queryResult = await sails + .sendNativeQuery( + 'UPDATE card SET comments_total = comments_total - 1, updated_at = $1 WHERE id = $2', + [new Date().toISOString(), comment.cardId], + ) + .usingConnection(db); + + if (queryResult.rowCount === 0) { + throw 'cardNotFound'; + } + + return comment; + }); module.exports = { createOne, diff --git a/server/api/models/Card.js b/server/api/models/Card.js index 02cf67a5..dc3073c2 100755 --- a/server/api/models/Card.js +++ b/server/api/models/Card.js @@ -48,6 +48,11 @@ module.exports = { stopwatch: { type: 'json', }, + commentsTotal: { + type: 'number', + defaultsTo: 0, + columnName: 'comments_total', + }, listChangedAt: { type: 'ref', columnName: 'list_changed_at', diff --git a/server/db/migrations/20250523131647_add_comments_counter.js b/server/db/migrations/20250523131647_add_comments_counter.js new file mode 100644 index 00000000..2108832a --- /dev/null +++ b/server/db/migrations/20250523131647_add_comments_counter.js @@ -0,0 +1,32 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +exports.up = async (knex) => { + await knex.schema.alterTable('card', (table) => { + /* Columns */ + + table.integer('comments_total').notNullable().defaultTo(0); + }); + + await knex.raw(` + UPDATE card + SET comments_total = comments_total_by_card_id.comments_total + FROM ( + SELECT card_id, COUNT(*) as comments_total + FROM comment + GROUP BY card_id + ) AS comments_total_by_card_id + WHERE card.id = comments_total_by_card_id.card_id + `); + + return knex.schema.alterTable('card', (table) => { + table.integer('comments_total').notNullable().alter(); + }); +}; + +exports.down = (knex) => + knex.schema.table('card', (table) => { + table.dropColumn('comments_total'); + });