diff --git a/backend/middleware/verifyEditable.js b/backend/middleware/verifyEditable.js index 7be9aa2..cebd3eb 100644 --- a/backend/middleware/verifyEditable.js +++ b/backend/middleware/verifyEditable.js @@ -5,6 +5,7 @@ import defineProject from '../models/projects.js'; import defineFolder from '../models/folders.js'; import defineCase from '../models/cases.js'; import defineRun from '../models/runs.js'; +import defineRunCase from '../models/runCases.js'; export default function verifyEditableMiddleware(sequelize) { /** @@ -243,6 +244,55 @@ export default function verifyEditableMiddleware(sequelize) { return res.status(403).json({ error: 'Forbidden' }); } + /** + * Verify user is reporter of the project by CommentableId + * (have to be called after verifySignedIn() middleware) + */ + async function verifyProjectReporterFromCommentableId(req, res, next) { + const commentableType = req.params.commentableType || req.query.commentableType; + const commentableId = req.params.commentableId || req.query.commentableId; + if (!commentableType || !commentableId) { + return res.status(400).json({ error: 'commentableType and commentableId are required' }); + } + + if (commentableType === 'Run') { + // not implemented yet + next(); + return; + } else if (commentableType === 'Case') { + // not implemented yet + next(); + return; + } else if (commentableType === 'RunCase') { + const RunCase = defineRunCase(sequelize, DataTypes); + const runCaseId = req.params.commentableId || req.query.commentableId; + if (!runCaseId) { + return res.status(400).json({ error: 'runCaseId is required' }); + } + + const runCase = await RunCase.findByPk(runCaseId); + const runId = runCase && runCase.runId; + if (!runId) { + return res.status(404).send('failed to find runId'); + } + + const Run = defineRun(sequelize, DataTypes); + const run = await Run.findByPk(runId); + const projectId = run && run.projectId; + if (!projectId) { + return res.status(404).send('failed to find projectId'); + } + + const isReporterRet = await isReporter(projectId, req.userId); + if (isReporterRet) { + next(); + return; + } + } else { + return res.status(400).json({ error: 'unsupported commentableType' }); + } + } + async function isReporter(projectId, userId) { const Project = defineProject(sequelize, DataTypes); const Member = defineMember(sequelize, DataTypes); @@ -289,5 +339,6 @@ export default function verifyEditableMiddleware(sequelize) { verifyProjectDeveloperFromCaseId, verifyProjectReporterFromProjectId, verifyProjectReporterFromRunId, + verifyProjectReporterFromCommentableId, }; } diff --git a/backend/middleware/verifyVisible.js b/backend/middleware/verifyVisible.js index 640e2ce..85b6c03 100644 --- a/backend/middleware/verifyVisible.js +++ b/backend/middleware/verifyVisible.js @@ -4,6 +4,7 @@ import defineProject from '../models/projects.js'; import defineFolder from '../models/folders.js'; import defineCase from '../models/cases.js'; import defineRun from '../models/runs.js'; +import defineRunCase from '../models/runCases.js'; export default function verifyVisibleMiddleware(sequelize) { /** @@ -16,8 +17,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(400).json({ error: 'projectId is required' }); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -44,8 +45,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(404).send('failed to find projectId'); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -80,8 +81,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(404).send('failed to find projectId'); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -108,8 +109,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(404).send('failed to find projectId'); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -117,6 +118,51 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(403).json({ error: 'Forbidden' }); } + async function verifyProjectVisibleFromCommentableId(req, res, next) { + const commentableType = req.params.commentableType || req.query.commentableType; + const commentableId = req.params.commentableId || req.query.commentableId; + if (!commentableType || !commentableId) { + return res.status(400).json({ error: 'commentableType and commentableId are required' }); + } + + if (commentableType === 'Run') { + // not implemented yet + next(); + return; + } else if (commentableType === 'Case') { + // not implemented yet + next(); + return; + } else if (commentableType === 'RunCase') { + const RunCase = defineRunCase(sequelize, DataTypes); + const runCaseId = req.params.commentableId || req.query.commentableId; + if (!runCaseId) { + return res.status(400).json({ error: 'runCaseId is required' }); + } + + const runCase = await RunCase.findByPk(runCaseId); + const runId = runCase && runCase.runId; + if (!runId) { + return res.status(404).send('failed to find runId'); + } + + const Run = defineRun(sequelize, DataTypes); + const run = await Run.findByPk(runId); + const projectId = run && run.projectId; + if (!projectId) { + return res.status(404).send('failed to find projectId'); + } + + const visible = await isVisible(projectId, req.userId); + if (visible) { + next(); + return; + } + } else { + return res.status(400).json({ error: 'unsupported commentableType' }); + } + } + async function isVisible(projectId, userId) { const Project = defineProject(sequelize, DataTypes); const Member = defineMember(sequelize, DataTypes); @@ -158,5 +204,6 @@ export default function verifyVisibleMiddleware(sequelize) { verifyProjectVisibleFromFolderId, verifyProjectVisibleFromCaseId, verifyProjectVisibleFromRunId, + verifyProjectVisibleFromCommentableId, }; } diff --git a/backend/migrations/20260131000000-create-comments.js b/backend/migrations/20260131000000-create-comments.js new file mode 100644 index 0000000..5ebfbcb --- /dev/null +++ b/backend/migrations/20260131000000-create-comments.js @@ -0,0 +1,47 @@ +export async function up(queryInterface, Sequelize) { + await queryInterface.createTable('comments', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + commentableType: { + type: Sequelize.STRING, + allowNull: false, + }, + commentableId: { + type: Sequelize.INTEGER, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + content: { + type: Sequelize.TEXT, + allowNull: false, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + // Add composite index for efficient polymorphic queries + await queryInterface.addIndex('comments', ['commentableType', 'commentableId'], { + name: 'comments_commentable_index', + }); +} + +export async function down(queryInterface) { + await queryInterface.dropTable('comments'); +} diff --git a/backend/models/comments.js b/backend/models/comments.js new file mode 100644 index 0000000..9ff35c6 --- /dev/null +++ b/backend/models/comments.js @@ -0,0 +1,47 @@ +function defineComment(sequelize, DataTypes) { + const Comment = sequelize.define('Comment', { + commentableType: { + type: DataTypes.STRING, + allowNull: false, + }, + commentableId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: true, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + }); + + Comment.associate = (models) => { + // Polymorphic associations + Comment.belongsTo(models.RunCase, { + foreignKey: 'commentableId', + constraints: false, + as: 'runCase', + }); + Comment.belongsTo(models.Run, { + foreignKey: 'commentableId', + constraints: false, + as: 'run', + }); + Comment.belongsTo(models.Case, { + foreignKey: 'commentableId', + constraints: false, + as: 'case', + }); + Comment.belongsTo(models.User, { + foreignKey: 'userId', + onDelete: 'SET NULL', + }); + }; + + return Comment; +} + +export default defineComment; diff --git a/backend/models/runCases.js b/backend/models/runCases.js index 668ba00..700a4bf 100644 --- a/backend/models/runCases.js +++ b/backend/models/runCases.js @@ -23,6 +23,10 @@ function defineRunCase(sequelize, DataTypes) { foreignKey: 'caseId', onDelete: 'CASCADE', }); + RunCase.hasMany(models.Comment, { + foreignKey: 'commentableId', + onDelete: 'CASCADE', + }); }; return RunCase; diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index 216c027..c2c0f2b 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -109,7 +109,19 @@ export default function (sequelize) { }, { model: RunCase, - attributes: ['id', 'runId', 'status'], + attributes: [ + 'id', + 'runId', + 'status', + [ + sequelize.literal( + '(SELECT COUNT(*) FROM `comments` WHERE `comments`.`commentableType` = ' + + sequelize.escape('RunCase') + + ' AND `comments`.`commentableId` = `RunCases`.`id`)' + ), + 'commentCount', + ], + ], // Must be 'true' when filtering by status, otherwise all cases are returned. required: runCaseRequired, where: { diff --git a/backend/routes/cases/show.js b/backend/routes/cases/show.js index ac25f60..332ee5f 100644 --- a/backend/routes/cases/show.js +++ b/backend/routes/cases/show.js @@ -7,6 +7,7 @@ import defineTag from '../../models/tags.js'; import defineAttachment from '../../models/attachments.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import defineRunCase from '../../models/runCases.js'; export default function (sequelize) { const Case = defineCase(sequelize, DataTypes); @@ -19,6 +20,10 @@ export default function (sequelize) { Attachment.belongsToMany(Case, { through: 'caseAttachments' }); Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' }); Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); + const RunCase = defineRunCase(sequelize, DataTypes); + RunCase.belongsTo(Case, { foreignKey: 'caseId' }); + Case.hasMany(RunCase, { foreignKey: 'caseId' }); + const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectVisibleFromCaseId } = visibilityMiddleware(sequelize); @@ -44,6 +49,9 @@ export default function (sequelize) { attributes: ['id', 'name'], through: { attributes: [] }, }, + { + model: RunCase, + }, ], }); return res.json(testcase); diff --git a/backend/routes/comments/delete.js b/backend/routes/comments/delete.js new file mode 100644 index 0000000..75f7b39 --- /dev/null +++ b/backend/routes/comments/delete.js @@ -0,0 +1,38 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import authMiddleware from '../../middleware/auth.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + + router.delete('/:commentId', verifySignedIn, async (req, res) => { + const commentId = req.params.commentId; + + if (!commentId) { + return res.status(400).json({ error: 'commentId is required' }); + } + + try { + const comment = await Comment.findByPk(commentId); + if (!comment) { + return res.status(404).json({ error: 'Comment not found' }); + } + + // Verify the user owns the comment + if (comment.userId !== req.userId) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + await comment.destroy(); + res.json({ success: true }); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/comments/edit.js b/backend/routes/comments/edit.js new file mode 100644 index 0000000..ef616ff --- /dev/null +++ b/backend/routes/comments/edit.js @@ -0,0 +1,53 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + const User = defineUser(sequelize, DataTypes); + Comment.belongsTo(User, { foreignKey: 'userId' }); + + router.put('/:commentId', verifySignedIn, async (req, res) => { + const commentId = req.params.commentId; + const { content } = req.body; + + if (!commentId || !content) { + return res.status(400).json({ error: 'id and content are required' }); + } + + try { + const comment = await Comment.findByPk(commentId); + if (!comment) { + return res.status(404).json({ error: 'Comment not found' }); + } + + // Verify the user owns the comment + if (comment.userId !== req.userId) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + await comment.update({ content }); + + // Fetch the comment with user data + const commentWithUser = await Comment.findByPk(commentId, { + include: [ + { + model: sequelize.models.User, + attributes: ['id', 'username', 'email'], + }, + ], + }); + + res.json(commentWithUser); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/comments/index.js b/backend/routes/comments/index.js new file mode 100644 index 0000000..9feec76 --- /dev/null +++ b/backend/routes/comments/index.js @@ -0,0 +1,46 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; +import visibilityMiddleware from '../../middleware/verifyVisible.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectVisibleFromCommentableId } = visibilityMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + const User = defineUser(sequelize, DataTypes); + Comment.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' }); + User.hasMany(Comment, { foreignKey: 'userId', onDelete: 'CASCADE' }); + + router.get('/', verifySignedIn, verifyProjectVisibleFromCommentableId, async (req, res) => { + const { commentableType, commentableId } = req.query; + + if (!commentableType || !commentableId) { + return res.status(400).json({ error: 'commentableType and commentableId are required' }); + } + + try { + const comments = await Comment.findAll({ + where: { + commentableType: commentableType, + commentableId: commentableId, + }, + include: [ + { + model: User, + attributes: ['id', 'username', 'email'], + }, + ], + order: [['createdAt', 'ASC']], + }); + res.json(comments); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/comments/new.js b/backend/routes/comments/new.js new file mode 100644 index 0000000..3c6c37b --- /dev/null +++ b/backend/routes/comments/new.js @@ -0,0 +1,50 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; +import editableMiddleware from '../../middleware/verifyEditable.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectReporterFromCommentableId } = editableMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + const User = defineUser(sequelize, DataTypes); + Comment.belongsTo(User, { foreignKey: 'userId' }); + + router.post('/', verifySignedIn, verifyProjectReporterFromCommentableId, async (req, res) => { + const { commentableType, commentableId } = req.query; + const { content } = req.body; + + if (!commentableType || !commentableId || !content) { + return res.status(400).json({ error: 'commentableType, commentableId, and content are required' }); + } + + try { + const newComment = await Comment.create({ + commentableType: commentableType, + commentableId: commentableId, + userId: req.userId, + content: content, + }); + + // Fetch the comment with user data + const commentWithUser = await Comment.findByPk(newComment.id, { + include: [ + { + model: sequelize.models.User, + attributes: ['id', 'username', 'email'], + }, + ], + }); + + res.json(commentWithUser); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index 52bc492..54d96bb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -175,6 +175,16 @@ app.use('/tags', tagsEditRoute(sequelize)); import caseTagsEditRoute from './routes/casetags/edit.js'; app.use('/casetags', caseTagsEditRoute(sequelize)); +// "/comments" +import commentsIndexRoute from './routes/comments/index.js'; +import commentsNewRoute from './routes/comments/new.js'; +import commentsEditRoute from './routes/comments/edit.js'; +import commentsDeleteRoute from './routes/comments/delete.js'; +app.use('/comments', commentsIndexRoute(sequelize)); +app.use('/comments', commentsNewRoute(sequelize)); +app.use('/comments', commentsEditRoute(sequelize)); +app.use('/comments', commentsDeleteRoute(sequelize)); + // "/home" import homeIndexRoute from './routes/home/index.js'; app.use('/home', homeIndexRoute(sequelize)); diff --git a/frontend/components/CommentItem.tsx b/frontend/components/CommentItem.tsx new file mode 100644 index 0000000..95e8b2d --- /dev/null +++ b/frontend/components/CommentItem.tsx @@ -0,0 +1,95 @@ +import { Button, Textarea, Card, CardBody } from '@heroui/react'; +import { Trash2, Edit2 } from 'lucide-react'; +import UserAvatar from './UserAvatar'; +import { CommentMessages, CommentType } from '@/types/comment'; + +type Props = { + comment: CommentType; + isEditing: boolean; + canEdit: boolean; + editContent: string; + isSubmitting: boolean; + messages: CommentMessages; + onEditContentChange: (value: string) => void; + onStartEdit: () => void; + onCancelEdit: () => void; + onSave: () => void; + onDelete: () => void; +}; + +export default function CommentItem({ + comment, + isEditing, + canEdit, + editContent, + isSubmitting, + messages, + onEditContentChange, + onStartEdit, + onCancelEdit, + onSave, + onDelete, +}: Props) { + return ( + + +
+ +
+
+
+ {comment.User.username} + {new Date(comment.createdAt).toLocaleString()} +
+ {canEdit && ( +
+ + +
+ )} +
+ {isEditing ? ( +
+