diff --git a/backend/src/index.js b/backend/src/index.js index 03473fa..bf28f3c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,4 +1,3 @@ - const express = require('express'); const cors = require('cors'); const app = express(); @@ -37,10 +36,14 @@ const categoriesRoutes = require('./routes/categories'); const activitiesRoutes = require('./routes/activities'); +const activityRegistrationRoutes = require('./routes/activityRegistration'); + const participationsRoutes = require('./routes/participations'); const uploadsRoutes = require('./routes/uploads'); +const materialsRoutes = require('./routes/materials'); + const notificationsRoutes = require('./routes/notifications'); const class_ratingsRoutes = require('./routes/class_ratings'); @@ -123,10 +126,14 @@ app.use('/api/categories', passport.authenticate('jwt', {session: false}), categ app.use('/api/activities', passport.authenticate('jwt', {session: false}), activitiesRoutes); +app.use('/api/activities', passport.authenticate('jwt', {session: false}), activityRegistrationRoutes); + app.use('/api/participations', passport.authenticate('jwt', {session: false}), participationsRoutes); app.use('/api/uploads', passport.authenticate('jwt', {session: false}), uploadsRoutes); +app.use('/api/materials', passport.authenticate('jwt', {session: false}), materialsRoutes); + app.use('/api/notifications', passport.authenticate('jwt', {session: false}), notificationsRoutes); app.use('/api/class_ratings', passport.authenticate('jwt', {session: false}), class_ratingsRoutes); diff --git a/backend/src/routes/activityRegistration.js b/backend/src/routes/activityRegistration.js new file mode 100644 index 0000000..94da17b --- /dev/null +++ b/backend/src/routes/activityRegistration.js @@ -0,0 +1,45 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.post('/:id/register', checkCrudPermissions('participations'), wrapAsync(async (req, res) => { + const { id } = req.params; + const { id: studentId } = req.currentUser; + + const activity = await db.activities.findByPk(id); + if (!activity) return res.status(404).send({ message: 'Activity not found' }); + + const existing = await db.participations.findOne({ + where: { activityId: id, studentId } + }); + if (existing) return res.status(400).send({ message: 'Already registered' }); + + if (activity.max_participants > 0 && activity.current_participants >= activity.max_participants) { + return res.status(400).send({ message: 'No seats available' }); + } + + const transaction = await db.sequelize.transaction(); + try { + await db.participations.create({ + activityId: id, + studentId, + organizationsId: activity.organizationsId, + participation_status: 'registered', + registered_at: new Date() + }, { transaction }); + + await activity.increment('current_participants', { transaction }); + + // Add notification logic here if needed + + await transaction.commit(); + res.status(200).send({ message: 'Registered successfully' }); + } catch (err) { + await transaction.rollback(); + throw err; + } +})); + +module.exports = router; diff --git a/backend/src/routes/materials.js b/backend/src/routes/materials.js new file mode 100644 index 0000000..3f66fd5 --- /dev/null +++ b/backend/src/routes/materials.js @@ -0,0 +1,42 @@ + +const express = require('express'); +const router = express.Router(); +const wrapAsync = require('../helpers').wrapAsync; +const db = require('../db/models'); + +// POST /api/materials/upload +// Expects: { participationId, file_url, file_type, file_size_bytes } +router.post('/upload', wrapAsync(async (req, res) => { + const { participationId, file_url, file_type, file_size_bytes } = req.body; + const currentUser = req.currentUser; + const organizationsId = currentUser.organizationsId; + + const transaction = await db.sequelize.transaction(); + try { + // 1. Create the upload record + const upload = await db.uploads.create({ + file_type, + file_url, + file_size_bytes, + uploaded_at: new Date(), + participationId, + organizationsId, + createdById: currentUser.id, + updatedById: currentUser.id + }, { transaction }); + + // 2. Update participation status + await db.participations.update( + { participation_status: 'materials_uploaded' }, + { where: { id: participationId }, transaction } + ); + + await transaction.commit(); + res.status(200).send({ success: true, upload }); + } catch (error) { + await transaction.rollback(); + throw error; + } +})); + +module.exports = router; diff --git a/backend/src/routes/participations.js b/backend/src/routes/participations.js index 5becf06..cb2cf42 100644 --- a/backend/src/routes/participations.js +++ b/backend/src/routes/participations.js @@ -1,4 +1,3 @@ - const express = require('express'); const ParticipationsService = require('../services/participations'); @@ -91,6 +90,50 @@ router.post('/', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +/** + * @swagger + * /api/participations/{id}/review: + * post: + * security: + * - bearerAuth: [] + * tags: [Participations] + * summary: Review an activity participation + * description: Review an activity participation + * parameters: + * - in: path + * name: id + * description: Participation ID + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * comment: + * type: string + * points: + * type: number + * responses: + * 200: + * description: The participation was successfully reviewed + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.post('/:id/review', wrapAsync(async (req, res) => { + await ParticipationsService.review(req.params.id, req.body, req.currentUser); + res.status(200).send({ success: true }); +})); + /** * @swagger * /api/budgets/bulk-import: @@ -444,4 +487,4 @@ router.get('/:id', wrapAsync(async (req, res) => { router.use('/', require('../helpers').commonErrorHandler); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/participations.js b/backend/src/services/participations.js index d925f6e..d93df98 100644 --- a/backend/src/services/participations.js +++ b/backend/src/services/participations.js @@ -1,15 +1,7 @@ const db = require('../db/models'); const ParticipationsDBApi = require('../db/api/participations'); -const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - +const moment = require('moment'); module.exports = class ParticipationsService { static async create(data, currentUser) { @@ -97,6 +89,93 @@ module.exports = class ParticipationsService { } }; + static async review(id, data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let participation = await ParticipationsDBApi.findBy( + {id}, + {transaction}, + ); + + if (!participation) { + throw new ValidationError( + 'participationsNotFound', + ); + } + + const reviewData = { + participation_status: data.status, + organizer_comment: data.comment, + awarded_points: data.points, + reviewed_at: moment().toDate(), + }; + + const updatedParticipation = await ParticipationsDBApi.update( + id, + reviewData, + { + currentUser, + transaction, + }, + ); + + if (data.status === 'accepted' && data.points > 0) { + // Find existing achievements for student + const studentId = participation.studentId || participation.student?.id; + if (studentId) { + // Find all accepted participations for this student to calculate total points + const allParticipations = await db.participations.findAll({ + where: { + studentId, + participation_status: 'accepted' + }, + include: [{ + model: db.activities, + as: 'activity' + }], + transaction + }); + + const totalPoints = allParticipations.reduce((acc, p) => acc + (parseFloat(p.awarded_points) || 0), 0) + (parseFloat(data.points) || 0); + + // Check if student earned new achievements + const allAchievements = await db.achievements.findAll({ + where: { is_active: true }, + transaction + }); + + for (const achievement of allAchievements) { + if (totalPoints >= achievement.points_threshold) { + const alreadyAwarded = await db.user_achievements.findOne({ + where: { + userId: studentId, + achievementId: achievement.id + }, + transaction + }); + + if (!alreadyAwarded) { + await db.user_achievements.create({ + userId: studentId, + achievementId: achievement.id, + awarded_at: new Date(), + note: `Total points: ${totalPoints}` + }, { transaction }); + } + } + } + } + } + + await transaction.commit(); + return updatedParticipation; + + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -134,5 +213,3 @@ module.exports = class ParticipationsService { }; - - diff --git a/frontend/src/components/KanbanBoard/KanbanBoard.tsx b/frontend/src/components/KanbanBoard/KanbanBoard.tsx index 84954b8..8532df9 100644 --- a/frontend/src/components/KanbanBoard/KanbanBoard.tsx +++ b/frontend/src/components/KanbanBoard/KanbanBoard.tsx @@ -12,6 +12,7 @@ type Props = { showFieldName: string; deleteThunk: AsyncThunk; updateThunk: AsyncThunk; + onCardClick?: (id: string) => void; }; const KanbanBoard = ({ @@ -22,6 +23,7 @@ const KanbanBoard = ({ showFieldName, deleteThunk, updateThunk, + onCardClick, }: Props) => { return (
))} @@ -48,4 +51,4 @@ const KanbanBoard = ({ ); }; -export default KanbanBoard; +export default KanbanBoard; \ No newline at end of file diff --git a/frontend/src/components/KanbanBoard/KanbanCard.tsx b/frontend/src/components/KanbanBoard/KanbanCard.tsx index 7655572..83d9436 100644 --- a/frontend/src/components/KanbanBoard/KanbanCard.tsx +++ b/frontend/src/components/KanbanBoard/KanbanCard.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import Link from 'next/link'; import moment from 'moment'; import ListActionsPopover from '../ListActionsPopover'; import { DragSourceMonitor, useDrag } from 'react-dnd'; @@ -10,6 +9,7 @@ type Props = { entityName: string; showFieldName: string; setItemIdToDelete: (id: string) => void; + onClick?: (id: string) => void; }; const KanbanCard = ({ @@ -18,6 +18,7 @@ const KanbanCard = ({ showFieldName, setItemIdToDelete, column, + onClick }: Props) => { const [{ isDragging }, drag] = useDrag( () => ({ @@ -33,17 +34,15 @@ const KanbanCard = ({ return (
onClick && onClick(item.id)} className={ `bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}` } >
- +
{item[showFieldName] ?? 'No data'} - +

{moment(item.createdAt).format('MMM DD hh:mm a')}

@@ -61,4 +60,4 @@ const KanbanCard = ({ ); }; -export default KanbanCard; +export default KanbanCard; \ No newline at end of file diff --git a/frontend/src/components/KanbanBoard/KanbanColumn.tsx b/frontend/src/components/KanbanBoard/KanbanColumn.tsx index 425a0d3..2ef514d 100644 --- a/frontend/src/components/KanbanBoard/KanbanColumn.tsx +++ b/frontend/src/components/KanbanBoard/KanbanColumn.tsx @@ -15,6 +15,7 @@ type Props = { filtersQuery: any; deleteThunk: AsyncThunk; updateThunk: AsyncThunk; + onCardClick?: (id: string) => void; }; type DropResult = { @@ -32,6 +33,7 @@ const KanbanColumn = ({ filtersQuery, deleteThunk, updateThunk, + onCardClick }: Props) => { const [currentPage, setCurrentPage] = useState(0); const [count, setCount] = useState(0); @@ -184,6 +186,7 @@ const KanbanColumn = ({ showFieldName={showFieldName} entityName={entityName} setItemIdToDelete={setItemIdToDelete} + onClick={onCardClick} />
))} @@ -206,4 +209,4 @@ const KanbanColumn = ({ ); }; -export default KanbanColumn; +export default KanbanColumn; \ No newline at end of file diff --git a/frontend/src/components/Participations/ParticipationReviewModal.tsx b/frontend/src/components/Participations/ParticipationReviewModal.tsx new file mode 100644 index 0000000..7283ab2 --- /dev/null +++ b/frontend/src/components/Participations/ParticipationReviewModal.tsx @@ -0,0 +1,82 @@ +import { mdiCheck, mdiClose, mdiCommentAlert } from '@mdi/js'; +import React, { useState } from 'react'; +import axios from 'axios'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import FormField from '../FormField'; + +interface ReviewModalProps { + participationId: string; + isActive: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const ParticipationReviewModal: React.FC = ({ + participationId, + isActive, + onConfirm, + onCancel, +}) => { + const [status, setStatus] = useState('accepted'); + const [comment, setComment] = useState(''); + const [points, setPoints] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + setLoading(true); + try { + await axios.post(`/participations/${participationId}/review`, { + status, + comment, + points: parseFloat(points), + }); + onConfirm(); + } catch (error) { + console.error('Review failed', error); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + + + + setPoints(e.target.value)} + /> + + +