diff --git a/backend/package-lock.json b/backend/package-lock.json index 844ccb4..041d381 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,10 +11,12 @@ "dependencies": { "bcryptjs": "^2.4.3", "cloudinary": "^2.8.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.0.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.3", "morgan": "^1.10.1", @@ -27,6 +29,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.5", @@ -1519,6 +1522,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1534,6 +1547,7 @@ "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2094,6 +2108,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -2558,6 +2591,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 985be58..28054bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,10 +19,12 @@ "dependencies": { "bcryptjs": "^2.4.3", "cloudinary": "^2.8.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "express-validator": "^7.0.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.3", "morgan": "^1.10.1", @@ -35,6 +37,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.5", diff --git a/backend/src/app.ts b/backend/src/app.ts index b81d813..4413420 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,54 +1,74 @@ -import dotenv from 'dotenv'; -dotenv.config(); - -import express, { Application } from 'express'; +import express from 'express'; import cors from 'cors'; -import morgan from "morgan"; -import { errorHandler } from './middlewares/errorMiddleware'; - -import authRoutes from './routes/authRoutes'; -import notificationRoutes from './routes/notificationRoutes'; -import workshopRoutes from './routes/workshopRoutes'; -import teamRoutes from './routes/teamRoutes'; -import roleRoutes from './routes/roleRoutes'; -import workshopProjectRoutes from './routes/workshopProjectRoutes'; -import workshopTaskRoutes, { taskRouter, userTaskRouter, teamTaskRouter } from './routes/workshopTaskRoutes'; -import auditRoutes from './routes/auditRoutes'; -import permissionRoutes from './routes/permissionRoutes'; -import chatRoutes from './routes/chatRoutes'; -import activityRoutes from './routes/activityRoutes'; +import morgan from 'morgan'; +import helmet from 'helmet'; +import cookieParser from 'cookie-parser'; import passport from 'passport'; -const app: Application = express(); +import Database from './config/db.config'; +import { Container } from '@di/types'; +import { env } from './config/env'; + +import createAuthRoutes from './modules/auth/routes/authRoutes'; +import createNotificationRoutes from './modules/notification/routes/notificationRoutes'; +import createWorkshopRoutes from './modules/workshop/routes/workshopRoutes'; +import createTeamRoutes from './modules/team/routes/teamRoutes'; +import createRoleRoutes from './modules/access-control/routes/roleRoutes'; +import createWorkshopProjectRoutes from './modules/project/routes/workshopProjectRoutes'; +import createWorkshopTaskRoutes, { + createTaskRouter, + createUserTaskRouter, + createTeamTaskRouter +} from './modules/task/routes/workshopTaskRoutes'; +import createAuditRoutes from './modules/audit/routes/auditRoutes'; +import createPermissionRoutes from './modules/access-control/routes/permissionRoutes'; +import createChatRoutes from './modules/chat/routes/chatRoutes'; +import createActivityRoutes from './modules/audit/routes/activityRoutes'; +import createInviteRoutes from './modules/invitation/routes/inviteRoutes'; + +import { errorHandler, injectContainer } from '@middlewares'; +import { configurePassport } from './config/passport'; +import { API_PREFIX, MODULE_BASE } from '@constants'; + +export const createApp = (container: Container) => { + const app = express(); + + Database.getInstance(); -app.use(passport.initialize()); + app.use(helmet()); + app.use(injectContainer(container)); + app.use(cookieParser()); + app.use(cors({ + origin: env.FRONTEND_URL, + credentials: true + })); -app.use(cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:3000', - credentials: true -})); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + app.use(morgan('dev')); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(morgan("dev")); + app.use(passport.initialize()); + configurePassport(container); -app.use('/api/auth', authRoutes); -app.use('/api/notifications', notificationRoutes); + app.use(`${API_PREFIX}${MODULE_BASE.AUTH}`, createAuthRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.NOTIFICATIONS}`, createNotificationRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.WORKSHOPS}`, createWorkshopRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.TEAMS}`, createTeamRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.ROLES}`, createRoleRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.PROJECTS}`, createWorkshopProjectRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.PROJECT_TASKS}`, createWorkshopTaskRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.AUDIT}`, createAuditRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.PERMISSION_CHECK}`, createPermissionRoutes(container)); -app.use('/api/workshops', workshopRoutes); -app.use('/api/workshops/:workshopId/teams', teamRoutes); -app.use('/api/workshops/:workshopId/roles', roleRoutes); -app.use('/api/workshops/:workshopId/projects', workshopProjectRoutes); -app.use('/api/workshops/:workshopId/projects/:projectId/tasks', workshopTaskRoutes); -app.use('/api/workshops/:workshopId/audit', auditRoutes); -app.use('/api/workshops/:workshopId/permissions', permissionRoutes); -app.use('/api/workshop-tasks', taskRouter); -app.use('/api/users', userTaskRouter); -app.use('/api/teams', teamTaskRouter); + app.use(`${API_PREFIX}${MODULE_BASE.TASKS}`, createTaskRouter(container)); + app.use(`${API_PREFIX}${MODULE_BASE.USER_TASKS}`, createUserTaskRouter(container)); + app.use(`${API_PREFIX}${MODULE_BASE.TEAM_TASKS}`, createTeamTaskRouter(container)); -app.use('/api/chat', chatRoutes); -app.use('/api', activityRoutes); + app.use(`${API_PREFIX}${MODULE_BASE.CHAT}`, createChatRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.INVITES}`, createInviteRoutes(container)); + app.use(`${API_PREFIX}${MODULE_BASE.ACTIVITY}`, createActivityRoutes(container)); -app.use(errorHandler); + app.use(errorHandler); -export default app; \ No newline at end of file + return app; +} diff --git a/backend/src/bootstrap.ts b/backend/src/bootstrap.ts new file mode 100644 index 0000000..83d5317 --- /dev/null +++ b/backend/src/bootstrap.ts @@ -0,0 +1,18 @@ +import { createDIContainer } from "./di"; +import { createApp } from "./app"; +import { createServer } from "http"; +import { env } from "./config/env"; + +export const bootstrap = async () => { + const httpServer = createServer(); + + const container = createDIContainer(httpServer); + + const app = createApp(container); + httpServer.on('request', app); + + httpServer.listen(env.PORT, () => { + console.log(`Server started on port ${env.PORT}`); + console.log(`Environment: ${env.NODE_ENV}`); + }); +}; diff --git a/backend/src/config/cloudinary.config.ts b/backend/src/config/cloudinary.config.ts new file mode 100644 index 0000000..2e5d369 --- /dev/null +++ b/backend/src/config/cloudinary.config.ts @@ -0,0 +1,10 @@ +import { v2 as cloudinary } from 'cloudinary'; +import { env } from './env'; + +cloudinary.config({ + cloud_name: env.CLOUDINARY_CLOUD_NAME, + api_key: env.CLOUDINARY_API_KEY, + api_secret: env.CLOUDINARY_API_SECRET +}); + +export default cloudinary; diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index 4f65a9a..505a5de 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -6,7 +6,7 @@ export const connectDatabase = async (): Promise => { await mongoose.connect(mongoUri); - console.log('✅ MongoDB connected successfully'); + console.log('MongoDB connected successfully'); mongoose.connection.on('error', (error) => { console.error('MongoDB connection error:', error); diff --git a/backend/src/config/db.config.ts b/backend/src/config/db.config.ts new file mode 100644 index 0000000..d47c64f --- /dev/null +++ b/backend/src/config/db.config.ts @@ -0,0 +1,29 @@ +import mongoose from 'mongoose'; +import { env } from './env'; + +class Database { + private static instance: Database; + + private constructor() { + this.connect(); + } + + public static getInstance(): Database { + if (!Database.instance) { + Database.instance = new Database(); + } + return Database.instance; + } + + private async connect() { + try { + await mongoose.connect(env.MONGODB_URI); + console.log('MongoDB connected successfully'); + } catch (error) { + console.error('MongoDB connection error:', error); + process.exit(1); + } + } +} + +export default Database; diff --git a/backend/src/config/env.init.ts b/backend/src/config/env.init.ts new file mode 100644 index 0000000..3c83d9f --- /dev/null +++ b/backend/src/config/env.init.ts @@ -0,0 +1,2 @@ +import dotenv from 'dotenv'; +dotenv.config(); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..e7bda79 --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,13 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +export const env = { + PORT: process.env.PORT || 5001, + MONGODB_URI: process.env.MONGODB_URI || 'mongodb://localhost:27017/teamup', + FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000', + JWT_SECRET: process.env.JWT_SECRET || 'secret', + CLOUDINARY_CLOUD_NAME: process.env.CLOUDINARY_CLOUD_NAME, + CLOUDINARY_API_KEY: process.env.CLOUDINARY_API_KEY, + CLOUDINARY_API_SECRET: process.env.CLOUDINARY_API_SECRET, + NODE_ENV: process.env.NODE_ENV || 'development' +}; diff --git a/backend/src/config/passport.ts b/backend/src/config/passport.ts index 30deab4..e6a2d93 100644 --- a/backend/src/config/passport.ts +++ b/backend/src/config/passport.ts @@ -1,9 +1,7 @@ import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as GitHubStrategy } from 'passport-github2'; -import { AuthService } from '../services/AuthService'; - -const authService = new AuthService(); +import { Container } from '../di/types'; export const isStrategyEnabled = (strategy: string): boolean => { switch (strategy) { @@ -22,7 +20,8 @@ export const isStrategyEnabled = (strategy: string): boolean => { } }; -export const configurePassport = () => { +export const configurePassport = (container: Container) => { + const authService = container.authSrv; if (isStrategyEnabled('google')) { console.log('Initializing Google Strategy...'); diff --git a/backend/src/controllers/InviteController.ts b/backend/src/controllers/InviteController.ts deleted file mode 100644 index b96dfa2..0000000 --- a/backend/src/controllers/InviteController.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Response, NextFunction } from 'express'; -import { AuthRequest } from '../types'; -import { Invitation } from '../models/Invitation'; -import { WorkshopService } from '../services/WorkshopService'; -import { NotFoundError, ValidationError } from '../utils/errors'; - -export class InviteController { - private workshopService: WorkshopService; - - constructor() { - this.workshopService = new WorkshopService(); - } - - getInviteDetails = async (req: AuthRequest, res: Response, next: NextFunction) => { - try { - const { token } = req.params; - const invitation = await Invitation.findOne({ token, isUsed: false, expiresAt: { $gt: new Date() } }) - .populate('workshop', 'name description visibility') - .populate('invitedBy', 'name email'); - - if (!invitation) { - throw new NotFoundError('Invitation is invalid or has expired'); - } - - res.json({ - success: true, - data: { - type: 'workshop', - project: { - _id: (invitation.workshop as any)._id, - title: (invitation.workshop as any).name, - description: (invitation.workshop as any).description, - }, - invitedBy: invitation.invitedBy, - email: invitation.email, - expiresAt: invitation.expiresAt - } - }); - } catch (error) { - next(error); - } - }; - - acceptInvite = async (req: AuthRequest, res: Response, next: NextFunction) => { - try { - const { token } = req.params; - const userId = req.user?.id; - - if (!userId) { - throw new ValidationError('Authentication required to accept invitation'); - } - - const invitation = await Invitation.findOne({ token, isUsed: false, expiresAt: { $gt: new Date() } }); - if (!invitation) { - throw new NotFoundError('Invitation is invalid or has expired'); - } - - // Ensure the logged-in user's email matches the invite email (optional but recommended) - const User = require('../models/User').User; - const user = await User.findById(userId); - if (user.email.toLowerCase() !== invitation.email.toLowerCase()) { - throw new ValidationError(`This invitation was sent to ${invitation.email}, but you are logged in as ${user.email}`); - } - - // Add user to workshop - await this.workshopService.acceptInvitationByToken(invitation, userId); - - // Mark invite as used - invitation.isUsed = true; - await invitation.save(); - - res.json({ - success: true, - message: 'You have successfully joined the workshop' - }); - } catch (error) { - next(error); - } - }; -} diff --git a/backend/src/di/container.ts b/backend/src/di/container.ts new file mode 100644 index 0000000..fd898b3 --- /dev/null +++ b/backend/src/di/container.ts @@ -0,0 +1,271 @@ + +import { Server as HTTPServer } from 'http'; +import { Container } from './types'; +import { TokenProvider } from '../shared/providers/TokenProvider'; +import { EmailProvider } from '../shared/providers/EmailProvider'; +import { HashProvider } from '../shared/providers/HashProvider'; +import { ActivityHistoryRepository } from '../modules/audit/repositories/ActivityHistoryRepository'; +import { AuditLogRepository } from '../modules/audit/repositories/AuditLogRepository'; +import { MembershipRepository } from '../modules/team/repositories/MembershipRepository'; +import { InvitationRepository } from '../modules/invitation/repositories/InvitationRepository'; +import { NotificationRepository } from '../modules/notification/repositories/NotificationRepository'; +import { PasswordResetRepository } from '../modules/auth/repositories/PasswordResetRepository'; +import { PendingUserRepository } from '../modules/auth/repositories/PendingUserRepository'; +import { RoleAssignmentRepository } from '../modules/access-control/repositories/RoleAssignmentRepository'; +import { RoleRepository } from '../modules/access-control/repositories/RoleRepository'; +import { TeamRepository } from '../modules/team/repositories/TeamRepository'; +import { UserRepository } from '../modules/user/repositories/UserRepository'; +import { WorkshopProjectRepository } from '../modules/project/repositories/WorkshopProjectRepository'; +import { WorkshopRepository } from '../modules/workshop/repositories/WorkshopRepository'; +import { WorkshopTaskRepository } from '../modules/task/repositories/WorkshopTaskRepository'; +import { ActivityHistoryService } from '../modules/audit/services/ActivityHistoryService'; +import { AuditService } from '../modules/audit/services/AuditService'; +import { AuthService } from '../modules/auth/services/AuthService'; +import { ChatService } from '../modules/chat/services/ChatService'; +import { CloudinaryService } from '../shared/services/CloudinaryService'; +import { EmailService } from '../shared/services/EmailService'; +import { InvitationService } from '../modules/invitation/services/InvitationService'; +import { NotificationService } from '../modules/notification/services/NotificationService'; +import { PermissionService } from '../modules/access-control/services/PermissionService'; +import { SocketService } from '../socket/SocketService'; +import { TeamService } from '../modules/team/services/TeamService'; +import { WorkshopProjectService } from '../modules/project/services/WorkshopProjectService'; +import { WorkshopService } from '../modules/workshop/services/WorkshopService'; +import { WorkshopTaskService } from '../modules/task/services/WorkshopTaskService'; +import { ActivityController } from '../modules/audit/controllers/ActivityController'; +import { AuditController } from '../modules/audit/controllers/AuditController'; +import { AuthController } from '../modules/auth/controllers/AuthController'; +import { ChatController } from '../modules/chat/controllers/ChatController'; +import { InviteController } from '../modules/invitation/controllers/InviteController'; +import { NotificationController } from '../modules/notification/controllers/NotificationController'; +import { PermissionController } from '../modules/access-control/controllers/PermissionController'; +import { RoleController } from '../modules/access-control/controllers/RoleController'; +import { TeamController } from '../modules/team/controllers/TeamController'; +import { WorkshopController } from '../modules/workshop/controllers/WorkshopController'; +import { WorkshopProjectController } from '../modules/project/controllers/WorkshopProjectController'; +import { WorkshopTaskController } from '../modules/task/controllers/WorkshopTaskController'; + +import { ITokenProvider } from '../shared/interfaces/ITokenProvider'; +import { IEmailProvider } from '../shared/interfaces/IEmailProvider'; +import { IHashProvider } from '../shared/interfaces/IHashProvider'; +import { IActivityHistoryRepository } from '../modules/audit/interfaces/IActivityHistoryRepository'; +import { IAuditLogRepository } from '../modules/audit/interfaces/IAuditLogRepository'; +import { IMembershipRepository } from '../modules/team/interfaces/IMembershipRepository'; +import { INotificationRepository } from '../modules/notification/interfaces/INotificationRepository'; +import { IPasswordResetRepository } from '../modules/auth/interfaces/IPasswordResetRepository'; +import { IPendingUserRepository } from '../modules/auth/interfaces/IPendingUserRepository'; +import { IRoleAssignmentRepository } from '../modules/access-control/interfaces/IRoleAssignmentRepository'; +import { IRoleRepository } from '../modules/access-control/interfaces/IRoleRepository'; +import { ITeamRepository } from '../modules/team/interfaces/ITeamRepository'; +import { IUserRepository } from '../modules/user/interfaces/IUserRepository'; +import { IWorkshopProjectRepository } from '../modules/project/interfaces/IWorkshopProjectRepository'; +import { IWorkshopRepository } from '../modules/workshop/interfaces/IWorkshopRepository'; +import { IWorkshopTaskRepository } from '../modules/task/interfaces/IWorkshopTaskRepository'; +import { IActivityHistoryService } from '../modules/audit/interfaces/IActivityHistoryService'; +import { IAuditService } from '../modules/audit/interfaces/IAuditService'; +import { IAuthService } from '../modules/auth/interfaces/IAuthService'; +import { IChatService } from '../modules/chat/interfaces/IChatService'; +import { ICloudinaryService } from '../shared/interfaces/ICloudinaryService'; +import { IEmailService } from '../shared/interfaces/IEmailService'; +import { INotificationService } from '../modules/notification/interfaces/INotificationService'; +import { IPermissionService } from '../modules/access-control/interfaces/IPermissionService'; +import { ISocketService } from '../shared/interfaces/ISocketService'; +import { ITeamService } from '../modules/team/interfaces/ITeamService'; +import { IWorkshopProjectService } from '../modules/project/interfaces/IWorkshopProjectService'; +import { IWorkshopService } from '../modules/workshop/interfaces/IWorkshopService'; +import { IWorkshopTaskService } from '../modules/task/interfaces/IWorkshopTaskService'; +import { IInvitationService } from '../modules/invitation/interfaces/IInvitationService'; +import { IInvitationRepository } from '../modules/invitation/interfaces/IInvitationRepository'; + +export class DIContainer implements Container { + public tokenProv: ITokenProvider; + public emailProv: IEmailProvider; + public hashProv: IHashProvider; + + public activityHistoryRepo: IActivityHistoryRepository; + public auditLogRepo: IAuditLogRepository; + public membershipRepo: IMembershipRepository; + public notificationRepo: INotificationRepository; + public passwordResetRepo: IPasswordResetRepository; + public pendingUserRepo: IPendingUserRepository; + public roleAssignmentRepo: IRoleAssignmentRepository; + public invitationRepo: IInvitationRepository; + public roleRepo: IRoleRepository; + public teamRepo: ITeamRepository; + public userRepo: IUserRepository; + public workshopProjectRepo: IWorkshopProjectRepository; + public workshopRepo: IWorkshopRepository; + public workshopTaskRepo: IWorkshopTaskRepository; + + public activityHistorySrv: IActivityHistoryService; + public auditSrv: IAuditService; + public authSrv: IAuthService; + public chatSrv: IChatService; + public cloudinarySrv: ICloudinaryService; + public emailSrv: IEmailService; + public invitationSrv: IInvitationService; + public notificationSrv: INotificationService; + public permissionSrv: IPermissionService; + public socketSrv: ISocketService; + public teamSrv: ITeamService; + public workshopProjectSrv: IWorkshopProjectService; + public workshopSrv: IWorkshopService; + public workshopTaskSrv: IWorkshopTaskService; + + public activityCtrl: ActivityController; + public auditCtrl: AuditController; + public authCtrl: AuthController; + public chatCtrl: ChatController; + public inviteCtrl: InviteController; + public notificationCtrl: NotificationController; + public permissionCtrl: PermissionController; + public roleCtrl: RoleController; + public teamCtrl: TeamController; + public workshopCtrl: WorkshopController; + public workshopProjectCtrl: WorkshopProjectController; + public workshopTaskCtrl: WorkshopTaskController; + + constructor(httpServer: HTTPServer) { + this.tokenProv = new TokenProvider(); + this.emailProv = new EmailProvider(); + this.hashProv = new HashProvider(); + + this.activityHistoryRepo = new ActivityHistoryRepository(); + this.auditLogRepo = new AuditLogRepository(); + this.membershipRepo = new MembershipRepository(); + this.notificationRepo = new NotificationRepository(); + this.passwordResetRepo = new PasswordResetRepository(); + this.pendingUserRepo = new PendingUserRepository(); + this.roleAssignmentRepo = new RoleAssignmentRepository(); + this.invitationRepo = new InvitationRepository(); + this.roleRepo = new RoleRepository(); + this.teamRepo = new TeamRepository(); + this.userRepo = new UserRepository(); + this.workshopProjectRepo = new WorkshopProjectRepository(); + this.workshopRepo = new WorkshopRepository(); + this.workshopTaskRepo = new WorkshopTaskRepository(); + + this.activityHistorySrv = new ActivityHistoryService(this.activityHistoryRepo); + this.auditSrv = new AuditService(this.auditLogRepo); + this.authSrv = new AuthService( + this.userRepo, + this.pendingUserRepo, + this.passwordResetRepo, + this.tokenProv, + this.emailProv, + this.hashProv + ); + + this.socketSrv = new SocketService( + httpServer, + this.userRepo, + this.tokenProv + ); + + this.cloudinarySrv = new CloudinaryService(); + this.emailSrv = new EmailService(this.emailProv); + this.notificationSrv = new NotificationService( + this.notificationRepo, + this.socketSrv + ); + this.permissionSrv = new PermissionService( + this.roleAssignmentRepo, + this.workshopRepo, + this.teamRepo, + this.membershipRepo, + this.workshopProjectRepo + ); + this.chatSrv = new ChatService( + this.activityHistorySrv, + this.workshopRepo, + this.teamRepo, + this.workshopProjectRepo, + this.membershipRepo, + this.socketSrv + ); + this.teamSrv = new TeamService( + this.teamRepo, + this.membershipRepo, + this.workshopRepo, + this.auditSrv, + this.permissionSrv, + this.chatSrv, + this.socketSrv + ); + this.workshopProjectSrv = new WorkshopProjectService( + this.workshopProjectRepo, + this.workshopRepo, + this.teamRepo, + this.auditSrv, + this.permissionSrv, + this.chatSrv, + this.socketSrv + ); + this.workshopSrv = new WorkshopService( + this.workshopRepo, + this.membershipRepo, + this.teamRepo, + this.roleRepo, + this.roleAssignmentRepo, + this.workshopProjectRepo, + this.auditSrv, + this.permissionSrv, + this.emailSrv, + this.chatSrv, + this.socketSrv + ); + + this.workshopTaskSrv = new WorkshopTaskService( + this.workshopTaskRepo, + this.workshopProjectRepo, + this.membershipRepo, + this.teamRepo, + this.notificationRepo, + this.auditSrv, + this.permissionSrv, + this.socketSrv + ); + + this.invitationSrv = new InvitationService( + this.invitationRepo, + this.workshopSrv, + this.userRepo + ); + + this.activityCtrl = new ActivityController(this.activityHistorySrv); + this.auditCtrl = new AuditController(this.auditSrv, this.workshopRepo); + this.authCtrl = new AuthController(this.authSrv); + this.chatCtrl = new ChatController( + this.chatSrv, + this.cloudinarySrv, + this.permissionSrv + ); + this.chatCtrl.setSocketService(this.socketSrv); + + this.inviteCtrl = new InviteController( + this.invitationSrv + ); + this.notificationCtrl = new NotificationController(this.notificationSrv); + this.permissionCtrl = new PermissionController(this.permissionSrv); + this.roleCtrl = new RoleController( + this.roleRepo, + this.roleAssignmentRepo, + this.workshopRepo, + this.auditSrv, + this.permissionSrv + ); + this.roleCtrl.setSocketService(this.socketSrv); + + this.teamCtrl = new TeamController(this.teamSrv); + this.teamCtrl.setSocketService(this.socketSrv); + + this.workshopCtrl = new WorkshopController(this.workshopSrv); + this.workshopCtrl.setSocketService(this.socketSrv); + + this.workshopProjectCtrl = new WorkshopProjectController(this.workshopProjectSrv); + this.workshopProjectCtrl.setSocketService(this.socketSrv); + + this.workshopTaskCtrl = new WorkshopTaskController(this.workshopTaskSrv); + this.workshopTaskCtrl.setSocketService(this.socketSrv); + } +} diff --git a/backend/src/di/index.ts b/backend/src/di/index.ts new file mode 100644 index 0000000..ac18632 --- /dev/null +++ b/backend/src/di/index.ts @@ -0,0 +1,10 @@ +import { Server as HTTPServer } from 'http'; +import { DIContainer } from './container'; +import { Container } from './types'; + +export * from './types'; +export * from './container'; + +export const createDIContainer = (httpServer: HTTPServer): Container => { + return new DIContainer(httpServer); +}; diff --git a/backend/src/di/types.ts b/backend/src/di/types.ts new file mode 100644 index 0000000..7aad9fb --- /dev/null +++ b/backend/src/di/types.ts @@ -0,0 +1,93 @@ + +import { ITokenProvider } from '../shared/interfaces/ITokenProvider'; +import { IEmailProvider } from '../shared/interfaces/IEmailProvider'; +import { IHashProvider } from '../shared/interfaces/IHashProvider'; +import { IActivityHistoryRepository } from '../modules/audit/interfaces/IActivityHistoryRepository'; +import { IAuditLogRepository } from '../modules/audit/interfaces/IAuditLogRepository'; +import { IMembershipRepository } from '../modules/team/interfaces/IMembershipRepository'; +import { INotificationRepository } from '../modules/notification/interfaces/INotificationRepository'; +import { IPasswordResetRepository } from '../modules/auth/interfaces/IPasswordResetRepository'; +import { IPendingUserRepository } from '../modules/auth/interfaces/IPendingUserRepository'; +import { IRoleAssignmentRepository } from '../modules/access-control/interfaces/IRoleAssignmentRepository'; +import { IRoleRepository } from '../modules/access-control/interfaces/IRoleRepository'; +import { ITeamRepository } from '../modules/team/interfaces/ITeamRepository'; +import { IUserRepository } from '../modules/user/interfaces/IUserRepository'; +import { IWorkshopProjectRepository } from '../modules/project/interfaces/IWorkshopProjectRepository'; +import { IWorkshopRepository } from '../modules/workshop/interfaces/IWorkshopRepository'; +import { IWorkshopTaskRepository } from '../modules/task/interfaces/IWorkshopTaskRepository'; +import { IActivityHistoryService } from '../modules/audit/interfaces/IActivityHistoryService'; +import { IAuditService } from '../modules/audit/interfaces/IAuditService'; +import { IAuthService } from '../modules/auth/interfaces/IAuthService'; +import { IChatService } from '../modules/chat/interfaces/IChatService'; +import { ICloudinaryService } from '../shared/interfaces/ICloudinaryService'; +import { IEmailService } from '../shared/interfaces/IEmailService'; +import { INotificationService } from '../modules/notification/interfaces/INotificationService'; +import { IPermissionService } from '../modules/access-control/interfaces/IPermissionService'; +import { ISocketService } from '../shared/interfaces/ISocketService'; +import { ITeamService } from '../modules/team/interfaces/ITeamService'; +import { IWorkshopProjectService } from '../modules/project/interfaces/IWorkshopProjectService'; +import { IWorkshopService } from '../modules/workshop/interfaces/IWorkshopService'; +import { IWorkshopTaskService } from '../modules/task/interfaces/IWorkshopTaskService'; +import { ActivityController } from '../modules/audit/controllers/ActivityController'; +import { AuditController } from '../modules/audit/controllers/AuditController'; +import { AuthController } from '../modules/auth/controllers/AuthController'; +import { ChatController } from '../modules/chat/controllers/ChatController'; +import { InviteController } from '../modules/invitation/controllers/InviteController'; +import { NotificationController } from '../modules/notification/controllers/NotificationController'; +import { PermissionController } from '../modules/access-control/controllers/PermissionController'; +import { RoleController } from '../modules/access-control/controllers/RoleController'; +import { TeamController } from '../modules/team/controllers/TeamController'; +import { WorkshopController } from '../modules/workshop/controllers/WorkshopController'; +import { WorkshopProjectController } from '../modules/project/controllers/WorkshopProjectController'; +import { WorkshopTaskController } from '../modules/task/controllers/WorkshopTaskController'; +import { IInvitationService } from '../modules/invitation/interfaces/IInvitationService'; +import { IInvitationRepository } from '../modules/invitation/interfaces/IInvitationRepository'; + +export interface Container { + tokenProv: ITokenProvider; + emailProv: IEmailProvider; + hashProv: IHashProvider; + + activityHistoryRepo: IActivityHistoryRepository; + auditLogRepo: IAuditLogRepository; + membershipRepo: IMembershipRepository; + notificationRepo: INotificationRepository; + passwordResetRepo: IPasswordResetRepository; + pendingUserRepo: IPendingUserRepository; + roleAssignmentRepo: IRoleAssignmentRepository; + invitationRepo: IInvitationRepository; + roleRepo: IRoleRepository; + teamRepo: ITeamRepository; + userRepo: IUserRepository; + workshopProjectRepo: IWorkshopProjectRepository; + workshopRepo: IWorkshopRepository; + workshopTaskRepo: IWorkshopTaskRepository; + + activityHistorySrv: IActivityHistoryService; + auditSrv: IAuditService; + authSrv: IAuthService; + chatSrv: IChatService; + cloudinarySrv: ICloudinaryService; + emailSrv: IEmailService; + invitationSrv: IInvitationService; + notificationSrv: INotificationService; + permissionSrv: IPermissionService; + socketSrv: ISocketService; + teamSrv: ITeamService; + workshopProjectSrv: IWorkshopProjectService; + workshopSrv: IWorkshopService; + workshopTaskSrv: IWorkshopTaskService; + + activityCtrl: ActivityController; + auditCtrl: AuditController; + authCtrl: AuthController; + chatCtrl: ChatController; + inviteCtrl: InviteController; + notificationCtrl: NotificationController; + permissionCtrl: PermissionController; + roleCtrl: RoleController; + teamCtrl: TeamController; + workshopCtrl: WorkshopController; + workshopProjectCtrl: WorkshopProjectController; + workshopTaskCtrl: WorkshopTaskController; +} diff --git a/backend/src/middlewares/errorMiddleware.ts b/backend/src/middlewares/errorMiddleware.ts deleted file mode 100644 index b40dcad..0000000 --- a/backend/src/middlewares/errorMiddleware.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { Error as MongooseError } from 'mongoose'; -import { - AppError, - ValidationError, - AuthenticationError, - NotFoundError, - ConflictError, - InternalServerError, - isOperationalError -} from '../utils/errors'; - -interface ErrorResponse { - success: false; - message: string; - code?: string; - details?: any; - stack?: string; -} - -function handleMongooseValidationError(err: MongooseError.ValidationError): ValidationError { - const errors = Object.values(err.errors).map(error => ({ - field: error.path, - message: error.message, - value: (error as any).value - })); - - if (errors.length === 1) { - const error = errors[0]; - return new ValidationError(error.message, { errors }); - } else { - const fieldMessages = errors.map(e => `${e.field}: ${e.message}`).join(', '); - return new ValidationError(`Validation failed: ${fieldMessages}`, { errors }); - } -} - -function handleMongooseCastError(err: MongooseError.CastError): ValidationError { - return new ValidationError(`Invalid ${err.path}: ${err.value}`); -} - -function handleMongooseDuplicateKeyError(err: any): ConflictError { - const fields = Object.keys(err.keyPattern); - - if (fields.length > 1) { - - return new ConflictError(`This combination of ${fields.join(' and ')} already exists`); - } - - const field = fields[0]; - const value = err.keyValue[field]; - return new ConflictError(`${field} '${value}' already exists`); -} - -function handleJWTError(): AuthenticationError { - return new AuthenticationError('Invalid token. Please log in again'); -} - -function handleJWTExpiredError(): AuthenticationError { - return new AuthenticationError('Your token has expired. Please log in again'); -} - -function sanitizeErrorMessage(message: string): string { - const sensitivePatterns = [ - /mongodb:\/\/[^\/\s]+/gi, - /connection string/gi, - /Bearer\s+[A-Za-z0-9\-_]+/gi, - /jwt\s+[A-Za-z0-9\-_\.]+/gi - ]; - - let sanitized = message; - sensitivePatterns.forEach(pattern => { - sanitized = sanitized.replace(pattern, '[REDACTED]'); - }); - - return sanitized; -} - -export const errorHandler = ( - err: Error, - req: Request, - res: Response, - _next: NextFunction -): void => { - let error: AppError; - - if (err instanceof MongooseError.ValidationError) { - error = handleMongooseValidationError(err); - } - - else if (err instanceof MongooseError.CastError) { - error = handleMongooseCastError(err); - } - - else if (err.name === 'MongoServerError' && (err as any).code === 11000) { - error = handleMongooseDuplicateKeyError(err); - } - - else if (err.name === 'BSONError' || err.message?.includes('24 character hex string')) { - error = new ValidationError('Invalid ID format provided'); - } - - else if (err.name === 'JsonWebTokenError') { - error = handleJWTError(); - } - else if (err.name === 'TokenExpiredError') { - error = handleJWTExpiredError(); - } - - else if (err instanceof AppError) { - error = err; - } - - else { - error = new InternalServerError( - process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred' - ); - } - - if (!isOperationalError(error) || process.env.NODE_ENV === 'development') { - console.error('Error Details:', { - name: err.name, - message: error.message, - statusCode: error.statusCode, - code: error.code, - details: error instanceof ValidationError ? error.details : undefined, - originalError: err instanceof MongooseError.ValidationError ? { - errors: Object.keys(err.errors).map(key => ({ - field: key, - message: err.errors[key].message, - value: (err.errors[key] as any).value, - kind: (err.errors[key] as any).kind - })) - } : undefined, - stack: error.stack, - url: req.url, - method: req.method, - body: req.method === 'POST' || req.method === 'PUT' ? req.body : undefined, - ip: req.ip - }); - } - - const response: ErrorResponse = { - success: false, - message: sanitizeErrorMessage(error.message), - code: error.code - }; - - if (error instanceof ValidationError && error.details) { - response.details = error.details; - - if (error.details.errors && Array.isArray(error.details.errors)) { - response.details.fieldErrors = error.details.errors.reduce((acc: any, err: any) => { - acc[err.field] = err.message; - return acc; - }, {}); - } - } - - if (process.env.NODE_ENV === 'development') { - response.stack = error.stack; - } - - res.status(error.statusCode).json(response); -}; - -export const notFoundHandler = (req: Request, _res: Response, next: NextFunction): void => { - const error = new NotFoundError(`Route ${req.originalUrl}`); - next(error); -}; - -export const asyncHandler = (fn: Function) => { - return (req: Request, res: Response, next: NextFunction) => { - Promise.resolve(fn(req, res, next)).catch(next); - }; -}; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts deleted file mode 100644 index 1da68cb..0000000 --- a/backend/src/models/index.ts +++ /dev/null @@ -1,14 +0,0 @@ - -export { User } from './User'; -export { PendingUser } from './PendingUser'; -export { PasswordReset } from './PasswordReset'; -export { Notification } from './Notification'; - -export { Workshop } from './Workshop'; -export { Membership } from './Membership'; -export { Team } from './Team'; -export { Role } from './Role'; -export { RoleAssignment } from './RoleAssignment'; -export { WorkshopProject } from './WorkshopProject'; -export { WorkshopTask } from './WorkshopTask'; -export { AuditLog } from './AuditLog'; \ No newline at end of file diff --git a/backend/src/controllers/PermissionController.ts b/backend/src/modules/access-control/controllers/PermissionController.ts similarity index 65% rename from backend/src/controllers/PermissionController.ts rename to backend/src/modules/access-control/controllers/PermissionController.ts index 466d44f..c86e7c0 100644 --- a/backend/src/controllers/PermissionController.ts +++ b/backend/src/modules/access-control/controllers/PermissionController.ts @@ -1,13 +1,13 @@ import { Response } from 'express'; -import { PermissionService } from '../services/PermissionService'; -import { AuthRequest } from '../types'; -import { ValidationError } from '../utils/errors'; -import { asyncHandler } from '../middlewares/errorMiddleware'; +import { IPermissionService } from '../interfaces/IPermissionService'; +import { AuthRequest } from '../../../shared/types/index'; +import { ValidationError } from '../../../shared/utils/errors'; +import { asyncHandler } from '../../../shared/middlewares/errorMiddleware'; export class PermissionController { - private permissionService: PermissionService; + private permissionService: IPermissionService; - constructor(permissionService: PermissionService) { + constructor(permissionService: IPermissionService) { this.permissionService = permissionService; } diff --git a/backend/src/controllers/RoleController.ts b/backend/src/modules/access-control/controllers/RoleController.ts similarity index 85% rename from backend/src/controllers/RoleController.ts rename to backend/src/modules/access-control/controllers/RoleController.ts index b73937e..0ba0d5b 100644 --- a/backend/src/controllers/RoleController.ts +++ b/backend/src/modules/access-control/controllers/RoleController.ts @@ -1,36 +1,27 @@ import { Response, NextFunction } from 'express'; -import { RoleRepository } from '../repositories/RoleRepository'; -import { RoleAssignmentRepository } from '../repositories/RoleAssignmentRepository'; -import { WorkshopRepository } from '../repositories/WorkshopRepository'; -import { AuditService } from '../services/AuditService'; -import { SocketService } from '../services/SocketService'; -import { PermissionService } from '../services/PermissionService'; -import { AuthRequest } from '../types'; -import { - CreateRoleDTO, - UpdateRoleDTO, - PermissionScope -} from '../types/workshop'; -import { NotFoundError, AuthorizationError, ValidationError } from '../utils/errors'; +import { IRoleRepository } from '../interfaces/IRoleRepository'; +import { IRoleAssignmentRepository } from '../interfaces/IRoleAssignmentRepository'; +import { IWorkshopRepository } from '../../workshop/interfaces/IWorkshopRepository'; +import { IAuditService } from '../../audit/interfaces/IAuditService'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; +import { IPermissionService } from '../interfaces/IPermissionService'; +import { AuthRequest } from '../../../shared/types/index'; +import { CreateRoleDTO, UpdateRoleDTO, PermissionScope } from '../types/index'; +import { NotFoundError, AuthorizationError, ValidationError } from '../../../shared/utils/errors'; import { Types } from 'mongoose'; export class RoleController { - private roleRepository: RoleRepository; - private roleAssignmentRepository: RoleAssignmentRepository; - private workshopRepository: WorkshopRepository; - private auditService: AuditService; - private socketService: SocketService | null = null; - private permissionService: PermissionService; - - constructor() { - this.roleRepository = new RoleRepository(); - this.roleAssignmentRepository = new RoleAssignmentRepository(); - this.workshopRepository = new WorkshopRepository(); - this.auditService = new AuditService(); - this.permissionService = PermissionService.getInstance(); - } + private socketService: ISocketService | null = null; + + constructor( + private roleRepository: IRoleRepository, + private roleAssignmentRepository: IRoleAssignmentRepository, + private workshopRepository: IWorkshopRepository, + private auditService: IAuditService, + private permissionService: IPermissionService + ) { } - setSocketService(socketService: SocketService): void { + setSocketService(socketService: ISocketService): void { this.socketService = socketService; } @@ -127,7 +118,6 @@ export class RoleController { timestamp: new Date().toISOString() }; - console.log(`🔔 [RoleController] Emitting role:updated event:`, eventData); this.socketService.emitToWorkshop(workshopId, 'role:updated', eventData); @@ -231,7 +221,6 @@ export class RoleController { timestamp: new Date().toISOString() }; - console.log(`🔔 [RoleController] Emitting role:assigned event:`, eventData); this.socketService.emitToWorkshop(workshopId, 'role:assigned', eventData); this.socketService.emitToWorkshop(workshopId, 'role:updated', eventData); @@ -276,7 +265,6 @@ export class RoleController { timestamp: new Date().toISOString() }; - console.log(`🔔 [RoleController] Emitting role:revoked event:`, eventData); this.socketService.emitToWorkshop(workshopId, 'role:revoked', eventData); diff --git a/backend/src/modules/access-control/interfaces/IPermissionService.ts b/backend/src/modules/access-control/interfaces/IPermissionService.ts new file mode 100644 index 0000000..1cbe6fa --- /dev/null +++ b/backend/src/modules/access-control/interfaces/IPermissionService.ts @@ -0,0 +1,26 @@ +import { PermissionContext, PermissionResult } from '../types/index'; + +export interface IPermissionService { + checkPermission( + userId: string, + workshopId: string, + action: string, + resource: string, + context?: PermissionContext + ): Promise; + invalidateUserCache(userId: string, workshopId: string): void; + invalidateWorkshopCache(workshopId: string): void; + invalidateAllCache(): void; + hasAnyPermission( + userId: string, + workshopId: string, + permissions: { action: string; resource: string }[], + context?: PermissionContext + ): Promise; + hasAllPermissions( + userId: string, + workshopId: string, + permissions: { action: string; resource: string }[], + context?: PermissionContext + ): Promise; +} diff --git a/backend/src/modules/access-control/interfaces/IRoleAssignmentRepository.ts b/backend/src/modules/access-control/interfaces/IRoleAssignmentRepository.ts new file mode 100644 index 0000000..5e83b3f --- /dev/null +++ b/backend/src/modules/access-control/interfaces/IRoleAssignmentRepository.ts @@ -0,0 +1,22 @@ +import { IRoleAssignment, PermissionScope } from '../types/index'; +import { CreateRoleAssignmentDTO } from '../repositories/RoleAssignmentRepository'; + +export interface IRoleAssignmentRepository { + create(assignmentData: CreateRoleAssignmentDTO): Promise; + findById(id: string): Promise; + findByUser(workshopId: string, userId: string): Promise; + findByUserAndScope(workshopId: string, userId: string, scope: PermissionScope, scopeId?: string): Promise; + findByRole(roleId: string): Promise; + findByWorkshop(workshopId: string): Promise; + findByScope(workshopId: string, scope: PermissionScope, scopeId?: string): Promise; + delete(id: string): Promise; + deleteByUser(workshopId: string, userId: string): Promise; + deleteByUserAndRole(workshopId: string, userId: string, roleId: string): Promise; + deleteByRole(roleId: string): Promise; + deleteByWorkshop(workshopId: string): Promise; + deleteByScope(workshopId: string, scope: PermissionScope, scopeId?: string): Promise; + exists(workshopId: string, roleId: string, userId: string, scope: PermissionScope, scopeId?: string): Promise; + countByUser(workshopId: string, userId: string): Promise; + countByRole(roleId: string): Promise; + countByWorkshop(workshopId: string): Promise; +} diff --git a/backend/src/modules/access-control/interfaces/IRoleRepository.ts b/backend/src/modules/access-control/interfaces/IRoleRepository.ts new file mode 100644 index 0000000..1624c1c --- /dev/null +++ b/backend/src/modules/access-control/interfaces/IRoleRepository.ts @@ -0,0 +1,17 @@ +import { IRole, CreateRoleDTO, UpdateRoleDTO, PermissionScope } from '../types/index'; + +export interface IRoleRepository { + create(workshopId: string, roleData: CreateRoleDTO): Promise; + findById(id: string): Promise; + findByWorkshop(workshopId: string): Promise; + findByScope(workshopId: string, scope: PermissionScope, scopeId?: string): Promise; + findByName(workshopId: string, name: string): Promise; + update(id: string, updates: UpdateRoleDTO): Promise; + delete(id: string): Promise; + deleteByWorkshop(workshopId: string): Promise; + deleteByScope(workshopId: string, scope: PermissionScope, scopeId?: string): Promise; + exists(workshopId: string, name: string): Promise; + countByWorkshop(workshopId: string): Promise; + countByScope(workshopId: string, scope: PermissionScope, scopeId?: string): Promise; + searchByName(workshopId: string, searchTerm: string): Promise; +} diff --git a/backend/src/models/Role.ts b/backend/src/modules/access-control/models/Role.ts similarity index 98% rename from backend/src/models/Role.ts rename to backend/src/modules/access-control/models/Role.ts index 7a53be9..fb7eb7c 100644 --- a/backend/src/models/Role.ts +++ b/backend/src/modules/access-control/models/Role.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { IRole, IPermission, PermissionType, PermissionScope } from '../types'; +import { IRole, IPermission, PermissionType, PermissionScope } from '../types/index'; const permissionSchema = new Schema( { diff --git a/backend/src/models/RoleAssignment.ts b/backend/src/modules/access-control/models/RoleAssignment.ts similarity index 95% rename from backend/src/models/RoleAssignment.ts rename to backend/src/modules/access-control/models/RoleAssignment.ts index a009dd6..6ff0bd5 100644 --- a/backend/src/models/RoleAssignment.ts +++ b/backend/src/modules/access-control/models/RoleAssignment.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { IRoleAssignment, PermissionScope } from '../types'; +import { IRoleAssignment, PermissionScope } from '../types/index'; const roleAssignmentSchema = new Schema( { diff --git a/backend/src/repositories/RoleAssignmentRepository.ts b/backend/src/modules/access-control/repositories/RoleAssignmentRepository.ts similarity index 95% rename from backend/src/repositories/RoleAssignmentRepository.ts rename to backend/src/modules/access-control/repositories/RoleAssignmentRepository.ts index dc95b61..beff427 100644 --- a/backend/src/repositories/RoleAssignmentRepository.ts +++ b/backend/src/modules/access-control/repositories/RoleAssignmentRepository.ts @@ -1,7 +1,8 @@ import { RoleAssignment } from '../models/RoleAssignment'; -import { IRoleAssignment, PermissionScope } from '../types'; +import { IRoleAssignment, PermissionScope } from '../types/index'; import { Types } from 'mongoose'; -import { NotFoundError } from '../utils/errors'; +import { NotFoundError } from '../../../shared/utils/errors'; +import { IRoleAssignmentRepository } from '../interfaces/IRoleAssignmentRepository'; export interface CreateRoleAssignmentDTO { workshopId: string; @@ -12,7 +13,7 @@ export interface CreateRoleAssignmentDTO { assignedBy: string; } -export class RoleAssignmentRepository { +export class RoleAssignmentRepository implements IRoleAssignmentRepository { private readonly populateRole = { path: 'role', select: 'name description permissions scope' }; private readonly populateUser = { path: 'user', select: 'name email profilePhoto' }; private readonly populateAssignedBy = { path: 'assignedBy', select: 'name email' }; diff --git a/backend/src/repositories/RoleRepository.ts b/backend/src/modules/access-control/repositories/RoleRepository.ts similarity index 94% rename from backend/src/repositories/RoleRepository.ts rename to backend/src/modules/access-control/repositories/RoleRepository.ts index 83c4ddc..3eccb9b 100644 --- a/backend/src/repositories/RoleRepository.ts +++ b/backend/src/modules/access-control/repositories/RoleRepository.ts @@ -1,9 +1,10 @@ import { Role } from '../models/Role'; -import { IRole, CreateRoleDTO, UpdateRoleDTO, PermissionScope } from '../types'; +import { IRole, CreateRoleDTO, UpdateRoleDTO, PermissionScope } from '../types/index'; import { Types } from 'mongoose'; -import { NotFoundError } from '../utils/errors'; +import { NotFoundError } from '../../../shared/utils/errors'; +import { IRoleRepository } from '../interfaces/IRoleRepository'; -export class RoleRepository { +export class RoleRepository implements IRoleRepository { private readonly populateWorkshop = { path: 'workshop', select: 'name description' }; async create(workshopId: string, roleData: CreateRoleDTO): Promise { diff --git a/backend/src/modules/access-control/routes/permissionRoutes.ts b/backend/src/modules/access-control/routes/permissionRoutes.ts new file mode 100644 index 0000000..a946c51 --- /dev/null +++ b/backend/src/modules/access-control/routes/permissionRoutes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { authMiddleware, requireWorkshopMembership } from '@middlewares'; +import { PERMISSION_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createPermissionRoutes = (container: Container) => { + const router = Router({ mergeParams: true }); + const permissionController = container.permissionCtrl; + + router.use(authMiddleware); + router.use(requireWorkshopMembership); + + router.post(PERMISSION_ROUTES.CHECK, permissionController.checkPermission); + + return router; +}; + +export default createPermissionRoutes; \ No newline at end of file diff --git a/backend/src/modules/access-control/routes/roleRoutes.ts b/backend/src/modules/access-control/routes/roleRoutes.ts new file mode 100644 index 0000000..8e37361 --- /dev/null +++ b/backend/src/modules/access-control/routes/roleRoutes.ts @@ -0,0 +1,24 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { ROLE_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createRoleRoutes = (container: Container) => { + const router = Router({ mergeParams: true }); + const roleController = container.roleCtrl; + + router.use(authMiddleware); + + router.post(ROLE_ROUTES.BASE, roleController.createRole); + router.get(ROLE_ROUTES.BASE, roleController.getRoles); + router.get(ROLE_ROUTES.BY_ID, roleController.getRole); + router.put(ROLE_ROUTES.BY_ID, roleController.updateRole); + router.delete(ROLE_ROUTES.BY_ID, roleController.deleteRole); + router.post(ROLE_ROUTES.ASSIGN, roleController.assignRole); + router.delete(ROLE_ROUTES.REVOKE, roleController.revokeRole); + router.get(ROLE_ROUTES.USER_ROLES, roleController.getUserRoles); + + return router; +}; + +export default createRoleRoutes; \ No newline at end of file diff --git a/backend/src/services/PermissionService.ts b/backend/src/modules/access-control/services/PermissionService.ts similarity index 86% rename from backend/src/services/PermissionService.ts rename to backend/src/modules/access-control/services/PermissionService.ts index c452f85..156c306 100644 --- a/backend/src/services/PermissionService.ts +++ b/backend/src/modules/access-control/services/PermissionService.ts @@ -1,17 +1,12 @@ -import { - PermissionScope, - PermissionType, - IPermission, - PermissionContext, - PermissionResult, - MembershipState, - WorkshopVisibility -} from '../types/workshop'; -import { RoleAssignmentRepository } from '../repositories/RoleAssignmentRepository'; -import { WorkshopRepository } from '../repositories/WorkshopRepository'; -import { TeamRepository } from '../repositories/TeamRepository'; -import { MembershipRepository } from '../repositories/MembershipRepository'; -import { WorkshopProjectRepository } from '../repositories/WorkshopProjectRepository'; +import { PermissionScope, PermissionType, IPermission, PermissionContext, PermissionResult } from '../types/index'; +import { MembershipState } from '../../team/types/index'; +import { WorkshopVisibility } from '../../workshop/types/index'; +import { IRoleAssignmentRepository } from '../interfaces/IRoleAssignmentRepository'; +import { IWorkshopRepository } from '../../workshop/interfaces/IWorkshopRepository'; +import { ITeamRepository } from '../../team/interfaces/ITeamRepository'; +import { IMembershipRepository } from '../../team/interfaces/IMembershipRepository'; +import { IWorkshopProjectRepository } from '../../project/interfaces/IWorkshopProjectRepository'; +import { IPermissionService } from '../interfaces/IPermissionService'; import { Types } from 'mongoose'; interface CacheEntry { @@ -27,31 +22,17 @@ function getIdString(ref: any): string { return ref.toString(); } -export class PermissionService { - private roleAssignmentRepository: RoleAssignmentRepository; - private workshopRepository: WorkshopRepository; - private teamRepository: TeamRepository; - private membershipRepository: MembershipRepository; - private projectRepository: WorkshopProjectRepository; - +export class PermissionService implements IPermissionService { private cache: Map; private readonly CACHE_TTL_MS = 60000; - private static instance: PermissionService; - - public static getInstance(): PermissionService { - if (!PermissionService.instance) { - PermissionService.instance = new PermissionService(); - } - return PermissionService.instance; - } - - private constructor() { - this.roleAssignmentRepository = new RoleAssignmentRepository(); - this.workshopRepository = new WorkshopRepository(); - this.teamRepository = new TeamRepository(); - this.membershipRepository = new MembershipRepository(); - this.projectRepository = new WorkshopProjectRepository(); + constructor( + private roleAssignmentRepository: IRoleAssignmentRepository, + private workshopRepository: IWorkshopRepository, + private teamRepository: ITeamRepository, + private membershipRepository: IMembershipRepository, + private projectRepository: IWorkshopProjectRepository + ) { this.cache = new Map(); } @@ -76,7 +57,6 @@ export class PermissionService { resource: string, context?: PermissionContext ): Promise { - if (!Types.ObjectId.isValid(workshopId) || (userId && !Types.ObjectId.isValid(userId))) { return { granted: false, @@ -102,9 +82,7 @@ export class PermissionService { } if (context?.projectId) { - if (!Types.ObjectId.isValid(context.projectId)) { - const result: PermissionResult = { granted: false, reason: 'Invalid project ID format' @@ -150,7 +128,6 @@ export class PermissionService { const workshop = await this.workshopRepository.findById(workshopId); if (workshop) { - if (workshop.visibility === WorkshopVisibility.PUBLIC && (action === 'view' || action === 'read')) { const result: PermissionResult = { granted: true, @@ -163,7 +140,6 @@ export class PermissionService { const membership = await this.membershipRepository.findByWorkshopAndUser(workshopId, userId); if (membership) { - if (membership.state === MembershipState.ACTIVE && (action === 'view' || action === 'read')) { const result: PermissionResult = { granted: true, @@ -209,7 +185,6 @@ export class PermissionService { if (context?.projectId) { scopeIds = [context.projectId]; } else { - continue; } } else if (scope === PermissionScope.TEAM) { @@ -294,7 +269,6 @@ export class PermissionService { ); for (const p of matchingPermissions) { - if (p.type === PermissionType.DENY) return p; if (p.type === PermissionType.GRANT) bestPermission = p; } diff --git a/backend/src/modules/access-control/types/index.ts b/backend/src/modules/access-control/types/index.ts new file mode 100644 index 0000000..eb350f3 --- /dev/null +++ b/backend/src/modules/access-control/types/index.ts @@ -0,0 +1,74 @@ + +import { Document, Types } from 'mongoose'; + +export enum PermissionScope { + WORKSHOP = 'workshop', + TEAM = 'team', + PROJECT = 'project', + TASK = 'task', + INDIVIDUAL = 'individual' +} + +export enum PermissionType { + READ = 'read', + WRITE = 'write', + DELETE = 'delete', + ADMIN = 'admin', + MANAGE = 'manage', + GRANT = 'grant', + DENY = 'deny' +} + +export interface IPermission { + resource: string; + action: string; + type?: string; + scope?: PermissionScope; +} + +export interface IRole extends Document { + name: string; + description?: string; + workshop: Types.ObjectId; + permissions: IPermission[]; + isDefault: boolean; + scope: PermissionScope; + scopeId?: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +export interface IRoleAssignment extends Document { + user: Types.ObjectId; + role: Types.ObjectId; + workshop: Types.ObjectId; + scope: PermissionScope; + scopeId?: Types.ObjectId; + assignedBy: Types.ObjectId; + assignedAt: Date; +} + +export interface CreateRoleDTO { + name: string; + description?: string; + permissions: IPermission[]; + scope?: PermissionScope; + scopeId?: string; +} + +export interface UpdateRoleDTO { + name?: string; + description?: string; + permissions?: IPermission[]; +} + +export interface PermissionContext { + projectId?: string; + teamId?: string; +} + +export interface PermissionResult { + granted: boolean; + reason?: string; + source?: PermissionScope; +} diff --git a/backend/src/controllers/ActivityController.ts b/backend/src/modules/audit/controllers/ActivityController.ts similarity index 93% rename from backend/src/controllers/ActivityController.ts rename to backend/src/modules/audit/controllers/ActivityController.ts index d133f7e..fb8a52b 100644 --- a/backend/src/controllers/ActivityController.ts +++ b/backend/src/modules/audit/controllers/ActivityController.ts @@ -1,14 +1,10 @@ import { Response, NextFunction } from 'express'; -import { ActivityHistoryService, ActivityFilters } from '../services/ActivityHistoryService'; -import { AuthRequest } from '../types'; -import { ActivityAction, ActivityEntityType } from '../models/ActivityHistory'; +import { IActivityHistoryService } from '../interfaces/IActivityHistoryService'; +import { AuthRequest } from '../../../shared/types/index'; +import { ActivityAction, ActivityEntityType, ActivityFilters } from '../types/index'; export class ActivityController { - private activityService: ActivityHistoryService; - - constructor() { - this.activityService = new ActivityHistoryService(); - } + constructor(private activityService: IActivityHistoryService) { } getWorkshopActivity = async (req: AuthRequest, res: Response, next: NextFunction): Promise => { try { diff --git a/backend/src/controllers/AuditController.ts b/backend/src/modules/audit/controllers/AuditController.ts similarity index 69% rename from backend/src/controllers/AuditController.ts rename to backend/src/modules/audit/controllers/AuditController.ts index c788edf..2e06903 100644 --- a/backend/src/controllers/AuditController.ts +++ b/backend/src/modules/audit/controllers/AuditController.ts @@ -1,17 +1,22 @@ import { Response } from 'express'; -import { AuditService } from '../services/AuditService'; -import { WorkshopRepository } from '../repositories/WorkshopRepository'; -import { AuthRequest, AuditAction } from '../types'; -import { AuthorizationError } from '../utils/errors'; -import { asyncHandler } from '../middlewares/errorMiddleware'; +import { IAuditService } from '../interfaces/IAuditService'; +import { IWorkshopRepository } from '../../workshop/interfaces/IWorkshopRepository'; +import { AuthRequest } from '../../../shared/types/index'; +import { AuditAction } from '../types/index'; +import { AuthorizationError } from '../../../shared/utils/errors'; +import { asyncHandler } from '../../../shared/middlewares/errorMiddleware'; export class AuditController { - private auditService: AuditService; - private workshopRepository: WorkshopRepository; + constructor( + private auditService: IAuditService, + private workshopRepository: IWorkshopRepository + ) { } - constructor() { - this.auditService = new AuditService(); - this.workshopRepository = new WorkshopRepository(); + private async checkAccess(workshopId: string, userId: string): Promise { + const canView = await this.workshopRepository.isOwnerOrManager(workshopId, userId); + if (!canView) { + throw new AuthorizationError('Only workshop owner or managers can view audit logs'); + } } getAuditLogs = asyncHandler(async (req: AuthRequest, res: Response): Promise => { @@ -19,10 +24,7 @@ export class AuditController { const { workshopId } = req.params; const { action, actor, target, targetType, startDate, endDate, page, limit } = req.query; - const canView = await this.workshopRepository.isOwnerOrManager(workshopId, userId); - if (!canView) { - throw new AuthorizationError('Only workshop owner or managers can view audit logs'); - } + await this.checkAccess(workshopId, userId); const filters = { action: action as AuditAction | undefined, @@ -46,7 +48,7 @@ export class AuditController { total: result.total, page: pagination.page, limit: pagination.limit, - totalPages: Math.ceil(result.total / pagination.limit) + totalPages: result.totalPages }); }); @@ -55,10 +57,7 @@ export class AuditController { const { workshopId } = req.params; const { limit } = req.query; - const canView = await this.workshopRepository.isOwnerOrManager(workshopId, userId); - if (!canView) { - throw new AuthorizationError('Only workshop owner or managers can view audit logs'); - } + await this.checkAccess(workshopId, userId); const logs = await this.auditService.getRecentLogs( workshopId, @@ -76,10 +75,7 @@ export class AuditController { const { workshopId, targetUserId } = req.params; const { page, limit } = req.query; - const canView = await this.workshopRepository.isOwnerOrManager(workshopId, userId); - if (!canView) { - throw new AuthorizationError('Only workshop owner or managers can view audit logs'); - } + await this.checkAccess(workshopId, userId); const pagination = { page: page ? parseInt(page as string) : 1, @@ -93,7 +89,8 @@ export class AuditController { data: result.logs, total: result.total, page: pagination.page, - limit: pagination.limit + limit: pagination.limit, + totalPages: result.totalPages }); }); @@ -102,10 +99,7 @@ export class AuditController { const { workshopId, targetId } = req.params; const { targetType, page, limit } = req.query; - const canView = await this.workshopRepository.isOwnerOrManager(workshopId, userId); - if (!canView) { - throw new AuthorizationError('Only workshop owner or managers can view audit logs'); - } + await this.checkAccess(workshopId, userId); const pagination = { page: page ? parseInt(page as string) : 1, @@ -124,7 +118,8 @@ export class AuditController { data: result.logs, total: result.total, page: pagination.page, - limit: pagination.limit + limit: pagination.limit, + totalPages: result.totalPages }); }); @@ -132,10 +127,7 @@ export class AuditController { const userId = req.user!.id; const { workshopId } = req.params; - const canView = await this.workshopRepository.isOwnerOrManager(workshopId, userId); - if (!canView) { - throw new AuthorizationError('Only workshop owner or managers can view audit logs'); - } + await this.checkAccess(workshopId, userId); const stats = await this.auditService.getAuditStats(workshopId); @@ -150,10 +142,7 @@ export class AuditController { const { workshopId, targetUserId } = req.params; const { days } = req.query; - const canView = await this.workshopRepository.isOwnerOrManager(workshopId, userId); - if (!canView) { - throw new AuthorizationError('Only workshop owner or managers can view audit logs'); - } + await this.checkAccess(workshopId, userId); const summary = await this.auditService.getUserActivitySummary( workshopId, @@ -166,4 +155,4 @@ export class AuditController { data: summary }); }); -} \ No newline at end of file +} diff --git a/backend/src/modules/audit/interfaces/IActivityHistoryRepository.ts b/backend/src/modules/audit/interfaces/IActivityHistoryRepository.ts new file mode 100644 index 0000000..40ae857 --- /dev/null +++ b/backend/src/modules/audit/interfaces/IActivityHistoryRepository.ts @@ -0,0 +1,11 @@ +import { IActivityHistory } from '../types/index'; +import { FilterQuery } from 'mongoose'; + +export interface IActivityHistoryRepository { + create(data: any): Promise; + find(query: FilterQuery, options?: { skip?: number; limit?: number; sort?: any; populate?: any }): Promise; + findById(id: string): Promise; + countDocuments(query: FilterQuery): Promise; + deleteMany(query: FilterQuery): Promise<{ deletedCount: number }>; + aggregate(pipeline: any[]): Promise; +} diff --git a/backend/src/modules/audit/interfaces/IActivityHistoryService.ts b/backend/src/modules/audit/interfaces/IActivityHistoryService.ts new file mode 100644 index 0000000..5922efa --- /dev/null +++ b/backend/src/modules/audit/interfaces/IActivityHistoryService.ts @@ -0,0 +1,28 @@ +import { IActivityHistory, ActivityEntityType } from '../types/index'; +import { LogActivityData, ActivityFilters } from '../types/index'; + + +export interface IActivityHistoryService { + logActivity(data: LogActivityData): Promise; + getWorkshopActivity( + workshopId: string, + filters?: ActivityFilters, + page?: number, + limit?: number + ): Promise<{ activities: IActivityHistory[]; total: number; hasMore: boolean }>; + getUserActivity( + userId: string, + filters?: ActivityFilters, + page?: number, + limit?: number + ): Promise<{ activities: IActivityHistory[]; total: number; hasMore: boolean }>; + getEntityActivity( + entityType: ActivityEntityType, + entityId: string, + page?: number, + limit?: number + ): Promise<{ activities: IActivityHistory[]; total: number; hasMore: boolean }>; + getRecentActivities(userId: string, limit?: number): Promise; + deleteOldActivities(daysOld?: number): Promise; + getWorkshopActivityStats(workshopId: string, days?: number): Promise; +} diff --git a/backend/src/modules/audit/interfaces/IAuditLogRepository.ts b/backend/src/modules/audit/interfaces/IAuditLogRepository.ts new file mode 100644 index 0000000..d941f70 --- /dev/null +++ b/backend/src/modules/audit/interfaces/IAuditLogRepository.ts @@ -0,0 +1,22 @@ +import { IAuditLog, AuditAction, AuditLogFilters } from '../types/index'; +import { Pagination } from '../../../shared/types/index'; + +export interface IAuditLogRepository { + create(data: { + workshopId: string; + action: AuditAction; + actorId: string; + targetId?: string; + targetType?: string; + details?: Record; + }): Promise; + findById(id: string): Promise; + findByWorkshop(workshopId: string, filters?: AuditLogFilters, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number }>; + findByActor(workshopId: string, actorId: string, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number }>; + findByTarget(workshopId: string, targetId: string, targetType?: string, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number }>; + findByAction(workshopId: string, action: AuditAction, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number }>; + findRecent(workshopId: string, limit?: number): Promise; + findByDateRange(workshopId: string, startDate: Date, endDate: Date, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number }>; + countByAction(workshopId: string): Promise>; + getUserActivitySummary(workshopId: string, userId: string, days?: number): Promise<{ action: string; count: number }[]>; +} diff --git a/backend/src/modules/audit/interfaces/IAuditService.ts b/backend/src/modules/audit/interfaces/IAuditService.ts new file mode 100644 index 0000000..0cece15 --- /dev/null +++ b/backend/src/modules/audit/interfaces/IAuditService.ts @@ -0,0 +1,39 @@ +import { IAuditLog, AuditAction, AuditLogFilters } from '../types/index'; +import { Pagination } from '../../../shared/types/index'; + +export interface IAuditService { + log(entry: { + workshopId: string; + action: AuditAction; + actorId: string; + targetId?: string; + targetType?: string; + details?: Record; + }): Promise; + logWorkshopCreated(workshopId: string, actorId: string, details?: Record): Promise; + logWorkshopUpdated(workshopId: string, actorId: string, changes: Record): Promise; + logManagerAssigned(workshopId: string, actorId: string, managerId: string): Promise; + logManagerRemoved(workshopId: string, actorId: string, managerId: string): Promise; + logMemberInvited(workshopId: string, actorId: string, invitedUserId: string): Promise; + logMemberJoined(workshopId: string, userId: string, source: string): Promise; + logMemberLeft(workshopId: string, userId: string): Promise; + logMemberRemoved(workshopId: string, actorId: string, removedUserId: string, reason?: string): Promise; + logJoinRequestApproved(workshopId: string, actorId: string, userId: string): Promise; + logJoinRequestRejected(workshopId: string, actorId: string, userId: string, reason?: string): Promise; + logTeamCreated(workshopId: string, actorId: string, teamId: string, teamName: string): Promise; + logTeamMemberAdded(workshopId: string, actorId: string, teamId: string, userId: string): Promise; + logTeamMemberRemoved(workshopId: string, actorId: string, teamId: string, userId: string): Promise; + logProjectCreated(workshopId: string, actorId: string, projectId: string, projectName: string): Promise; + logRoleCreated(workshopId: string, actorId: string, roleId: string, roleName: string): Promise; + logRoleAssigned(workshopId: string, actorId: string, roleId: string, userId: string, roleName: string): Promise; + logRoleRevoked(workshopId: string, actorId: string, roleId: string, userId: string, roleName: string): Promise; + logTaskCreated(workshopId: string, actorId: string, taskId: string, taskTitle: string, projectId: string): Promise; + logTaskStatusChanged(workshopId: string, actorId: string, taskId: string, oldStatus: string, newStatus: string): Promise; + logUnauthorizedAccess(workshopId: string, actorId: string, action: string, resource: string): Promise; + getWorkshopAuditLogs(workshopId: string, filters?: AuditLogFilters, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number; totalPages: number }>; + getUserActivityLogs(workshopId: string, userId: string, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number; totalPages: number }>; + getTargetAuditLogs(workshopId: string, targetId: string, targetType?: string, pagination?: Pagination): Promise<{ logs: IAuditLog[]; total: number; totalPages: number }>; + getRecentLogs(workshopId: string, limit?: number): Promise; + getUserActivitySummary(workshopId: string, userId: string, days?: number): Promise<{ action: string; count: number }[]>; + getAuditStats(workshopId: string): Promise>; +} diff --git a/backend/src/models/ActivityHistory.ts b/backend/src/modules/audit/models/ActivityHistory.ts similarity index 61% rename from backend/src/models/ActivityHistory.ts rename to backend/src/modules/audit/models/ActivityHistory.ts index 495750a..7f7f067 100644 --- a/backend/src/models/ActivityHistory.ts +++ b/backend/src/modules/audit/models/ActivityHistory.ts @@ -1,46 +1,5 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export enum ActivityAction { - CREATED = 'created', - UPDATED = 'updated', - DELETED = 'deleted', - ASSIGNED = 'assigned', - UNASSIGNED = 'unassigned', - COMPLETED = 'completed', - REOPENED = 'reopened', - COMMENTED = 'commented', - UPLOADED = 'uploaded', - JOINED = 'joined', - LEFT = 'left', - INVITED = 'invited', - APPROVED = 'approved', - REJECTED = 'rejected' -} - -export enum ActivityEntityType { - WORKSHOP = 'workshop', - PROJECT = 'project', - TASK = 'task', - TEAM = 'team', - MESSAGE = 'message', - MEMBER = 'member', - ROLE = 'role', - FILE = 'file' -} - -export interface IActivityHistory extends Document { - workshop: mongoose.Types.ObjectId; - user: mongoose.Types.ObjectId; - action: ActivityAction; - entityType: ActivityEntityType; - entityId: mongoose.Types.ObjectId; - entityName: string; - description: string; - metadata?: any; - ipAddress?: string; - userAgent?: string; - createdAt: Date; -} +import mongoose, { Schema } from 'mongoose'; +import { ActivityAction, ActivityEntityType, IActivityHistory } from '../types/index'; const activityHistorySchema = new Schema( { diff --git a/backend/src/models/AuditLog.ts b/backend/src/modules/audit/models/AuditLog.ts similarity index 95% rename from backend/src/models/AuditLog.ts rename to backend/src/modules/audit/models/AuditLog.ts index 98a9cff..51d3fcd 100644 --- a/backend/src/models/AuditLog.ts +++ b/backend/src/modules/audit/models/AuditLog.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { IAuditLog, AuditAction } from '../types'; +import { IAuditLog, AuditAction } from '../types/index'; const auditLogSchema = new Schema( { diff --git a/backend/src/modules/audit/repositories/ActivityHistoryRepository.ts b/backend/src/modules/audit/repositories/ActivityHistoryRepository.ts new file mode 100644 index 0000000..f0726a0 --- /dev/null +++ b/backend/src/modules/audit/repositories/ActivityHistoryRepository.ts @@ -0,0 +1,36 @@ +import { ActivityHistory } from '../models/ActivityHistory'; +import { IActivityHistory } from '../types/index'; +import { FilterQuery } from 'mongoose'; +import { IActivityHistoryRepository } from '../interfaces/IActivityHistoryRepository'; + +export class ActivityHistoryRepository implements IActivityHistoryRepository { + async create(data: any): Promise { + return ActivityHistory.create(data); + } + + async find(query: FilterQuery, options: { skip?: number; limit?: number; sort?: any; populate?: any } = {}): Promise { + let q: any = ActivityHistory.find(query); + if (options.populate) q = q.populate(options.populate as any); + if (options.sort) q = q.sort(options.sort); + if (options.skip) q = q.skip(options.skip); + if (options.limit) q = q.limit(options.limit); + return q; + } + + async findById(id: string): Promise { + return ActivityHistory.findById(id); + } + + async countDocuments(query: FilterQuery): Promise { + return ActivityHistory.countDocuments(query); + } + + async deleteMany(query: FilterQuery): Promise<{ deletedCount: number }> { + const result = await ActivityHistory.deleteMany(query); + return { deletedCount: result.deletedCount || 0 }; + } + + async aggregate(pipeline: any[]): Promise { + return ActivityHistory.aggregate(pipeline); + } +} diff --git a/backend/src/repositories/AuditLogRepository.ts b/backend/src/modules/audit/repositories/AuditLogRepository.ts similarity index 95% rename from backend/src/repositories/AuditLogRepository.ts rename to backend/src/modules/audit/repositories/AuditLogRepository.ts index ebaff3d..d8fb898 100644 --- a/backend/src/repositories/AuditLogRepository.ts +++ b/backend/src/modules/audit/repositories/AuditLogRepository.ts @@ -1,8 +1,10 @@ import { Types } from 'mongoose'; import { AuditLog } from '../models/AuditLog'; -import { IAuditLog, AuditAction, AuditLogFilters, Pagination } from '../types'; +import { IAuditLog, AuditAction, AuditLogFilters } from '../types/index'; +import { Pagination } from '../../../shared/types/index'; +import { IAuditLogRepository } from '../interfaces/IAuditLogRepository'; -export class AuditLogRepository { +export class AuditLogRepository implements IAuditLogRepository { private readonly populateActor = { path: 'actor', select: 'name email profilePhoto' }; private readonly populateWorkshop = { path: 'workshop', select: 'name' }; diff --git a/backend/src/modules/audit/routes/activityRoutes.ts b/backend/src/modules/audit/routes/activityRoutes.ts new file mode 100644 index 0000000..2713b6b --- /dev/null +++ b/backend/src/modules/audit/routes/activityRoutes.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { authMiddleware, requireWorkshopMembership } from '@middlewares'; +import { ACTIVITY_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createActivityRoutes = (container: Container) => { + const router = Router(); + const activityController = container.activityCtrl; + + router.get(ACTIVITY_ROUTES.WORKSHOP_ACTIVITY, authMiddleware, requireWorkshopMembership, activityController.getWorkshopActivity); + router.get(ACTIVITY_ROUTES.WORKSHOP_STATS, authMiddleware, requireWorkshopMembership, activityController.getWorkshopActivityStats); + router.get(ACTIVITY_ROUTES.USER_ACTIVITY, authMiddleware, activityController.getUserActivity); + router.get(ACTIVITY_ROUTES.ENTITY_ACTIVITY, authMiddleware, activityController.getEntityActivity); + router.get(ACTIVITY_ROUTES.RECENT, authMiddleware, activityController.getRecentActivities); + + return router; +}; + +export default createActivityRoutes; \ No newline at end of file diff --git a/backend/src/modules/audit/routes/auditRoutes.ts b/backend/src/modules/audit/routes/auditRoutes.ts new file mode 100644 index 0000000..cf55d8a --- /dev/null +++ b/backend/src/modules/audit/routes/auditRoutes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { AUDIT_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createAuditRoutes = (container: Container) => { + const router = Router({ mergeParams: true }); + const auditController = container.auditCtrl; + + router.use(authMiddleware); + + router.get(AUDIT_ROUTES.BASE, auditController.getAuditLogs); + router.get(AUDIT_ROUTES.RECENT, auditController.getRecentLogs); + router.get(AUDIT_ROUTES.STATS, auditController.getAuditStats); + router.get(AUDIT_ROUTES.USER_ACTIVITY, auditController.getUserActivityLogs); + router.get(AUDIT_ROUTES.USER_SUMMARY, auditController.getUserActivitySummary); + router.get(AUDIT_ROUTES.TARGET, auditController.getTargetLogs); + + return router; +}; + +export default createAuditRoutes; \ No newline at end of file diff --git a/backend/src/services/ActivityHistoryService.ts b/backend/src/modules/audit/services/ActivityHistoryService.ts similarity index 75% rename from backend/src/services/ActivityHistoryService.ts rename to backend/src/modules/audit/services/ActivityHistoryService.ts index 8cb85e2..6b6f378 100644 --- a/backend/src/services/ActivityHistoryService.ts +++ b/backend/src/modules/audit/services/ActivityHistoryService.ts @@ -1,31 +1,14 @@ -import { ActivityHistory, IActivityHistory, ActivityAction, ActivityEntityType } from '../models/ActivityHistory'; +import { IActivityHistory, ActivityEntityType } from '../types/index'; import { Types } from 'mongoose'; +import { IActivityHistoryRepository } from '../interfaces/IActivityHistoryRepository'; +import { IActivityHistoryService } from '../interfaces/IActivityHistoryService'; +import { LogActivityData, ActivityFilters } from '../types/index'; -export interface LogActivityData { - workshop: string; - user: string; - action: ActivityAction; - entityType: ActivityEntityType; - entityId: string; - entityName: string; - description: string; - metadata?: any; - ipAddress?: string; - userAgent?: string; -} - -export interface ActivityFilters { - action?: ActivityAction; - entityType?: ActivityEntityType; - entityId?: string; - startDate?: Date; - endDate?: Date; -} - -export class ActivityHistoryService { +export class ActivityHistoryService implements IActivityHistoryService { + constructor(private activityRepository: IActivityHistoryRepository) { } async logActivity(data: LogActivityData): Promise { - const activity = await ActivityHistory.create({ + const activity = await this.activityRepository.create({ workshop: new Types.ObjectId(data.workshop), user: new Types.ObjectId(data.user), action: data.action, @@ -76,12 +59,13 @@ export class ActivityHistoryService { } const [activities, total] = await Promise.all([ - ActivityHistory.find(query) - .populate('user') - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit), - ActivityHistory.countDocuments(query) + this.activityRepository.find(query, { + populate: 'user', + sort: { createdAt: -1 }, + skip, + limit + }), + this.activityRepository.countDocuments(query) ]); return { @@ -122,12 +106,13 @@ export class ActivityHistoryService { } const [activities, total] = await Promise.all([ - ActivityHistory.find(query) - .populate(['user', 'workshop']) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit), - ActivityHistory.countDocuments(query) + this.activityRepository.find(query, { + populate: ['user', 'workshop'], + sort: { createdAt: -1 }, + skip, + limit + }), + this.activityRepository.countDocuments(query) ]); return { @@ -151,12 +136,13 @@ export class ActivityHistoryService { }; const [activities, total] = await Promise.all([ - ActivityHistory.find(query) - .populate('user') - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit), - ActivityHistory.countDocuments(query) + this.activityRepository.find(query, { + populate: 'user', + sort: { createdAt: -1 }, + skip, + limit + }), + this.activityRepository.countDocuments(query) ]); return { @@ -170,19 +156,20 @@ export class ActivityHistoryService { userId: string, limit: number = 20 ): Promise { - return ActivityHistory.find({ + return this.activityRepository.find({ user: new Types.ObjectId(userId) - }) - .populate(['workshop']) - .sort({ createdAt: -1 }) - .limit(limit); + }, { + populate: ['workshop'], + sort: { createdAt: -1 }, + limit + }); } async deleteOldActivities(daysOld: number = 90): Promise { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysOld); - const result = await ActivityHistory.deleteMany({ + const result = await this.activityRepository.deleteMany({ createdAt: { $lt: cutoffDate } }); @@ -193,7 +180,7 @@ export class ActivityHistoryService { const startDate = new Date(); startDate.setDate(startDate.getDate() - days); - const stats = await ActivityHistory.aggregate([ + const stats = await this.activityRepository.aggregate([ { $match: { workshop: new Types.ObjectId(workshopId), diff --git a/backend/src/services/AuditService.ts b/backend/src/modules/audit/services/AuditService.ts similarity index 85% rename from backend/src/services/AuditService.ts rename to backend/src/modules/audit/services/AuditService.ts index 507e3f4..7cd96aa 100644 --- a/backend/src/services/AuditService.ts +++ b/backend/src/modules/audit/services/AuditService.ts @@ -1,17 +1,10 @@ -import { - IAuditLog, - AuditAction, - AuditLogFilters, - Pagination -} from '../types'; -import { AuditLogRepository } from '../repositories/AuditLogRepository'; +import { IAuditLog, AuditAction, AuditLogFilters } from '../types/index'; +import { Pagination } from '../../../shared/types/index'; +import { IAuditLogRepository } from '../interfaces/IAuditLogRepository'; +import { IAuditService } from '../interfaces/IAuditService'; -export class AuditService { - private auditLogRepository: AuditLogRepository; - - constructor() { - this.auditLogRepository = new AuditLogRepository(); - } +export class AuditService implements IAuditService { + constructor(private auditLogRepository: IAuditLogRepository) { } async log(entry: { workshopId: string; @@ -338,26 +331,38 @@ export class AuditService { async getWorkshopAuditLogs( workshopId: string, filters?: AuditLogFilters, - pagination?: Pagination - ): Promise<{ logs: IAuditLog[]; total: number }> { - return await this.auditLogRepository.findByWorkshop(workshopId, filters, pagination); + pagination: Pagination = { page: 1, limit: 50 } + ): Promise<{ logs: IAuditLog[]; total: number; totalPages: number }> { + const result = await this.auditLogRepository.findByWorkshop(workshopId, filters, pagination); + return { + ...result, + totalPages: Math.ceil(result.total / pagination.limit) + }; } async getUserActivityLogs( workshopId: string, userId: string, - pagination?: Pagination - ): Promise<{ logs: IAuditLog[]; total: number }> { - return await this.auditLogRepository.findByActor(workshopId, userId, pagination); + pagination: Pagination = { page: 1, limit: 50 } + ): Promise<{ logs: IAuditLog[]; total: number; totalPages: number }> { + const result = await this.auditLogRepository.findByActor(workshopId, userId, pagination); + return { + ...result, + totalPages: Math.ceil(result.total / pagination.limit) + }; } async getTargetAuditLogs( workshopId: string, targetId: string, targetType?: string, - pagination?: Pagination - ): Promise<{ logs: IAuditLog[]; total: number }> { - return await this.auditLogRepository.findByTarget(workshopId, targetId, targetType, pagination); + pagination: Pagination = { page: 1, limit: 50 } + ): Promise<{ logs: IAuditLog[]; total: number; totalPages: number }> { + const result = await this.auditLogRepository.findByTarget(workshopId, targetId, targetType, pagination); + return { + ...result, + totalPages: Math.ceil(result.total / pagination.limit) + }; } async getRecentLogs(workshopId: string, limit: number = 50): Promise { diff --git a/backend/src/modules/audit/types/index.ts b/backend/src/modules/audit/types/index.ts new file mode 100644 index 0000000..1ebab5f --- /dev/null +++ b/backend/src/modules/audit/types/index.ts @@ -0,0 +1,128 @@ +import { Document, Types } from 'mongoose'; + +export enum ActivityAction { + CREATED = 'created', + UPDATED = 'updated', + DELETED = 'deleted', + ASSIGNED = 'assigned', + UNASSIGNED = 'unassigned', + COMPLETED = 'completed', + REOPENED = 'reopened', + COMMENTED = 'commented', + UPLOADED = 'uploaded', + JOINED = 'joined', + LEFT = 'left', + INVITED = 'invited', + APPROVED = 'approved', + REJECTED = 'rejected' +} + +export enum ActivityEntityType { + WORKSHOP = 'workshop', + PROJECT = 'project', + TASK = 'task', + TEAM = 'team', + MESSAGE = 'message', + MEMBER = 'member', + ROLE = 'role', + FILE = 'file' +} + +export interface IActivityHistory extends Document { + workshop: Types.ObjectId; + user: Types.ObjectId; + action: ActivityAction; + entityType: ActivityEntityType; + entityId: Types.ObjectId; + entityName: string; + description: string; + metadata?: any; + ipAddress?: string; + userAgent?: string; + createdAt: Date; +} + +export enum AuditAction { + WORKSHOP_CREATED = 'workshop_created', + WORKSHOP_UPDATED = 'workshop_updated', + WORKSHOP_DELETED = 'workshop_deleted', + WORKSHOP_SETTINGS_UPDATED = 'workshop_settings_updated', + + MEMBER_INVITED = 'member_invited', + MEMBER_JOINED = 'member_joined', + MEMBER_LEFT = 'member_left', + MEMBER_REMOVED = 'member_removed', + MEMBER_ROLE_UPDATED = 'member_role_updated', + MEMBER_UPDATED = 'member_updated', + + TASK_CREATED = 'task_created', + TASK_UPDATED = 'task_updated', + TASK_DELETED = 'task_deleted', + TASK_ASSIGNED = 'task_assigned', + TASK_STATUS_UPDATED = 'task_status_updated', + TASK_STATUS_CHANGED = 'task_status_changed', + + PROJECT_CREATED = 'project_created', + PROJECT_UPDATED = 'project_updated', + PROJECT_DELETED = 'project_deleted', + + ROLE_CREATED = 'role_created', + ROLE_UPDATED = 'role_updated', + ROLE_DELETED = 'role_deleted', + ROLE_ASSIGNED = 'role_assigned', + ROLE_REVOKED = 'role_revoked', + + TEAM_CREATED = 'team_created', + TEAM_UPDATED = 'team_updated', + TEAM_DELETED = 'team_deleted', + TEAM_MEMBER_ADDED = 'team_member_added', + TEAM_MEMBER_REMOVED = 'team_member_removed', + + MANAGER_ASSIGNED = 'manager_assigned', + MANAGER_REMOVED = 'manager_removed', + JOIN_REQUEST_APPROVED = 'join_request_approved', + JOIN_REQUEST_REJECTED = 'join_request_rejected', + UNAUTHORIZED_ACCESS = 'unauthorized_access', + PROJECT_MANAGER_ASSIGNED = 'project_manager_assigned', + PROJECT_MAINTAINER_ASSIGNED = 'project_maintainer_assigned' +} + +export interface IAuditLog extends Document { + workshop: Types.ObjectId; + action: AuditAction; + actor: Types.ObjectId; + target?: Types.ObjectId; + targetType?: 'User' | 'Workshop' | 'Team' | 'Project' | 'Task' | 'Role' | 'Membership'; + details?: any; + timestamp: Date; +} + +export interface AuditLogFilters { + action?: AuditAction; + actor?: string; + target?: string; + targetType?: string; + startDate?: Date; + endDate?: Date; +} + +export interface LogActivityData { + workshop: string; + user: string; + action: ActivityAction; + entityType: ActivityEntityType; + entityId: string; + entityName: string; + description: string; + metadata?: any; + ipAddress?: string; + userAgent?: string; +} + +export interface ActivityFilters { + action?: ActivityAction; + entityType?: ActivityEntityType; + entityId?: string; + startDate?: Date; + endDate?: Date; +} diff --git a/backend/src/controllers/AuthController.ts b/backend/src/modules/auth/controllers/AuthController.ts similarity index 64% rename from backend/src/controllers/AuthController.ts rename to backend/src/modules/auth/controllers/AuthController.ts index da396a5..2ebbec4 100644 --- a/backend/src/controllers/AuthController.ts +++ b/backend/src/modules/auth/controllers/AuthController.ts @@ -1,14 +1,12 @@ import { Response, NextFunction } from 'express'; -import { AuthService } from '../services/AuthService'; -import { AuthRequest } from '../types'; -import { ValidationError } from '../utils/errors'; +import { IAuthService } from '../interfaces/IAuthService'; +import { AuthRequest } from '../../../shared/types/index'; +import { ValidationError } from '../../../shared/utils/errors'; +import passport from 'passport'; +import { isStrategyEnabled } from '../../../config/passport'; export class AuthController { - private authService: AuthService; - - constructor() { - this.authService = new AuthService(); - } + constructor(private authService: IAuthService) { } register = async (req: AuthRequest, res: Response, next: NextFunction): Promise => { try { @@ -178,4 +176,49 @@ export class AuthController { next(error); } }; + + completeSocialLogin = (req: AuthRequest, res: Response): void => { + const user = req.user as any; + const { token, refreshToken } = this.authService.generateTokens(user); + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + res.redirect(`${frontendUrl}/social-callback?token=${token}&refreshToken=${refreshToken}`); + }; + + initiateGoogleAuth = (req: AuthRequest, res: Response, next: NextFunction): void => { + if (!isStrategyEnabled('google')) { + res.status(400).json({ + success: false, + message: 'Google authentication is not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the backend .env file.' + }); + return; + } + passport.authenticate('google', { scope: ['profile', 'email'] })(req, res, next); + }; + + handleGoogleCallback = (req: AuthRequest, res: Response, next: NextFunction): void => { + if (!isStrategyEnabled('google')) { + res.redirect((process.env.FRONTEND_URL || 'http://localhost:3000') + '/login?error=google_not_configured'); + return; + } + passport.authenticate('google', { session: false, failureRedirect: '/login' })(req, res, next); + }; + + initiateGithubAuth = (req: AuthRequest, res: Response, next: NextFunction): void => { + if (!isStrategyEnabled('github')) { + res.status(400).json({ + success: false, + message: 'GitHub authentication is not configured. Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in the backend .env file.' + }); + return; + } + passport.authenticate('github', { scope: ['user:email'] })(req, res, next); + }; + + handleGithubCallback = (req: AuthRequest, res: Response, next: NextFunction): void => { + if (!isStrategyEnabled('github')) { + res.redirect((process.env.FRONTEND_URL || 'http://localhost:3000') + '/login?error=github_not_configured'); + return; + } + passport.authenticate('github', { session: false, failureRedirect: '/login' })(req, res, next); + }; } \ No newline at end of file diff --git a/backend/src/modules/auth/interfaces/IAuthService.ts b/backend/src/modules/auth/interfaces/IAuthService.ts new file mode 100644 index 0000000..a78671e --- /dev/null +++ b/backend/src/modules/auth/interfaces/IAuthService.ts @@ -0,0 +1,13 @@ +export interface IAuthService { + generateTokens(user: any): { token: string; refreshToken: string }; + register(name: string, email: string, password: string): Promise<{ message: string }>; + verifyOTP(email: string, otp: string): Promise<{ user: any; token: string; refreshToken: string }>; + resendOTP(email: string): Promise<{ message: string }>; + login(email: string, password: string): Promise<{ user: any; token: string; refreshToken: string }>; + refreshToken(token: string): Promise<{ token: string }>; + getProfile(userId: string): Promise; + socialLogin(profile: any, type: 'google' | 'github'): Promise; + updateProfile(userId: string, data: any): Promise; + forgotPassword(email: string): Promise<{ message: string }>; + resetPassword(token: string, newPassword: string): Promise<{ message: string }>; +} diff --git a/backend/src/modules/auth/interfaces/IPasswordResetRepository.ts b/backend/src/modules/auth/interfaces/IPasswordResetRepository.ts new file mode 100644 index 0000000..389cac3 --- /dev/null +++ b/backend/src/modules/auth/interfaces/IPasswordResetRepository.ts @@ -0,0 +1,9 @@ +import { IPasswordReset } from '../types/index'; + +export interface IPasswordResetRepository { + create(data: Partial): Promise; + findByToken(token: string): Promise; + findByEmail(email: string): Promise; + markAsUsed(token: string): Promise; + deleteByEmail(email: string): Promise; +} diff --git a/backend/src/modules/auth/interfaces/IPendingUserRepository.ts b/backend/src/modules/auth/interfaces/IPendingUserRepository.ts new file mode 100644 index 0000000..6d0b463 --- /dev/null +++ b/backend/src/modules/auth/interfaces/IPendingUserRepository.ts @@ -0,0 +1,8 @@ +import { IPendingUser } from '../types/index'; + +export interface IPendingUserRepository { + create(userData: Partial): Promise; + findByEmail(email: string): Promise; + findByOTP(otp: string): Promise; + deleteById(id: string): Promise; +} diff --git a/backend/src/models/PasswordReset.ts b/backend/src/modules/auth/models/PasswordReset.ts similarity index 77% rename from backend/src/models/PasswordReset.ts rename to backend/src/modules/auth/models/PasswordReset.ts index 33e916b..6d2d7ed 100644 --- a/backend/src/models/PasswordReset.ts +++ b/backend/src/modules/auth/models/PasswordReset.ts @@ -1,13 +1,5 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IPasswordReset extends Document { - email: string; - token: string; - expiresAt: Date; - used: boolean; - createdAt: Date; - updatedAt: Date; -} +import mongoose, { Schema } from 'mongoose'; +import { IPasswordReset } from '../types/index'; const passwordResetSchema = new Schema( { diff --git a/backend/src/models/PendingUser.ts b/backend/src/modules/auth/models/PendingUser.ts similarity index 95% rename from backend/src/models/PendingUser.ts rename to backend/src/modules/auth/models/PendingUser.ts index 7c06795..821c54d 100644 --- a/backend/src/models/PendingUser.ts +++ b/backend/src/modules/auth/models/PendingUser.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { IPendingUser } from '../types'; +import { IPendingUser } from '../types/index'; const pendingUserSchema = new Schema( { diff --git a/backend/src/repositories/PasswordResetRepository.ts b/backend/src/modules/auth/repositories/PasswordResetRepository.ts similarity index 56% rename from backend/src/repositories/PasswordResetRepository.ts rename to backend/src/modules/auth/repositories/PasswordResetRepository.ts index 490af0c..9b4fbcd 100644 --- a/backend/src/repositories/PasswordResetRepository.ts +++ b/backend/src/modules/auth/repositories/PasswordResetRepository.ts @@ -1,24 +1,26 @@ -import { PasswordReset, IPasswordReset } from '../models/PasswordReset'; +import { PasswordReset } from '../models/PasswordReset'; +import { IPasswordReset } from '../types/index'; +import { IPasswordResetRepository } from '../interfaces/IPasswordResetRepository'; -export class PasswordResetRepository { +export class PasswordResetRepository implements IPasswordResetRepository { async create(data: Partial): Promise { const passwordReset = new PasswordReset(data); return await passwordReset.save(); } async findByToken(token: string): Promise { - return await PasswordReset.findOne({ - token, - used: false, - expiresAt: { $gt: new Date() } + return await PasswordReset.findOne({ + token, + used: false, + expiresAt: { $gt: new Date() } }); } async findByEmail(email: string): Promise { - return await PasswordReset.findOne({ - email, - used: false, - expiresAt: { $gt: new Date() } + return await PasswordReset.findOne({ + email, + used: false, + expiresAt: { $gt: new Date() } }); } diff --git a/backend/src/repositories/PendingUserRepository.ts b/backend/src/modules/auth/repositories/PendingUserRepository.ts similarity index 74% rename from backend/src/repositories/PendingUserRepository.ts rename to backend/src/modules/auth/repositories/PendingUserRepository.ts index 5d9322d..99c565e 100644 --- a/backend/src/repositories/PendingUserRepository.ts +++ b/backend/src/modules/auth/repositories/PendingUserRepository.ts @@ -1,7 +1,8 @@ import { PendingUser } from '../models/PendingUser'; -import { IPendingUser } from '../types'; +import { IPendingUser } from '../types/index'; +import { IPendingUserRepository } from '../interfaces/IPendingUserRepository'; -export class PendingUserRepository { +export class PendingUserRepository implements IPendingUserRepository { async create(userData: Partial): Promise { const pendingUser = new PendingUser(userData); return await pendingUser.save(); diff --git a/backend/src/modules/auth/routes/authRoutes.ts b/backend/src/modules/auth/routes/authRoutes.ts new file mode 100644 index 0000000..f2a418c --- /dev/null +++ b/backend/src/modules/auth/routes/authRoutes.ts @@ -0,0 +1,29 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { AUTH_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createAuthRoutes = (container: Container) => { + const router = Router(); + const authController = container.authCtrl; + + router.post(AUTH_ROUTES.REGISTER, authController.register as any); + router.post(AUTH_ROUTES.VERIFY_OTP, authController.verifyOTP as any); + router.post(AUTH_ROUTES.RESEND_OTP, authController.resendOTP as any); + router.post(AUTH_ROUTES.LOGIN, authController.login as any); + router.post(AUTH_ROUTES.REFRESH_TOKEN, authController.refreshToken as any); + router.post(AUTH_ROUTES.FORGOT_PASSWORD, authController.forgotPassword as any); + router.post(AUTH_ROUTES.RESET_PASSWORD, authController.resetPassword as any); + router.get(AUTH_ROUTES.ME, authMiddleware as any, authController.getProfile as any); + router.put(AUTH_ROUTES.PROFILE, authMiddleware as any, authController.updateProfile as any); + + router.get(AUTH_ROUTES.GOOGLE, authController.initiateGoogleAuth); + router.get(AUTH_ROUTES.GOOGLE_CALLBACK, authController.handleGoogleCallback, authController.completeSocialLogin); + + router.get(AUTH_ROUTES.GITHUB, authController.initiateGithubAuth); + router.get(AUTH_ROUTES.GITHUB_CALLBACK, authController.handleGithubCallback, authController.completeSocialLogin); + + return router; +}; + +export default createAuthRoutes; \ No newline at end of file diff --git a/backend/src/services/AuthService.ts b/backend/src/modules/auth/services/AuthService.ts similarity index 66% rename from backend/src/services/AuthService.ts rename to backend/src/modules/auth/services/AuthService.ts index d8f3e70..60f7834 100644 --- a/backend/src/services/AuthService.ts +++ b/backend/src/modules/auth/services/AuthService.ts @@ -1,21 +1,28 @@ -import bcrypt from 'bcryptjs'; +import { IUserRepository } from '../../user/interfaces/IUserRepository'; +import { IPendingUserRepository } from '../interfaces/IPendingUserRepository'; +import { IPasswordResetRepository } from '../interfaces/IPasswordResetRepository'; +import { ITokenProvider } from '../../../shared/interfaces/ITokenProvider'; +import { IEmailProvider } from '../../../shared/interfaces/IEmailProvider'; +import { IHashProvider } from '../../../shared/interfaces/IHashProvider'; +import { IAuthService } from '../interfaces/IAuthService'; +import { AuthenticationError, ValidationError, NotFoundError } from '../../../shared/utils/errors'; import crypto from 'crypto'; -import { UserRepository } from '../repositories/UserRepository'; -import { PendingUserRepository } from '../repositories/PendingUserRepository'; -import { PasswordResetRepository } from '../repositories/PasswordResetRepository'; -import { generateToken, generateRefreshToken, verifyRefreshToken } from '../config/jwt'; -import { AuthenticationError, ValidationError, NotFoundError } from '../utils/errors'; -import { sendEmail } from '../utils/emailService'; - -export class AuthService { - private userRepository: UserRepository; - private pendingUserRepository: PendingUserRepository; - private passwordResetRepository: PasswordResetRepository; - - constructor() { - this.userRepository = new UserRepository(); - this.pendingUserRepository = new PendingUserRepository(); - this.passwordResetRepository = new PasswordResetRepository(); +import { verificationOtpTemplate, passwordResetTemplate } from '../../../shared/templates/email'; + +export class AuthService implements IAuthService { + constructor( + private userRepository: IUserRepository, + private pendingUserRepository: IPendingUserRepository, + private passwordResetRepository: IPasswordResetRepository, + private tokenProv: ITokenProvider, + private emailProv: IEmailProvider, + private hashProv: IHashProvider + ) { } + + generateTokens(user: any): { token: string; refreshToken: string } { + const token = this.tokenProv.generateToken({ id: user._id.toString(), email: user.email }); + const refreshToken = this.tokenProv.generateRefreshToken({ id: user._id.toString(), email: user.email }); + return { token, refreshToken }; } async register(name: string, email: string, password: string): Promise<{ message: string }> { @@ -26,8 +33,7 @@ export class AuthService { const existingPending = await this.pendingUserRepository.findByEmail(email); if (existingPending) { - - const hashedPassword = await bcrypt.hash(password, 10); + const hashedPassword = await this.hashProv.hash(password); const otp = Math.floor(100000 + Math.random() * 900000).toString(); const otpExpires = new Date(Date.now() + 10 * 60 * 1000); @@ -41,7 +47,7 @@ export class AuthService { return { message: 'Verification code re-sent. Please check your email.' }; } - const hashedPassword = await bcrypt.hash(password, 10); + const hashedPassword = await this.hashProv.hash(password); const otp = Math.floor(100000 + Math.random() * 900000).toString(); const otpExpires = new Date(Date.now() + 10 * 60 * 1000); @@ -59,19 +65,9 @@ export class AuthService { } private async sendVerificationOTP(email: string, otp: string) { - const emailHtml = ` -
-

Welcome to Team Up!

-

Your verification code is:

-
- ${otp} -
-

This code will expire in 10 minutes.

-

If you didn't request this, please ignore this email.

-
- `; - - await sendEmail(email, 'Your Verification Code - Team Up', emailHtml); + const emailHtml = verificationOtpTemplate(otp); + + await this.emailProv.sendEmail(email, 'Your Verification Code - Team Up', emailHtml); } async verifyOTP(email: string, otp: string): Promise<{ user: any; token: string; refreshToken: string }> { @@ -102,8 +98,8 @@ export class AuthService { await this.pendingUserRepository.deleteById(pendingUser._id.toString()); - const authToken = generateToken({ id: newUser._id.toString(), email: newUser.email }); - const refreshToken = generateRefreshToken({ id: newUser._id.toString(), email: newUser.email }); + const authToken = this.tokenProv.generateToken({ id: newUser._id.toString(), email: newUser.email }); + const refreshToken = this.tokenProv.generateRefreshToken({ id: newUser._id.toString(), email: newUser.email }); const userResponse = JSON.parse(JSON.stringify(newUser)); delete userResponse.password; @@ -138,15 +134,15 @@ export class AuthService { throw new ValidationError('Please verify your email before logging in.'); } - const isPasswordValid = await bcrypt.compare(password, user.password); + const isPasswordValid = await this.hashProv.compare(password, user.password); if (!isPasswordValid) { throw new ValidationError('Invalid email or password'); } await this.userRepository.updatePresence(user._id.toString(), true); - const token = generateToken({ id: user._id.toString(), email: user.email }); - const refreshToken = generateRefreshToken({ id: user._id.toString(), email: user.email }); + const token = this.tokenProv.generateToken({ id: user._id.toString(), email: user.email }); + const refreshToken = this.tokenProv.generateRefreshToken({ id: user._id.toString(), email: user.email }); const userResponse = JSON.parse(JSON.stringify(user)); delete userResponse.password; @@ -156,14 +152,14 @@ export class AuthService { async refreshToken(token: string): Promise<{ token: string }> { try { - const decoded = verifyRefreshToken(token); + const decoded = this.tokenProv.verifyRefreshToken(token); const user = await this.userRepository.findById(decoded.id); if (!user) { throw new AuthenticationError('User not found'); } - const newToken = generateToken({ id: user._id.toString(), email: user.email }); + const newToken = this.tokenProv.generateToken({ id: user._id.toString(), email: user.email }); return { token: newToken }; } catch (error) { throw new AuthenticationError('Invalid or expired refresh token'); @@ -192,7 +188,6 @@ export class AuthService { if (email) { user = await this.userRepository.findByEmail(email); if (user) { - (user as any)[idField] = profile.id; if (!user.isVerified) user.isVerified = true; await user.save(); @@ -201,7 +196,7 @@ export class AuthService { } const dummyPassword = crypto.randomBytes(32).toString('hex'); - const hashedPassword = await bcrypt.hash(dummyPassword, 10); + const hashedPassword = await this.hashProv.hash(dummyPassword); const userData: any = { name: profile.displayName || profile.username || 'User', @@ -256,26 +251,10 @@ export class AuthService { }); const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; - - const emailHtml = ` -
-

Reset Your Password

-

Hi ${user.name},

-

You requested to reset your password for your Team Up account. Click the button below to reset it:

- -

This link will expire in 1 hour for security reasons.

-

If you didn't request this password reset, please ignore this email.

-
-

- If the button doesn't work, copy and paste this link into your browser:
- ${resetUrl} -

-
- `; - - await sendEmail(email, 'Reset Your Password - Team Up', emailHtml); + + const emailHtml = passwordResetTemplate(user.name, resetUrl); + + await this.emailProv.sendEmail(email, 'Reset Your Password - Team Up', emailHtml); return { message: 'Password reset link has been sent to your email' }; } @@ -291,7 +270,7 @@ export class AuthService { throw new NotFoundError('User not found'); } - const hashedPassword = await bcrypt.hash(newPassword, 10); + const hashedPassword = await this.hashProv.hash(newPassword); await this.userRepository.update(user._id.toString(), { password: hashedPassword }); await this.passwordResetRepository.markAsUsed(token); diff --git a/backend/src/modules/auth/types/index.ts b/backend/src/modules/auth/types/index.ts new file mode 100644 index 0000000..153e22e --- /dev/null +++ b/backend/src/modules/auth/types/index.ts @@ -0,0 +1,21 @@ + +import { Document, Types } from 'mongoose'; + +export interface IPendingUser extends Document { + _id: Types.ObjectId; + name: string; + email: string; + password: string; + otp: string; + otpExpires: Date; + createdAt: Date; +} + +export interface IPasswordReset extends Document { + email: string; + token: string; + expiresAt: Date; + used: boolean; + createdAt: Date; + updatedAt: Date; +} diff --git a/backend/src/controllers/ChatController.ts b/backend/src/modules/chat/controllers/ChatController.ts similarity index 84% rename from backend/src/controllers/ChatController.ts rename to backend/src/modules/chat/controllers/ChatController.ts index dab53f6..a96a2e0 100644 --- a/backend/src/controllers/ChatController.ts +++ b/backend/src/modules/chat/controllers/ChatController.ts @@ -1,11 +1,12 @@ import { Response, NextFunction } from 'express'; -import { ChatService } from '../services/ChatService'; -import { CloudinaryService } from '../services/CloudinaryService'; -import { AuthRequest } from '../types'; -import { MessageType } from '../models/Message'; -import { ChatRoomType } from '../models/ChatRoom'; -import { SocketService } from '../services/SocketService'; +import { IChatService } from '../interfaces/IChatService'; +import { ICloudinaryService } from '../../../shared/interfaces/ICloudinaryService'; +import { AuthRequest } from '../../../shared/types/index'; +import { MessageType, ChatRoomType } from '../types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; import multer from 'multer'; +import { IPermissionService } from '../../access-control/interfaces/IPermissionService'; +import { AuthorizationError } from '../../../shared/utils/errors'; const storage = multer.memoryStorage(); const upload = multer({ @@ -15,23 +16,17 @@ const upload = multer({ } }); -import { PermissionService } from '../services/PermissionService'; -import { AuthorizationError } from '../utils/errors'; - export class ChatController { - private chatService: ChatService; - private cloudinaryService: CloudinaryService; - private permissionService: PermissionService; - private socketService: SocketService | null = null; + private socketService: ISocketService | null = null; public uploadMiddleware = upload.single('file'); - constructor() { - this.chatService = new ChatService(); - this.cloudinaryService = new CloudinaryService(); - this.permissionService = PermissionService.getInstance(); - } + constructor( + private chatService: IChatService, + private cloudinaryService: ICloudinaryService, + private permissionService: IPermissionService + ) { } - setSocketService(socketService: SocketService): void { + setSocketService(socketService: ISocketService): void { this.socketService = socketService; this.chatService.setSocketService(socketService); } @@ -51,52 +46,12 @@ export class ChatController { throw new AuthorizationError(permission.reason || 'You do not have permission to create chat rooms'); } - let roomParticipants = participants; - - if (!roomParticipants || roomParticipants.length === 0) { - const pList = new Set(); - pList.add(userId); - - if (roomType === ChatRoomType.PROJECT && projectId) { - const project = await require('../models/WorkshopProject').WorkshopProject.findById(projectId); - if (project) { - project.assignedIndividuals.forEach((id: any) => pList.add(id.toString())); - if (project.projectManager) pList.add(project.projectManager.toString()); - project.maintainers.forEach((id: any) => pList.add(id.toString())); - - if (project.assignedTeams && project.assignedTeams.length > 0) { - const TeamModel = require('../models/Team').Team; - const teams = await TeamModel.find({ _id: { $in: project.assignedTeams } }); - teams.forEach((team: any) => { - team.members.forEach((memberId: any) => pList.add(memberId.toString())); - }); - } - } - } else if (roomType === ChatRoomType.TEAM && teamId) { - const team = await require('../models/Team').Team.findById(teamId); - if (team) { - team.members.forEach((id: any) => pList.add(id.toString())); - } - } else if (roomType === ChatRoomType.WORKSHOP || !roomType) { - const MembershipModel = require('../models/Membership').Membership; - const members = await MembershipModel.find({ - workshop: workshopId, - state: 'active' - }); - members.forEach((m: any) => pList.add(m.user.toString())); - } - - roomParticipants = Array.from(pList); - } else if (!roomParticipants.includes(userId)) { - roomParticipants.push(userId); - } - const chatRoom = await this.chatService.createChatRoom({ roomType: roomType || ChatRoomType.WORKSHOP, workshopId, projectId, teamId, - participants: roomParticipants, + participants: participants || [], name, description, createdBy: userId @@ -440,7 +395,6 @@ export class ChatController { await this.chatService.deleteMessage(messageId, userId); if (this.socketService) { - const Message = require('../models/Message').Message; const message = await Message.findById(messageId); if (message) { diff --git a/backend/src/modules/chat/interfaces/IChatService.ts b/backend/src/modules/chat/interfaces/IChatService.ts new file mode 100644 index 0000000..de20a13 --- /dev/null +++ b/backend/src/modules/chat/interfaces/IChatService.ts @@ -0,0 +1,27 @@ +import { IChatRoom, IMessage } from '../types/index'; +import { CreateChatRoomData, SendMessageData } from '../types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; + +export interface IChatService { + setSocketService(socketService: ISocketService): void; + createChatRoom(data: CreateChatRoomData): Promise; + getChatRoom(roomId: string): Promise; + getUserChatRooms(userId: string, workshopId: string): Promise; + syncAllWorkshopMembers(workshopId: string): Promise; + getOrCreateDirectRoom(workshopId: string, user1Id: string, user2Id: string): Promise; + updateChatRoom(roomId: string, userId: string, data: Partial<{ name: string; description: string }>): Promise; + deleteChatRoom(roomId: string, userId: string): Promise; + deleteRoomsByEntity(entityType: 'project' | 'team', entityId: string): Promise; + syncUserToWorkshopRooms(userId: string, workshopId: string): Promise; + syncUserRemovalFromWorkshopRooms(userId: string, workshopId: string): Promise; + sendMessage(roomId: string, senderId: string, data: SendMessageData): Promise; + getMessages(roomId: string, userId: string, page?: number, limit?: number): Promise<{ messages: IMessage[]; total: number; hasMore: boolean }>; + markMessageAsSeen(messageId: string, userId: string): Promise; + markAllMessagesAsSeen(roomId: string, userId: string): Promise; + editMessage(messageId: string, userId: string, newContent: string): Promise; + deleteMessage(messageId: string, userId: string): Promise; + addReaction(messageId: string, userId: string, emoji: string): Promise; + removeReaction(messageId: string, userId: string, emoji: string): Promise; + searchMessages(roomId: string, userId: string, query: string, page?: number, limit?: number): Promise<{ messages: IMessage[]; total: number }>; + getUnreadCount(roomId: string, userId: string): Promise; +} diff --git a/backend/src/models/ChatRoom.ts b/backend/src/modules/chat/models/ChatRoom.ts similarity index 71% rename from backend/src/models/ChatRoom.ts rename to backend/src/modules/chat/models/ChatRoom.ts index b3ada43..3fde9e3 100644 --- a/backend/src/models/ChatRoom.ts +++ b/backend/src/modules/chat/models/ChatRoom.ts @@ -1,34 +1,5 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export enum ChatRoomType { - WORKSHOP = 'workshop', - PROJECT = 'project', - TEAM = 'team', - DIRECT = 'direct' -} - -export interface IChatRoomSettings { - allowFileSharing: boolean; - allowAudioMessages: boolean; - maxFileSize: number; -} - -export interface IChatRoom extends Document { - roomType: ChatRoomType; - workshop: mongoose.Types.ObjectId; - project?: mongoose.Types.ObjectId; - team?: mongoose.Types.ObjectId; - participants: mongoose.Types.ObjectId[]; - name: string; - description?: string; - lastMessage?: mongoose.Types.ObjectId; - lastMessageAt?: Date; - createdBy: mongoose.Types.ObjectId; - admins: mongoose.Types.ObjectId[]; - settings: IChatRoomSettings; - createdAt: Date; - updatedAt: Date; -} +import mongoose, { Schema } from 'mongoose'; +import { ChatRoomType, IChatRoom, IChatRoomSettings } from '../types/index'; const chatRoomSettingsSchema = new Schema({ allowFileSharing: { diff --git a/backend/src/models/Message.ts b/backend/src/modules/chat/models/Message.ts similarity index 70% rename from backend/src/models/Message.ts rename to backend/src/modules/chat/models/Message.ts index d916e70..baee3f0 100644 --- a/backend/src/models/Message.ts +++ b/backend/src/modules/chat/models/Message.ts @@ -1,42 +1,5 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export enum MessageType { - TEXT = 'text', - AUDIO = 'audio', - IMAGE = 'image', - DOCUMENT = 'document' -} - -export interface IMessageReaction { - user: mongoose.Types.ObjectId; - emoji: string; - createdAt: Date; -} - -export interface ISeenBy { - user: mongoose.Types.ObjectId; - seenAt: Date; -} - -export interface IMessage extends Document { - chatRoom: mongoose.Types.ObjectId; - sender: mongoose.Types.ObjectId; - messageType: MessageType; - content: string; - fileName?: string; - fileSize?: number; - mimeType?: string; - duration?: number; - seenBy: ISeenBy[]; - replyTo?: mongoose.Types.ObjectId; - isEdited: boolean; - editedAt?: Date; - isDeleted: boolean; - deletedAt?: Date; - reactions: IMessageReaction[]; - createdAt: Date; - updatedAt: Date; -} +import mongoose, { Schema } from 'mongoose'; +import { MessageType, IMessage, ISeenBy, IMessageReaction } from '../types/index'; const messageReactionSchema = new Schema({ user: { diff --git a/backend/src/modules/chat/routes/chatRoutes.ts b/backend/src/modules/chat/routes/chatRoutes.ts new file mode 100644 index 0000000..b6c371c --- /dev/null +++ b/backend/src/modules/chat/routes/chatRoutes.ts @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { CHAT_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createChatRoutes = (container: Container) => { + const router = Router(); + const chatController = container.chatCtrl; + + router.post(CHAT_ROUTES.WORKSHOP_ROOMS, authMiddleware, chatController.createRoom); + router.get(CHAT_ROUTES.WORKSHOP_ROOMS, authMiddleware, chatController.getRooms); + router.post(CHAT_ROUTES.DIRECT, authMiddleware, chatController.getOrCreateDirectRoom); + + router.get(CHAT_ROUTES.BY_ID, authMiddleware, chatController.getRoom); + router.put(CHAT_ROUTES.BY_ID, authMiddleware, chatController.updateRoom); + router.delete(CHAT_ROUTES.BY_ID, authMiddleware, chatController.deleteRoom); + + router.post(CHAT_ROUTES.MESSAGES, authMiddleware, chatController.sendMessage); + router.get(CHAT_ROUTES.MESSAGES, authMiddleware, chatController.getMessages); + + router.put(CHAT_ROUTES.MESSAGE_BY_ID, authMiddleware, chatController.editMessage); + router.delete(CHAT_ROUTES.MESSAGE_BY_ID, authMiddleware, chatController.deleteMessage); + + router.put(CHAT_ROUTES.MESSAGE_SEEN, authMiddleware, chatController.markAsSeen); + router.put(CHAT_ROUTES.ROOM_SEEN, authMiddleware, chatController.markAllAsSeen); + router.get(CHAT_ROUTES.ROOM_UNREAD, authMiddleware, chatController.getUnreadCount); + + router.post(CHAT_ROUTES.REACTIONS, authMiddleware, chatController.addReaction); + router.delete(CHAT_ROUTES.REACTIONS, authMiddleware, chatController.removeReaction); + + router.get(CHAT_ROUTES.SEARCH, authMiddleware, chatController.searchMessages); + + router.post(CHAT_ROUTES.UPLOAD, authMiddleware, chatController.uploadMiddleware, chatController.uploadMedia); + router.post(CHAT_ROUTES.UPLOAD_ONLY, authMiddleware, chatController.uploadMiddleware, chatController.uploadOnly); + + return router; +}; + +export default createChatRoutes; \ No newline at end of file diff --git a/backend/src/services/ChatService.ts b/backend/src/modules/chat/services/ChatService.ts similarity index 77% rename from backend/src/services/ChatService.ts rename to backend/src/modules/chat/services/ChatService.ts index f4e21d3..3df7231 100644 --- a/backend/src/services/ChatService.ts +++ b/backend/src/modules/chat/services/ChatService.ts @@ -1,48 +1,28 @@ -import { ChatRoom, IChatRoom, ChatRoomType } from '../models/ChatRoom'; -import { Message, IMessage, MessageType } from '../models/Message'; -import { NotFoundError, AuthorizationError, ValidationError } from '../utils/errors'; +import { ChatRoom } from '../models/ChatRoom'; +import { Message } from '../models/Message'; +import { IChatRoom, ChatRoomType, IMessage, MessageType, CreateChatRoomData, SendMessageData, ISeenBy, IMessageReaction } from '../types/index'; +import { NotFoundError, AuthorizationError, ValidationError } from '../../../shared/utils/errors'; import { Types } from 'mongoose'; -import { ActivityHistoryService } from './ActivityHistoryService'; -import { ActivityAction, ActivityEntityType } from '../models/ActivityHistory'; -import { SocketService } from './SocketService'; - -export interface CreateChatRoomData { - roomType: ChatRoomType; - workshopId: string; - projectId?: string; - teamId?: string; - participants: string[]; - name: string; - description?: string; - createdBy: string; -} - -export interface SendMessageData { - messageType: MessageType; - content: string; - fileName?: string; - fileSize?: number; - mimeType?: string; - duration?: number; - replyTo?: string; -} - -export interface MessageFilters { - search?: string; - messageType?: MessageType; - startDate?: Date; - endDate?: Date; -} - -export class ChatService { - private activityService: ActivityHistoryService; - private socketService: SocketService | null = null; - - constructor() { - this.activityService = new ActivityHistoryService(); - } - - setSocketService(socketService: SocketService): void { +import { IActivityHistoryService } from '../../audit/interfaces/IActivityHistoryService'; +import { ActivityAction, ActivityEntityType } from '../../audit/types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; +import { IWorkshopRepository } from '../../workshop/interfaces/IWorkshopRepository'; +import { ITeamRepository } from '../../team/interfaces/ITeamRepository'; +import { IWorkshopProjectRepository } from '../../project/interfaces/IWorkshopProjectRepository'; +import { IMembershipRepository } from '../../team/interfaces/IMembershipRepository'; +import { IChatService } from '../interfaces/IChatService'; + +export class ChatService implements IChatService { + constructor( + private activityService: IActivityHistoryService, + private workshopRepository: IWorkshopRepository, + private teamRepository: ITeamRepository, + private projectRepository: IWorkshopProjectRepository, + private membershipRepository: IMembershipRepository, + private socketService: ISocketService | null = null + ) { } + + setSocketService(socketService: ISocketService): void { this.socketService = socketService; } @@ -52,10 +32,19 @@ export class ChatService { } async createChatRoom(data: CreateChatRoomData): Promise { - const WorkshopModel = require('../models/Workshop').Workshop; - const workshop = await WorkshopModel.findById(data.workshopId); + const workshop = await this.workshopRepository.findById(data.workshopId); + + let participants = data.participants; + + if (!participants || participants.length === 0) { + participants = await this.getParticipantsForRoom(data.workshopId, data.roomType, data.projectId, data.teamId); + } + + const userIdStr = data.createdBy; + if (!participants.includes(userIdStr)) { + participants.push(userIdStr); + } - const participants = [...data.participants]; if (data.roomType !== ChatRoomType.DIRECT && workshop) { const ownerIdStr = workshop.owner.toString(); if (!participants.includes(ownerIdStr)) { @@ -88,6 +77,41 @@ export class ChatService { return chatRoom.populate(['participants', 'createdBy', 'workshop', 'project', 'team']); } + private async getParticipantsForRoom( + workshopId: string, + roomType: ChatRoomType, + projectId?: string, + teamId?: string + ): Promise { + const pList = new Set(); + + if (roomType === ChatRoomType.PROJECT && projectId) { + const project = await this.projectRepository.findById(projectId); + if (project) { + project.assignedIndividuals?.forEach((id: any) => pList.add(id.toString())); + if (project.projectManager) pList.add(project.projectManager.toString()); + project.maintainers?.forEach((id: any) => pList.add(id.toString())); + + if (project.assignedTeams && project.assignedTeams.length > 0) { + const teams = await this.teamRepository.findByIds(project.assignedTeams.map((id: any) => id.toString())); + teams.forEach((team: any) => { + team.members.forEach((memberId: any) => pList.add(memberId.toString())); + }); + } + } + } else if (roomType === ChatRoomType.TEAM && teamId) { + const team = await this.teamRepository.findById(teamId); + if (team) { + team.members.forEach((id: any) => pList.add(id.toString())); + } + } else if (roomType === ChatRoomType.WORKSHOP || !roomType) { + const members = await this.membershipRepository.getActiveMembers(workshopId); + members.forEach((m: any) => pList.add(m.user.toString())); + } + + return Array.from(pList); + } + async getChatRoom(roomId: string): Promise { const chatRoom = await ChatRoom.findById(roomId) .populate(['participants', 'createdBy', 'lastMessage', 'workshop', 'project', 'team']); @@ -100,11 +124,9 @@ export class ChatService { } async getUserChatRooms(userId: string, workshopId: string): Promise { - await this.syncUserToWorkshopRooms(userId, workshopId); - const WorkshopModel = require('../models/Workshop').Workshop; - const workshop = await WorkshopModel.findById(workshopId); + const workshop = await this.workshopRepository.findById(workshopId); const isOwner = workshop && workshop.owner.toString() === userId; const query: any = { workshop: new Types.ObjectId(workshopId) }; @@ -120,11 +142,7 @@ export class ChatService { } async syncAllWorkshopMembers(workshopId: string): Promise { - const MembershipModel = require('../models/Membership').Membership; - const activeMemberships = await MembershipModel.find({ - workshop: new Types.ObjectId(workshopId), - state: 'active' - }); + const activeMemberships = await this.membershipRepository.getActiveMembers(workshopId); for (const membership of activeMemberships) { await this.syncUserToWorkshopRooms(membership.user.toString(), workshopId); @@ -132,7 +150,6 @@ export class ChatService { } async getOrCreateDirectRoom(workshopId: string, user1Id: string, user2Id: string): Promise { - const existingRoom = await ChatRoom.findOne({ roomType: ChatRoomType.DIRECT, workshop: new Types.ObjectId(workshopId), @@ -194,7 +211,6 @@ export class ChatService { } private async performRoomDeletion(roomId: string): Promise { - await Message.deleteMany({ chatRoom: new Types.ObjectId(roomId) }); await ChatRoom.findByIdAndDelete(roomId); @@ -207,18 +223,10 @@ export class ChatService { const uId = new Types.ObjectId(userId); const workshopObjectId = new Types.ObjectId(workshopId); - const MembershipModel = require('../models/Membership').Membership; - const membership = await MembershipModel.findOne({ - workshop: workshopObjectId, - user: uId, - state: 'active' - }); - - const WorkshopModel = require('../models/Workshop').Workshop; - const workshop = await WorkshopModel.findById(workshopObjectId); + const membership = await this.membershipRepository.findActive(workshopId, userId); + const workshop = await this.workshopRepository.findById(workshopId); if (!membership) { - const isOwner = workshop && workshop.owner.toString() === userId; if (!isOwner) { await this.syncUserRemovalFromWorkshopRooms(userId, workshopId); @@ -229,7 +237,6 @@ export class ChatService { const isOwner = workshop && workshop.owner.toString() === userId; if (isOwner) { - await ChatRoom.updateMany( { workshop: workshopObjectId, @@ -252,26 +259,17 @@ export class ChatService { { $pull: { participants: uId } } ); - const TeamModel = require('../models/Team').Team; - const teams = await TeamModel.find({ workshop: workshopObjectId, members: uId }); + const teams = await this.teamRepository.findByMemberInWorkshop(workshopId, userId); + const teamIds = teams.map((t: any) => t._id.toString()); + if (teams.length > 0) { - const teamIds = teams.map((t: any) => t._id); await ChatRoom.updateMany( - { workshop: workshopObjectId, roomType: ChatRoomType.TEAM, team: { $in: teamIds } }, + { workshop: workshopObjectId, roomType: ChatRoomType.TEAM, team: { $in: teams.map((t: any) => t._id) } }, { $addToSet: { participants: uId } } ); } - const ProjectModel = require('../models/WorkshopProject').WorkshopProject; - const projects = await ProjectModel.find({ - workshop: workshopObjectId, - $or: [ - { assignedIndividuals: uId }, - { projectManager: uId }, - { maintainers: uId }, - { assignedTeams: { $in: teams.map((t: any) => t._id) } } - ] - }); + const projects = await this.projectRepository.findAccessible(userId, workshopId, teamIds); if (projects.length > 0) { const projectIds = projects.map((p: any) => p._id); await ChatRoom.updateMany( @@ -297,7 +295,6 @@ export class ChatService { } async sendMessage(roomId: string, senderId: string, data: SendMessageData): Promise { - const chatRoom = await this.getChatRoom(roomId); if (!chatRoom.participants.some(p => this.getIdString(p) === senderId)) { throw new AuthorizationError('You are not a participant of this chat room'); @@ -340,7 +337,6 @@ export class ChatService { page: number = 1, limit: number = 50 ): Promise<{ messages: IMessage[]; total: number; hasMore: boolean }> { - const chatRoom = await this.getChatRoom(roomId); if (!chatRoom.participants.some(p => this.getIdString(p) === userId)) { throw new AuthorizationError('You are not a participant of this chat room'); @@ -376,7 +372,7 @@ export class ChatService { throw new NotFoundError('Message'); } - const alreadySeen = message.seenBy.some(s => s.user.toString() === userId); + const alreadySeen = message.seenBy.some((s: ISeenBy) => s.user.toString() === userId); if (alreadySeen) { return message; } @@ -457,7 +453,7 @@ export class ChatService { } const existingReaction = message.reactions.find( - r => this.getIdString(r.user) === userId && r.emoji === emoji + (r: IMessageReaction) => this.getIdString(r.user) === userId && r.emoji === emoji ); if (existingReaction) { @@ -481,7 +477,7 @@ export class ChatService { } message.reactions = message.reactions.filter( - r => !(this.getIdString(r.user) === userId && r.emoji === emoji) + (r: IMessageReaction) => !(this.getIdString(r.user) === userId && r.emoji === emoji) ); await message.save(); @@ -495,7 +491,6 @@ export class ChatService { page: number = 1, limit: number = 20 ): Promise<{ messages: IMessage[]; total: number }> { - const chatRoom = await this.getChatRoom(roomId); if (!chatRoom.participants.some(p => this.getIdString(p) === userId)) { throw new AuthorizationError('You are not a participant of this chat room'); diff --git a/backend/src/modules/chat/types/index.ts b/backend/src/modules/chat/types/index.ts new file mode 100644 index 0000000..914c10b --- /dev/null +++ b/backend/src/modules/chat/types/index.ts @@ -0,0 +1,97 @@ +import { Document, Types } from 'mongoose'; + +export enum ChatRoomType { + WORKSHOP = 'workshop', + PROJECT = 'project', + TEAM = 'team', + DIRECT = 'direct' +} + +export interface IChatRoomSettings { + allowFileSharing: boolean; + allowAudioMessages: boolean; + maxFileSize: number; +} + +export interface IChatRoom extends Document { + roomType: ChatRoomType; + workshop: Types.ObjectId; + project?: Types.ObjectId; + team?: Types.ObjectId; + participants: Types.ObjectId[]; + name: string; + description?: string; + lastMessage?: Types.ObjectId; + lastMessageAt?: Date; + createdBy: Types.ObjectId; + admins: Types.ObjectId[]; + settings: IChatRoomSettings; + createdAt: Date; + updatedAt: Date; +} + +export enum MessageType { + TEXT = 'text', + AUDIO = 'audio', + IMAGE = 'image', + DOCUMENT = 'document' +} + +export interface IMessageReaction { + user: Types.ObjectId; + emoji: string; + createdAt: Date; +} + +export interface ISeenBy { + user: Types.ObjectId; + seenAt: Date; +} + +export interface IMessage extends Document { + chatRoom: Types.ObjectId; + sender: Types.ObjectId; + messageType: MessageType; + content: string; + fileName?: string; + fileSize?: number; + mimeType?: string; + duration?: number; + seenBy: ISeenBy[]; + replyTo?: Types.ObjectId; + isEdited: boolean; + editedAt?: Date; + isDeleted: boolean; + deletedAt?: Date; + reactions: IMessageReaction[]; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateChatRoomData { + roomType: ChatRoomType; + workshopId: string; + projectId?: string; + teamId?: string; + participants: string[]; + name: string; + description?: string; + createdBy: string; +} + +export interface SendMessageData { + messageType: MessageType; + content: string; + fileName?: string; + fileSize?: number; + mimeType?: string; + duration?: number; + replyTo?: string; +} + +export interface MessageFilters { + search?: string; + messageType?: MessageType; + startDate?: Date; + endDate?: Date; +} diff --git a/backend/src/modules/invitation/controllers/InviteController.ts b/backend/src/modules/invitation/controllers/InviteController.ts new file mode 100644 index 0000000..372f83d --- /dev/null +++ b/backend/src/modules/invitation/controllers/InviteController.ts @@ -0,0 +1,43 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../../shared/types/index'; +import { IInvitationService } from '../interfaces/IInvitationService'; +import { ValidationError } from '../../../shared/utils/errors'; +export class InviteController { + constructor( + private invitationService: IInvitationService + ) { } + + getInviteDetails = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { token } = req.params; + const details = await this.invitationService.getInviteDetails(token); + + res.json({ + success: true, + data: details + }); + } catch (error) { + next(error); + } + }; + + acceptInvite = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { token } = req.params; + const userId = req.user?.id; + + if (!userId) { + throw new ValidationError('Authentication required to accept invitation'); + } + + await this.invitationService.acceptInvite(token, userId); + + res.json({ + success: true, + message: 'You have successfully joined the workshop' + }); + } catch (error) { + next(error); + } + }; +} diff --git a/backend/src/modules/invitation/interfaces/IInvitationRepository.ts b/backend/src/modules/invitation/interfaces/IInvitationRepository.ts new file mode 100644 index 0000000..1fc37c1 --- /dev/null +++ b/backend/src/modules/invitation/interfaces/IInvitationRepository.ts @@ -0,0 +1,7 @@ +import { IInvitation } from '../types/index'; + +export interface IInvitationRepository { + findByToken(token: string): Promise; + findById(id: string): Promise; + markAsUsed(id: string): Promise; +} diff --git a/backend/src/modules/invitation/interfaces/IInvitationService.ts b/backend/src/modules/invitation/interfaces/IInvitationService.ts new file mode 100644 index 0000000..0115fc6 --- /dev/null +++ b/backend/src/modules/invitation/interfaces/IInvitationService.ts @@ -0,0 +1,4 @@ +export interface IInvitationService { + getInviteDetails(token: string): Promise; + acceptInvite(token: string, userId: string): Promise; +} diff --git a/backend/src/models/Invitation.ts b/backend/src/modules/invitation/models/Invitation.ts similarity index 76% rename from backend/src/models/Invitation.ts rename to backend/src/modules/invitation/models/Invitation.ts index 7f0b567..d29f5fb 100644 --- a/backend/src/models/Invitation.ts +++ b/backend/src/modules/invitation/models/Invitation.ts @@ -1,16 +1,5 @@ -import mongoose, { Schema, Document } from 'mongoose'; - -export interface IInvitation extends Document { - token: string; - email: string; - workshop: mongoose.Types.ObjectId; - role?: mongoose.Types.ObjectId; - invitedBy: mongoose.Types.ObjectId; - expiresAt: Date; - isUsed: boolean; - createdAt: Date; - updatedAt: Date; -} +import mongoose, { Schema } from 'mongoose'; +import { IInvitation } from '../types/index'; const invitationSchema = new Schema( { diff --git a/backend/src/modules/invitation/repositories/InvitationRepository.ts b/backend/src/modules/invitation/repositories/InvitationRepository.ts new file mode 100644 index 0000000..4be0ff2 --- /dev/null +++ b/backend/src/modules/invitation/repositories/InvitationRepository.ts @@ -0,0 +1,23 @@ +import { Invitation } from '../models/Invitation'; +import { IInvitation } from '../types/index'; +import { IInvitationRepository } from '../interfaces/IInvitationRepository'; + +export class InvitationRepository implements IInvitationRepository { + async findByToken(token: string): Promise { + return await Invitation.findOne({ + token, + isUsed: false, + expiresAt: { $gt: new Date() } + }) + .populate('workshop', 'name description visibility') + .populate('invitedBy', 'name email'); + } + + async findById(id: string): Promise { + return await Invitation.findById(id); + } + + async markAsUsed(id: string): Promise { + await Invitation.findByIdAndUpdate(id, { isUsed: true }); + } +} diff --git a/backend/src/modules/invitation/routes/inviteRoutes.ts b/backend/src/modules/invitation/routes/inviteRoutes.ts new file mode 100644 index 0000000..1da6518 --- /dev/null +++ b/backend/src/modules/invitation/routes/inviteRoutes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { INVITE_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createInviteRoutes = (container: Container) => { + const router = Router(); + const inviteController = container.inviteCtrl; + + router.get(INVITE_ROUTES.BY_TOKEN, inviteController.getInviteDetails as any); + router.post(INVITE_ROUTES.ACCEPT, authMiddleware as any, inviteController.acceptInvite as any); + + return router; +}; + +export default createInviteRoutes; diff --git a/backend/src/modules/invitation/services/InvitationService.ts b/backend/src/modules/invitation/services/InvitationService.ts new file mode 100644 index 0000000..1ee38bc --- /dev/null +++ b/backend/src/modules/invitation/services/InvitationService.ts @@ -0,0 +1,51 @@ +import { IInvitationRepository } from '../interfaces/IInvitationRepository'; +import { IWorkshopService } from '../../workshop/interfaces/IWorkshopService'; +import { IUserRepository } from '../../user/interfaces/IUserRepository'; +import { IInvitationService } from '../interfaces/IInvitationService'; +import { NotFoundError, ValidationError } from '../../../shared/utils/errors'; + +export class InvitationService implements IInvitationService { + constructor( + private invitationRepo: IInvitationRepository, + private workshopService: IWorkshopService, + private userRepository: IUserRepository + ) { } + + async getInviteDetails(token: string): Promise { + const invitation = await this.invitationRepo.findByToken(token); + if (!invitation) { + throw new NotFoundError('Invitation is invalid or has expired'); + } + + return { + type: 'workshop', + project: { + _id: (invitation.workshop as any)._id, + title: (invitation.workshop as any).name, + description: (invitation.workshop as any).description, + }, + invitedBy: invitation.invitedBy, + email: invitation.email, + expiresAt: invitation.expiresAt + }; + } + + async acceptInvite(token: string, userId: string): Promise { + const invitation = await this.invitationRepo.findByToken(token); + if (!invitation) { + throw new NotFoundError('Invitation is invalid or has expired'); + } + + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found'); + } + + if (user.email.toLowerCase() !== invitation.email.toLowerCase()) { + throw new ValidationError(`This invitation was sent to ${invitation.email}, but you are logged in as ${user.email}`); + } + + await this.workshopService.acceptInvitationByToken(invitation, userId); + await this.invitationRepo.markAsUsed(invitation._id.toString()); + } +} diff --git a/backend/src/modules/invitation/types/index.ts b/backend/src/modules/invitation/types/index.ts new file mode 100644 index 0000000..f92b945 --- /dev/null +++ b/backend/src/modules/invitation/types/index.ts @@ -0,0 +1,14 @@ + +import { Document, Types } from 'mongoose'; + +export interface IInvitation extends Document { + token: string; + email: string; + workshop: Types.ObjectId; + role?: Types.ObjectId; + invitedBy: Types.ObjectId; + expiresAt: Date; + isUsed: boolean; + createdAt: Date; + updatedAt: Date; +} diff --git a/backend/src/controllers/NotificationController.ts b/backend/src/modules/notification/controllers/NotificationController.ts similarity index 86% rename from backend/src/controllers/NotificationController.ts rename to backend/src/modules/notification/controllers/NotificationController.ts index c609eff..7b54734 100644 --- a/backend/src/controllers/NotificationController.ts +++ b/backend/src/modules/notification/controllers/NotificationController.ts @@ -1,12 +1,12 @@ import { Response } from 'express'; -import { NotificationService } from '../services/NotificationService'; -import { AuthRequest } from '../types'; -import { asyncHandler } from '../middlewares/errorMiddleware'; +import { INotificationService } from '../interfaces/INotificationService'; +import { AuthRequest } from '../../../shared/types/index'; +import { asyncHandler } from '../../../shared/middlewares/errorMiddleware'; export class NotificationController { - private notificationService: NotificationService; + private notificationService: INotificationService; - constructor(notificationService: NotificationService) { + constructor(notificationService: INotificationService) { this.notificationService = notificationService; } diff --git a/backend/src/modules/notification/interfaces/INotificationRepository.ts b/backend/src/modules/notification/interfaces/INotificationRepository.ts new file mode 100644 index 0000000..d06db01 --- /dev/null +++ b/backend/src/modules/notification/interfaces/INotificationRepository.ts @@ -0,0 +1,12 @@ +import { INotification } from '../types/index'; + +export interface INotificationRepository { + create(notificationData: Partial): Promise; + findById(id: string): Promise; + findByUserId(userId: string, limit?: number): Promise; + findUnreadByUserId(userId: string): Promise; + markAsRead(id: string): Promise; + markAllAsRead(userId: string): Promise; + delete(id: string): Promise; + getUnreadCount(userId: string): Promise; +} diff --git a/backend/src/modules/notification/interfaces/INotificationService.ts b/backend/src/modules/notification/interfaces/INotificationService.ts new file mode 100644 index 0000000..92c566c --- /dev/null +++ b/backend/src/modules/notification/interfaces/INotificationService.ts @@ -0,0 +1,31 @@ +import { INotification, NotificationType } from '../types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; + +export interface INotificationService { + setSocketService(socketService: ISocketService): void; + createNotification(data: { + userId: string; + type: NotificationType; + title: string; + message: string; + relatedProject?: string; + relatedTask?: string; + relatedUser?: string; + }): Promise; + notifyComment(postOwnerId: string, commenterId: string, postTitle: string): Promise; + notifyJoinRequest(postOwnerId: string, requesterId: string, postTitle: string): Promise; + notifyJoinRequestResponse( + requesterId: string, + postTitle: string, + status: 'approved' | 'rejected', + projectId?: string + ): Promise; + notifyProjectInvite(userId: string, projectTitle: string, projectId: string, inviterId: string): Promise; + notifyTaskAssignment(userId: string, taskTitle: string, taskId: string, projectId: string): Promise; + getUserNotifications(userId: string, limit?: number): Promise; + getUnreadNotifications(userId: string): Promise; + markAsRead(notificationId: string, userId: string): Promise; + markAllAsRead(userId: string): Promise; + getUnreadCount(userId: string): Promise; + deleteNotification(notificationId: string, userId: string): Promise; +} diff --git a/backend/src/models/Notification.ts b/backend/src/modules/notification/models/Notification.ts similarity index 94% rename from backend/src/models/Notification.ts rename to backend/src/modules/notification/models/Notification.ts index e4587c4..7719966 100644 --- a/backend/src/models/Notification.ts +++ b/backend/src/modules/notification/models/Notification.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { INotification, NotificationType } from '../types'; +import { INotification, NotificationType } from '../types/index'; const notificationSchema = new Schema( { diff --git a/backend/src/repositories/NotificationRepository.ts b/backend/src/modules/notification/repositories/NotificationRepository.ts similarity index 90% rename from backend/src/repositories/NotificationRepository.ts rename to backend/src/modules/notification/repositories/NotificationRepository.ts index 46e71ae..5e97b01 100644 --- a/backend/src/repositories/NotificationRepository.ts +++ b/backend/src/modules/notification/repositories/NotificationRepository.ts @@ -1,8 +1,9 @@ import { Notification } from '../models/Notification'; -import { INotification } from '../types'; +import { INotification } from '../types/index'; import { Types } from 'mongoose'; +import { INotificationRepository } from '../interfaces/INotificationRepository'; -export class NotificationRepository { +export class NotificationRepository implements INotificationRepository { async create(notificationData: Partial): Promise { const notification = new Notification(notificationData); return await notification.save(); diff --git a/backend/src/modules/notification/routes/notificationRoutes.ts b/backend/src/modules/notification/routes/notificationRoutes.ts new file mode 100644 index 0000000..72f5f00 --- /dev/null +++ b/backend/src/modules/notification/routes/notificationRoutes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { NOTIFICATION_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createNotificationRoutes = (container: Container) => { + const router = Router(); + const notificationController = container.notificationCtrl; + + router.use(authMiddleware); + + router.get(NOTIFICATION_ROUTES.BASE, notificationController.getNotifications); + router.get(NOTIFICATION_ROUTES.UNREAD, notificationController.getUnreadNotifications); + router.get(NOTIFICATION_ROUTES.COUNT, notificationController.getUnreadCount); + router.put(NOTIFICATION_ROUTES.MARK_READ, notificationController.markAsRead); + router.put(NOTIFICATION_ROUTES.MARK_ALL_READ, notificationController.markAllAsRead); + router.delete(NOTIFICATION_ROUTES.DELETE, notificationController.deleteNotification); + + return router; +}; + +export default createNotificationRoutes; \ No newline at end of file diff --git a/backend/src/services/NotificationService.ts b/backend/src/modules/notification/services/NotificationService.ts similarity index 87% rename from backend/src/services/NotificationService.ts rename to backend/src/modules/notification/services/NotificationService.ts index 89d0ad4..12d9abc 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/modules/notification/services/NotificationService.ts @@ -1,17 +1,16 @@ -import { NotificationRepository } from '../repositories/NotificationRepository'; -import { INotification, NotificationType } from '../types'; -import { SocketService } from './SocketService'; -import { NotFoundError } from '../utils/errors'; - -export class NotificationService { - private notificationRepo: NotificationRepository; - private socketService: SocketService | null = null; - - constructor() { - this.notificationRepo = new NotificationRepository(); - } - - setSocketService(socketService: SocketService): void { +import { INotificationRepository } from '../interfaces/INotificationRepository'; +import { INotification, NotificationType } from '../types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; +import { INotificationService } from '../interfaces/INotificationService'; +import { NotFoundError } from '../../../shared/utils/errors'; + +export class NotificationService implements INotificationService { + constructor( + private notificationRepo: INotificationRepository, + private socketService: ISocketService | null = null + ) { } + + setSocketService(socketService: ISocketService): void { this.socketService = socketService; } diff --git a/backend/src/modules/notification/types/index.ts b/backend/src/modules/notification/types/index.ts new file mode 100644 index 0000000..12247c3 --- /dev/null +++ b/backend/src/modules/notification/types/index.ts @@ -0,0 +1,30 @@ + +import { Document, Types } from 'mongoose'; + +export enum NotificationType { + TASK_ASSIGNED = 'task_assigned', + TASK_UPDATED = 'task_updated', + MESSAGE = 'message', + PROJECT_INVITE = 'project_invite', + PROJECT_ASSIGNED = 'project_assigned', + JOIN_REQUEST = 'join_request', + COMMENT = 'comment', + WORKSHOP_INVITE = 'workshop_invite', + TEAM_ASSIGNED = 'team_assigned', + ROLE_ASSIGNED = 'role_assigned', + MEMBERSHIP_APPROVED = 'membership_approved', + MEMBERSHIP_REJECTED = 'membership_rejected' +} + +export interface INotification extends Document { + user: Types.ObjectId; + type: NotificationType; + title: string; + message: string; + relatedProject?: Types.ObjectId; + relatedWorkshop?: Types.ObjectId; + relatedTask?: Types.ObjectId; + relatedUser?: Types.ObjectId; + isRead: boolean; + createdAt: Date; +} diff --git a/backend/src/controllers/WorkshopProjectController.ts b/backend/src/modules/project/controllers/WorkshopProjectController.ts similarity index 94% rename from backend/src/controllers/WorkshopProjectController.ts rename to backend/src/modules/project/controllers/WorkshopProjectController.ts index 3ec51bd..ad6df08 100644 --- a/backend/src/controllers/WorkshopProjectController.ts +++ b/backend/src/modules/project/controllers/WorkshopProjectController.ts @@ -1,18 +1,16 @@ import { Response, NextFunction } from 'express'; -import { WorkshopProjectService } from '../services/WorkshopProjectService'; -import { AuthRequest } from '../types'; -import { SocketService } from '../services/SocketService'; +import { IWorkshopProjectService } from '../interfaces/IWorkshopProjectService'; +import { AuthRequest } from '../../../shared/types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; export class WorkshopProjectController { - private projectService: WorkshopProjectService; - private socketService: SocketService | null = null; + private socketService: ISocketService | null = null; - constructor(projectService?: WorkshopProjectService) { - this.projectService = projectService || new WorkshopProjectService(); - } + constructor(private projectService: IWorkshopProjectService) { } - setSocketService(socketService: SocketService): void { + setSocketService(socketService: ISocketService): void { this.socketService = socketService; + (this.projectService as any).setSocketService?.(socketService); } createProject = async (req: AuthRequest, res: Response, next: NextFunction): Promise => { diff --git a/backend/src/modules/project/interfaces/IWorkshopProjectRepository.ts b/backend/src/modules/project/interfaces/IWorkshopProjectRepository.ts new file mode 100644 index 0000000..b93a521 --- /dev/null +++ b/backend/src/modules/project/interfaces/IWorkshopProjectRepository.ts @@ -0,0 +1,24 @@ +import { IWorkshopProject, CreateWorkshopProjectDTO, UpdateWorkshopProjectDTO } from '../types/index'; + +export interface IWorkshopProjectRepository { + create(workshopId: string, projectData: CreateWorkshopProjectDTO): Promise; + findById(id: string): Promise; + findByWorkshop(workshopId: string): Promise; + update(id: string, updates: UpdateWorkshopProjectDTO): Promise; + delete(id: string): Promise; + deleteByWorkshop(workshopId: string): Promise; + assignTeam(projectId: string, teamId: string): Promise; + removeTeam(projectId: string, teamId: string): Promise; + assignIndividual(projectId: string, userId: string): Promise; + removeIndividual(projectId: string, userId: string): Promise; + assignProjectManager(projectId: string, userId: string): Promise; + removeProjectManager(projectId: string): Promise; + addMaintainer(projectId: string, userId: string): Promise; + removeMaintainer(projectId: string, userId: string): Promise; + isUserAssigned(projectId: string, userId: string): Promise; + addTeam(projectId: string, teamId: string): Promise; + addIndividual(projectId: string, userId: string): Promise; + findAccessibleByUser(workshopId: string, userId: string, teamIds: string[]): Promise; + findAccessible(userId: string, workshopId: string, teamIds?: string[]): Promise; + countByWorkshop(workshopId: string): Promise; +} diff --git a/backend/src/modules/project/interfaces/IWorkshopProjectService.ts b/backend/src/modules/project/interfaces/IWorkshopProjectService.ts new file mode 100644 index 0000000..b997413 --- /dev/null +++ b/backend/src/modules/project/interfaces/IWorkshopProjectService.ts @@ -0,0 +1,20 @@ +import { IWorkshopProject, CreateWorkshopProjectDTO, UpdateWorkshopProjectDTO } from '../types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; + +export interface IWorkshopProjectService { + setSocketService(socketService: ISocketService): void; + createProject(workshopId: string, actorId: string, data: CreateWorkshopProjectDTO): Promise; + getProject(projectId: string): Promise; + getWorkshopProjects(workshopId: string): Promise; + getUserAccessibleProjects(workshopId: string, userId: string): Promise; + updateProject(projectId: string, actorId: string, updates: UpdateWorkshopProjectDTO): Promise; + deleteProject(projectId: string, actorId: string): Promise; + assignTeamToProject(projectId: string, actorId: string, teamId: string): Promise; + removeTeamFromProject(projectId: string, actorId: string, teamId: string): Promise; + assignIndividualToProject(projectId: string, actorId: string, userId: string): Promise; + removeIndividualFromProject(projectId: string, actorId: string, userId: string): Promise; + assignProjectManager(projectId: string, actorId: string, userId: string): Promise; + addMaintainer(projectId: string, actorId: string, userId: string): Promise; + removeMaintainer(projectId: string, actorId: string, userId: string): Promise; + hasAccess(projectId: string, userId: string): Promise; +} diff --git a/backend/src/models/WorkshopProject.ts b/backend/src/modules/project/models/WorkshopProject.ts similarity index 99% rename from backend/src/models/WorkshopProject.ts rename to backend/src/modules/project/models/WorkshopProject.ts index ea4df23..81034d4 100644 --- a/backend/src/models/WorkshopProject.ts +++ b/backend/src/modules/project/models/WorkshopProject.ts @@ -6,7 +6,7 @@ import { IWorkflowTransition, DEFAULT_PROJECT_SETTINGS, DEFAULT_TASK_WORKFLOW -} from '../types'; +} from '../types/index'; const workflowTransitionSchema = new Schema( { diff --git a/backend/src/repositories/WorkshopProjectRepository.ts b/backend/src/modules/project/repositories/WorkshopProjectRepository.ts similarity index 89% rename from backend/src/repositories/WorkshopProjectRepository.ts rename to backend/src/modules/project/repositories/WorkshopProjectRepository.ts index 61a4d86..d62e9f2 100644 --- a/backend/src/repositories/WorkshopProjectRepository.ts +++ b/backend/src/modules/project/repositories/WorkshopProjectRepository.ts @@ -1,9 +1,10 @@ import { WorkshopProject } from '../models/WorkshopProject'; -import { IWorkshopProject, CreateWorkshopProjectDTO, UpdateWorkshopProjectDTO } from '../types'; +import { IWorkshopProject, CreateWorkshopProjectDTO, UpdateWorkshopProjectDTO } from '../types/index'; import { Types } from 'mongoose'; -import { NotFoundError } from '../utils/errors'; +import { NotFoundError } from '../../../shared/utils/errors'; +import { IWorkshopProjectRepository } from '../interfaces/IWorkshopProjectRepository'; -export class WorkshopProjectRepository { +export class WorkshopProjectRepository implements IWorkshopProjectRepository { private readonly populateAssignedTeams = { path: 'assignedTeams', select: 'name description members' }; private readonly populateAssignedIndividuals = { path: 'assignedIndividuals', select: 'name email profilePhoto' }; private readonly populateProjectManager = { path: 'projectManager', select: 'name email profilePhoto' }; @@ -242,6 +243,27 @@ export class WorkshopProjectRepository { .sort({ updatedAt: -1 }); } + async findAccessible(userId: string, workshopId: string, teamIds: string[] = []): Promise { + const uId = new Types.ObjectId(userId); + const wId = new Types.ObjectId(workshopId); + const tIds = teamIds.map(id => new Types.ObjectId(id)); + + return await WorkshopProject.find({ + workshop: wId, + $or: [ + { assignedIndividuals: uId }, + { projectManager: uId }, + { maintainers: uId }, + { assignedTeams: { $in: tIds } } + ] + }) + .populate(this.populateAssignedTeams) + .populate(this.populateAssignedIndividuals) + .populate(this.populateProjectManager) + .populate(this.populateMaintainers) + .sort({ updatedAt: -1 }); + } + async countByWorkshop(workshopId: string): Promise { return await WorkshopProject.countDocuments({ workshop: new Types.ObjectId(workshopId) }); } diff --git a/backend/src/modules/project/routes/workshopProjectRoutes.ts b/backend/src/modules/project/routes/workshopProjectRoutes.ts new file mode 100644 index 0000000..f8cc0a4 --- /dev/null +++ b/backend/src/modules/project/routes/workshopProjectRoutes.ts @@ -0,0 +1,29 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { PROJECT_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createWorkshopProjectRoutes = (container: Container) => { + const router = Router({ mergeParams: true }); + const projectController = container.workshopProjectCtrl; + + router.use(authMiddleware); + + router.post(PROJECT_ROUTES.BASE, projectController.createProject); + router.get(PROJECT_ROUTES.BASE, projectController.getProjects); + router.get(PROJECT_ROUTES.ACCESSIBLE, projectController.getAccessibleProjects); + router.get(PROJECT_ROUTES.BY_ID, projectController.getProject); + router.put(PROJECT_ROUTES.BY_ID, projectController.updateProject); + router.delete(PROJECT_ROUTES.BY_ID, projectController.deleteProject); + router.post(PROJECT_ROUTES.TEAMS, projectController.assignTeam); + router.delete(PROJECT_ROUTES.TEAM_BY_ID, projectController.removeTeam); + router.post(PROJECT_ROUTES.INDIVIDUALS, projectController.assignIndividual); + router.delete(PROJECT_ROUTES.INDIVIDUAL_BY_ID, projectController.removeIndividual); + router.post(PROJECT_ROUTES.MANAGER, projectController.assignProjectManager); + router.post(PROJECT_ROUTES.MAINTAINERS, projectController.addMaintainer); + router.delete(PROJECT_ROUTES.MAINTAINER_BY_ID, projectController.removeMaintainer); + + return router; +}; + +export default createWorkshopProjectRoutes; \ No newline at end of file diff --git a/backend/src/services/WorkshopProjectService.ts b/backend/src/modules/project/services/WorkshopProjectService.ts similarity index 88% rename from backend/src/services/WorkshopProjectService.ts rename to backend/src/modules/project/services/WorkshopProjectService.ts index f3daaef..3cfc5f6 100644 --- a/backend/src/services/WorkshopProjectService.ts +++ b/backend/src/modules/project/services/WorkshopProjectService.ts @@ -1,18 +1,15 @@ import { Types } from 'mongoose'; -import { - IWorkshopProject, - CreateWorkshopProjectDTO, - UpdateWorkshopProjectDTO -} from '../types'; -import { WorkshopProjectRepository } from '../repositories/WorkshopProjectRepository'; -import { WorkshopRepository } from '../repositories/WorkshopRepository'; -import { TeamRepository } from '../repositories/TeamRepository'; -import { AuditService } from './AuditService'; -import { PermissionService } from './PermissionService'; -import { NotFoundError, AuthorizationError } from '../utils/errors'; -import { AuditAction } from '../types'; -import { ChatService } from './ChatService'; -import { SocketService } from './SocketService'; +import { IWorkshopProject, CreateWorkshopProjectDTO, UpdateWorkshopProjectDTO } from '../types/index'; +import { AuditAction } from '../../audit/types/index'; +import { IWorkshopProjectRepository } from '../interfaces/IWorkshopProjectRepository'; +import { IWorkshopRepository } from '../../workshop/interfaces/IWorkshopRepository'; +import { ITeamRepository } from '../../team/interfaces/ITeamRepository'; +import { IAuditService } from '../../audit/interfaces/IAuditService'; +import { IPermissionService } from '../../access-control/interfaces/IPermissionService'; +import { IChatService } from '../../chat/interfaces/IChatService'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; +import { IWorkshopProjectService } from '../interfaces/IWorkshopProjectService'; +import { NotFoundError, AuthorizationError } from '../../../shared/utils/errors'; function getIdString(ref: Types.ObjectId | { _id: Types.ObjectId } | any): string { if (ref && typeof ref === 'object' && '_id' in ref) { @@ -21,25 +18,18 @@ function getIdString(ref: Types.ObjectId | { _id: Types.ObjectId } | any): strin return ref?.toString() || ''; } -export class WorkshopProjectService { - private projectRepository: WorkshopProjectRepository; - private workshopRepository: WorkshopRepository; - private teamRepository: TeamRepository; - private auditService: AuditService; - private permissionService: PermissionService; - private chatService: ChatService; - private socketService?: SocketService; - - constructor() { - this.projectRepository = new WorkshopProjectRepository(); - this.workshopRepository = new WorkshopRepository(); - this.teamRepository = new TeamRepository(); - this.auditService = new AuditService(); - this.permissionService = PermissionService.getInstance(); - this.chatService = new ChatService(); - } - - setSocketService(socketService: SocketService): void { +export class WorkshopProjectService implements IWorkshopProjectService { + constructor( + private projectRepository: IWorkshopProjectRepository, + private workshopRepository: IWorkshopRepository, + private teamRepository: ITeamRepository, + private auditService: IAuditService, + private permissionService: IPermissionService, + private chatService: IChatService, + private socketService: ISocketService | null = null + ) { } + + setSocketService(socketService: ISocketService): void { this.socketService = socketService; this.chatService.setSocketService(socketService); } diff --git a/backend/src/modules/project/types/index.ts b/backend/src/modules/project/types/index.ts new file mode 100644 index 0000000..8d9aa90 --- /dev/null +++ b/backend/src/modules/project/types/index.ts @@ -0,0 +1,76 @@ + +import { Document, Types } from 'mongoose'; + +export interface IProjectSettings { + isPublic: boolean; + allowComments: boolean; + allowExternalContribution: boolean; + taskWorkflow: ITaskWorkflow; +} + +export const DEFAULT_TASK_WORKFLOW: ITaskWorkflow = { + statuses: ['To Do', 'In Progress', 'In Review', 'Done'], + transitions: [ + { from: 'To Do', to: 'In Progress' }, + { from: 'In Progress', to: 'In Review' }, + { from: 'In Review', to: 'Done' }, + { from: 'In Review', to: 'In Progress' } + ] +}; + +export const DEFAULT_PROJECT_SETTINGS: IProjectSettings = { + isPublic: true, + allowComments: true, + allowExternalContribution: false, + taskWorkflow: DEFAULT_TASK_WORKFLOW +}; + +export interface ITaskWorkflow { + statuses: string[]; + transitions: IWorkflowTransition[]; +} + +export interface IWorkflowTransition { + from: string; + to: string; + conditions?: any; + allowedRoles?: string[]; +} + +export interface IWorkshopProject extends Document { + title: string; + name?: string; + description: string; + workshop: Types.ObjectId; + owner: Types.ObjectId; + team?: Types.ObjectId; + status: string; + settings: IProjectSettings; + workflow: ITaskWorkflow; + tasks: Types.ObjectId[]; + + projectManager?: Types.ObjectId; + maintainers?: Types.ObjectId[]; + assignedIndividuals: Types.ObjectId[]; + assignedTeams: Types.ObjectId[]; + + createdAt: Date; + updatedAt: Date; +} + +export interface CreateWorkshopProjectDTO { + title?: string; + name: string; + description: string; + teamId?: string; + allowedRoles?: string[]; + allowExternalContribution?: boolean; + settings?: IProjectSettings; +} + +export interface UpdateWorkshopProjectDTO { + title?: string; + description?: string; + status?: string; + settings?: IProjectSettings; +} diff --git a/backend/src/controllers/WorkshopTaskController.ts b/backend/src/modules/task/controllers/WorkshopTaskController.ts similarity index 94% rename from backend/src/controllers/WorkshopTaskController.ts rename to backend/src/modules/task/controllers/WorkshopTaskController.ts index 274e20b..d764d58 100644 --- a/backend/src/controllers/WorkshopTaskController.ts +++ b/backend/src/modules/task/controllers/WorkshopTaskController.ts @@ -1,17 +1,13 @@ import { Response, NextFunction } from 'express'; -import { WorkshopTaskService } from '../services/WorkshopTaskService'; -import { AuthRequest } from '../types'; -import { SocketService } from '../services/SocketService'; +import { IWorkshopTaskService } from '../interfaces/IWorkshopTaskService'; +import { AuthRequest } from '../../../shared/types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; export class WorkshopTaskController { - private taskService: WorkshopTaskService; + constructor(private taskService: IWorkshopTaskService) { } - constructor() { - this.taskService = new WorkshopTaskService(); - } - - setSocketService(socketService: SocketService): void { - this.taskService.setSocketService(socketService); + setSocketService(socketService: ISocketService): void { + (this.taskService as any).setSocketService?.(socketService); } createTask = async (req: AuthRequest, res: Response, next: NextFunction): Promise => { diff --git a/backend/src/modules/task/interfaces/IWorkshopTaskRepository.ts b/backend/src/modules/task/interfaces/IWorkshopTaskRepository.ts new file mode 100644 index 0000000..842290e --- /dev/null +++ b/backend/src/modules/task/interfaces/IWorkshopTaskRepository.ts @@ -0,0 +1,24 @@ +import { IWorkshopTask, CreateWorkshopTaskDTO, UpdateWorkshopTaskDTO, ITaskAttachment } from '../types/index'; + +export interface TasksByStatus { + [status: string]: IWorkshopTask[]; +} + +export interface IWorkshopTaskRepository { + create(workshopId: string, projectId: string, taskData: CreateWorkshopTaskDTO, createdBy: string): Promise; + findById(id: string): Promise; + findByProject(projectId: string): Promise; + findByProjectGroupedByStatus(projectId: string): Promise; + update(id: string, updates: UpdateWorkshopTaskDTO, updatedBy: string): Promise; + updateStatus(id: string, status: string, updatedBy: string): Promise; + addComment(taskId: string, userId: string, content: string, mentions?: string[]): Promise; + addAttachment(taskId: string, userId: string, fileData: Omit): Promise; + assignTeam(taskId: string, teamId: string, assignedBy: string): Promise; + assignIndividual(taskId: string, userId: string, assignedBy: string): Promise; + delete(id: string, deletedBy: string): Promise; + deleteByProject(projectId: string): Promise; + findByAssignedUser(userId: string): Promise; + findByAssignedTeam(teamId: string): Promise; + countByProject(projectId: string): Promise; + countByStatus(projectId: string, status: string): Promise; +} diff --git a/backend/src/modules/task/interfaces/IWorkshopTaskService.ts b/backend/src/modules/task/interfaces/IWorkshopTaskService.ts new file mode 100644 index 0000000..2450995 --- /dev/null +++ b/backend/src/modules/task/interfaces/IWorkshopTaskService.ts @@ -0,0 +1,21 @@ +import { IWorkshopTask, CreateWorkshopTaskDTO, UpdateWorkshopTaskDTO } from '../types/index'; +import { TasksByStatus } from './IWorkshopTaskRepository'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; + +export interface IWorkshopTaskService { + setSocketService(socketService: ISocketService): void; + createTask(projectId: string, userId: string, data: CreateWorkshopTaskDTO): Promise; + getTaskById(taskId: string, userId: string): Promise; + getTaskActivities(taskId: string, userId: string): Promise; + getProjectTasks(projectId: string, userId: string): Promise; + getProjectTaskBoard(projectId: string, userId: string): Promise; + updateTask(taskId: string, userId: string, updates: UpdateWorkshopTaskDTO): Promise; + updateTaskStatus(taskId: string, userId: string, newStatus: string): Promise; + assignTeamToTask(taskId: string, userId: string, teamId: string): Promise; + assignIndividualToTask(taskId: string, userId: string, assigneeId: string): Promise; + deleteTask(taskId: string, userId: string): Promise; + getUserTasks(userId: string): Promise; + getTeamTasks(teamId: string, userId: string): Promise; + addComment(taskId: string, userId: string, content: string, mentions?: string[]): Promise; + addAttachment(taskId: string, userId: string, fileData: { fileName: string; fileUrl: string; fileType: string; fileSize: number }): Promise; +} diff --git a/backend/src/models/WorkshopTask.ts b/backend/src/modules/task/models/WorkshopTask.ts similarity index 99% rename from backend/src/models/WorkshopTask.ts rename to backend/src/modules/task/models/WorkshopTask.ts index 87b1ce0..6589d8f 100644 --- a/backend/src/models/WorkshopTask.ts +++ b/backend/src/modules/task/models/WorkshopTask.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { IWorkshopTask, ITaskActivity, TaskType, ITaskComment, ITaskStatusHistory, ITaskAttachment } from '../types'; +import { IWorkshopTask, ITaskActivity, TaskType, ITaskComment, ITaskStatusHistory, ITaskAttachment } from '../types/index'; const taskActivitySchema = new Schema( { diff --git a/backend/src/repositories/WorkshopTaskRepository.ts b/backend/src/modules/task/repositories/WorkshopTaskRepository.ts similarity index 97% rename from backend/src/repositories/WorkshopTaskRepository.ts rename to backend/src/modules/task/repositories/WorkshopTaskRepository.ts index add6d86..587e67e 100644 --- a/backend/src/repositories/WorkshopTaskRepository.ts +++ b/backend/src/modules/task/repositories/WorkshopTaskRepository.ts @@ -1,13 +1,10 @@ import { WorkshopTask } from '../models/WorkshopTask'; -import { IWorkshopTask, CreateWorkshopTaskDTO, UpdateWorkshopTaskDTO, ITaskAttachment } from '../types'; +import { IWorkshopTask, CreateWorkshopTaskDTO, UpdateWorkshopTaskDTO, ITaskAttachment } from '../types/index'; import { Types } from 'mongoose'; -import { NotFoundError } from '../utils/errors'; +import { NotFoundError } from '../../../shared/utils/errors'; +import { IWorkshopTaskRepository, TasksByStatus } from '../interfaces/IWorkshopTaskRepository'; -export interface TasksByStatus { - [status: string]: IWorkshopTask[]; -} - -export class WorkshopTaskRepository { +export class WorkshopTaskRepository implements IWorkshopTaskRepository { private readonly populateAssignedTeams = { path: 'assignedTeams', select: 'name description' }; private readonly populatePeople = [ { path: 'primaryOwner', select: 'name email profilePhoto' }, diff --git a/backend/src/modules/task/routes/workshopTaskRoutes.ts b/backend/src/modules/task/routes/workshopTaskRoutes.ts new file mode 100644 index 0000000..c61d109 --- /dev/null +++ b/backend/src/modules/task/routes/workshopTaskRoutes.ts @@ -0,0 +1,50 @@ +import { Router } from 'express'; +import { authMiddleware } from '@middlewares'; +import { TASK_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createWorkshopTaskRoutes = (container: Container) => { + const router = Router({ mergeParams: true }); + const taskController = container.workshopTaskCtrl; + + router.use(authMiddleware); + + router.post(TASK_ROUTES.BASE, taskController.createTask); + router.get(TASK_ROUTES.BASE, taskController.getProjectTasks); + router.get(TASK_ROUTES.BOARD, taskController.getProjectTaskBoard); + router.get(TASK_ROUTES.BY_ID, taskController.getTask); + router.put(TASK_ROUTES.BY_ID, taskController.updateTask); + router.delete(TASK_ROUTES.BY_ID, taskController.deleteTask); + router.put(TASK_ROUTES.STATUS, taskController.updateTaskStatus); + router.post(TASK_ROUTES.COMMENTS, taskController.addComment); + router.post(TASK_ROUTES.ATTACHMENTS, taskController.addAttachment); + router.post(TASK_ROUTES.TEAMS, taskController.assignTeam); + router.post(TASK_ROUTES.INDIVIDUALS, taskController.assignIndividual); + router.get(TASK_ROUTES.ACTIVITY, taskController.getTaskActivities); + + return router; +}; + +export const createTaskRouter = (_container: Container) => { + const router = Router({ mergeParams: true }); + router.use(authMiddleware); + return router; +}; + +export const createUserTaskRouter = (container: Container) => { + const router = Router(); + const taskController = container.workshopTaskCtrl; + router.use(authMiddleware); + router.get(TASK_ROUTES.MY_TASKS, taskController.getMyTasks); + return router; +}; + +export const createTeamTaskRouter = (container: Container) => { + const router = Router(); + const taskController = container.workshopTaskCtrl; + router.use(authMiddleware); + router.get(TASK_ROUTES.TEAM_TASKS, taskController.getTeamTasks); + return router; +}; + +export default createWorkshopTaskRoutes; \ No newline at end of file diff --git a/backend/src/services/WorkshopTaskService.ts b/backend/src/modules/task/services/WorkshopTaskService.ts similarity index 93% rename from backend/src/services/WorkshopTaskService.ts rename to backend/src/modules/task/services/WorkshopTaskService.ts index eb89155..9b8b7ed 100644 --- a/backend/src/services/WorkshopTaskService.ts +++ b/backend/src/modules/task/services/WorkshopTaskService.ts @@ -1,35 +1,31 @@ -import { WorkshopTaskRepository, TasksByStatus } from '../repositories/WorkshopTaskRepository'; -import { WorkshopProjectRepository } from '../repositories/WorkshopProjectRepository'; -import { MembershipRepository } from '../repositories/MembershipRepository'; -import { TeamRepository } from '../repositories/TeamRepository'; -import { NotificationRepository } from '../repositories/NotificationRepository'; -import { IWorkshopTask, IMembership, CreateWorkshopTaskDTO, UpdateWorkshopTaskDTO, NotificationType, AuditAction } from '../types'; -import { NotFoundError, AuthorizationError, ValidationError } from '../utils/errors'; -import { SocketService } from './SocketService'; -import { AuditService } from './AuditService'; -import { PermissionService } from './PermissionService'; - -export class WorkshopTaskService { - private taskRepo: WorkshopTaskRepository; - private projectRepo: WorkshopProjectRepository; - private membershipRepo: MembershipRepository; - private teamRepo: TeamRepository; - private notificationRepo: NotificationRepository; - private auditService: AuditService; - private permissionService: PermissionService; - private socketService: SocketService | null = null; - - constructor() { - this.taskRepo = new WorkshopTaskRepository(); - this.projectRepo = new WorkshopProjectRepository(); - this.membershipRepo = new MembershipRepository(); - this.teamRepo = new TeamRepository(); - this.notificationRepo = new NotificationRepository(); - this.auditService = new AuditService(); - this.permissionService = PermissionService.getInstance(); - } - - setSocketService(socketService: SocketService): void { +import { IWorkshopTaskRepository, TasksByStatus } from '../interfaces/IWorkshopTaskRepository'; +import { IWorkshopProjectRepository } from '../../project/interfaces/IWorkshopProjectRepository'; +import { IMembershipRepository } from '../../team/interfaces/IMembershipRepository'; +import { ITeamRepository } from '../../team/interfaces/ITeamRepository'; +import { INotificationRepository } from '../../notification/interfaces/INotificationRepository'; +import { IWorkshopTask, CreateWorkshopTaskDTO, UpdateWorkshopTaskDTO } from '../types/index'; +import { IMembership } from '../../team/types/index'; +import { NotificationType } from '../../notification/types/index'; +import { AuditAction } from '../../audit/types/index'; +import { NotFoundError, AuthorizationError, ValidationError } from '../../../shared/utils/errors'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; +import { IAuditService } from '../../audit/interfaces/IAuditService'; +import { IPermissionService } from '../../access-control/interfaces/IPermissionService'; +import { IWorkshopTaskService } from '../interfaces/IWorkshopTaskService'; + +export class WorkshopTaskService implements IWorkshopTaskService { + constructor( + private taskRepo: IWorkshopTaskRepository, + private projectRepo: IWorkshopProjectRepository, + private membershipRepo: IMembershipRepository, + private teamRepo: ITeamRepository, + private notificationRepo: INotificationRepository, + private auditService: IAuditService, + private permissionService: IPermissionService, + private socketService: ISocketService | null = null + ) { } + + setSocketService(socketService: ISocketService): void { this.socketService = socketService; } @@ -38,7 +34,6 @@ export class WorkshopTaskService { userId: string, data: CreateWorkshopTaskDTO ): Promise { - const project = await this.projectRepo.findById(projectId); if (!project) { throw new NotFoundError('Project'); @@ -298,7 +293,7 @@ export class WorkshopTaskService { } const oldPrimaryOwner = task.primaryOwner?.toString(); - const oldContributors = new Set(task.contributors?.map(c => typeof c === 'string' ? c : (c as any)._id?.toString() || c.toString()) || []); + const oldContributors = new Set(task.contributors?.map((c: any) => typeof c === 'string' ? c : (c as any)._id?.toString() || c.toString()) || []); const updatedTask = await this.taskRepo.update(taskId, updates, userId); @@ -650,7 +645,6 @@ export class WorkshopTaskService { if (mentions.length > 0) { for (const mentionedId of mentions) { - if (mentionedId === userId) continue; const isMember = await this.membershipRepo.isActiveMember(workshopId, mentionedId); diff --git a/backend/src/modules/task/types/index.ts b/backend/src/modules/task/types/index.ts new file mode 100644 index 0000000..38abf9e --- /dev/null +++ b/backend/src/modules/task/types/index.ts @@ -0,0 +1,156 @@ + +import { Document, Types } from 'mongoose'; + +export enum TaskType { + FEATURE = 'feature', + BUG = 'bug', + IMPROVEMENT = 'improvement', + TASK = 'task' +} + +export interface ITaskComment { + user: Types.ObjectId; + content: string; + createdAt: Date; + updatedAt?: Date; + mentions?: string[]; + isEdited?: boolean; +} + +export interface ITaskStatusHistory { + from: string; + to: string; + status?: string; + changedBy: Types.ObjectId; + changedAt: Date; + comment?: string; + duration?: number; +} + +export interface ITaskAttachment { + name?: string; + url?: string; + type?: string; + size?: number; + + fileName?: string; + fileUrl?: string; + fileType?: string; + fileSize?: number; + + uploadedBy: Types.ObjectId; + uploadedAt: Date; +} + +export interface ITaskActivity { + action: string; + actor: Types.ObjectId; + user?: Types.ObjectId; + timestamp: Date; + details?: any; + changes?: any; +} + +export interface IWorkshopTask extends Document { + title: string; + description: string; + project: Types.ObjectId; + workshop: Types.ObjectId; + + parentTask?: Types.ObjectId; + childTasks: Types.ObjectId[]; + + assignees: Types.ObjectId[]; + reporter: Types.ObjectId; + primaryOwner?: Types.ObjectId; + contributors: Types.ObjectId[]; + watchers: Types.ObjectId[]; + assignedTeams: Types.ObjectId[]; + assignedIndividuals: Types.ObjectId[]; + + status: string; + priority: number; + severity: number; + type: TaskType; + + blockedBy: Types.ObjectId[]; + blocking: Types.ObjectId[]; + dependencies: Types.ObjectId[]; + + labels: string[]; + tags: string[]; + + estimatedHours?: number; + actualHours?: number; + startDate?: Date; + + comments: ITaskComment[]; + attachments: ITaskAttachment[]; + activity?: ITaskActivity[]; + activityHistory: ITaskActivity[]; + statusHistory: ITaskStatusHistory[]; + + linkedResources?: { + chatRooms: Types.ObjectId[]; + documents: Types.ObjectId[]; + relatedTasks: Types.ObjectId[]; + }; + + isRecurring?: boolean; + recurrencePattern?: { + frequency: string; + interval: number; + daysOfWeek?: number[]; + dayOfMonth?: number; + endDate?: Date; + occurrences?: number; + }; + autoAssignmentRules?: any; + customFields?: any; + createdBy: Types.ObjectId; + + dueDate?: Date; + completedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateWorkshopTaskDTO { + title: string; + description: string; + projectId: string; + + parentTask?: string; + + assignees?: string[]; + assignedTeams?: string[]; + assignedIndividuals?: string[]; + + primaryOwner?: string; + contributors?: string[]; + watchers?: string[]; + + priority?: number; + type?: TaskType; + dueDate?: Date; +} + +export interface UpdateWorkshopTaskDTO { + title?: string; + description?: string; + status?: string; + priority?: number; + + assignees?: string[]; + assignedTeams?: string[]; + assignedIndividuals?: string[]; + + primaryOwner?: string; + contributors?: string[]; + watchers?: string[]; + + blockedBy?: string[]; + blocking?: string[]; + + dueDate?: Date; +} diff --git a/backend/src/controllers/TeamController.ts b/backend/src/modules/team/controllers/TeamController.ts similarity index 94% rename from backend/src/controllers/TeamController.ts rename to backend/src/modules/team/controllers/TeamController.ts index dc85f97..aeeecc0 100644 --- a/backend/src/controllers/TeamController.ts +++ b/backend/src/modules/team/controllers/TeamController.ts @@ -1,17 +1,14 @@ import { Response, NextFunction } from 'express'; -import { TeamService } from '../services/TeamService'; -import { AuthRequest } from '../types'; -import { SocketService } from '../services/SocketService'; +import { ITeamService } from '../interfaces/ITeamService'; +import { AuthRequest } from '../../../shared/types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; export class TeamController { - private teamService: TeamService; - private socketService: SocketService | null = null; + private socketService: ISocketService | null = null; - constructor() { - this.teamService = new TeamService(); - } + constructor(private teamService: ITeamService) { } - setSocketService(socketService: SocketService): void { + setSocketService(socketService: ISocketService): void { this.socketService = socketService; this.teamService.setSocketService(socketService); } diff --git a/backend/src/modules/team/interfaces/IMembershipRepository.ts b/backend/src/modules/team/interfaces/IMembershipRepository.ts new file mode 100644 index 0000000..95576f6 --- /dev/null +++ b/backend/src/modules/team/interfaces/IMembershipRepository.ts @@ -0,0 +1,22 @@ +import { IMembership, MembershipState, CreateMembershipDTO } from '../types/index'; + +export interface IMembershipRepository { + create(membershipData: CreateMembershipDTO): Promise; + findById(id: string): Promise; + findByIdRaw(id: string): Promise; + findByWorkshop(workshopId: string, state?: MembershipState): Promise; + findByUser(userId: string, state?: MembershipState): Promise; + findByWorkshopAndUser(workshopId: string, userId: string): Promise; + findActive(workshopId: string, userId: string): Promise; + findPendingByWorkshop(workshopId: string): Promise; + updateState(membershipId: string, newState: MembershipState, actorId?: string): Promise; + isActiveMember(workshopId: string, userId: string): Promise; + countByWorkshop(workshopId: string, state?: MembershipState): Promise; + countByUser(userId: string, state?: MembershipState): Promise; + deleteByWorkshop(workshopId: string): Promise; + deleteByUser(workshopId: string, userId: string): Promise; + getActiveMembers(workshopId: string): Promise; + getPendingInvitations(workshopId: string): Promise; + getPendingJoinRequests(workshopId: string): Promise; + delete(membershipId: string): Promise; +} diff --git a/backend/src/modules/team/interfaces/ITeamRepository.ts b/backend/src/modules/team/interfaces/ITeamRepository.ts new file mode 100644 index 0000000..b5299b7 --- /dev/null +++ b/backend/src/modules/team/interfaces/ITeamRepository.ts @@ -0,0 +1,20 @@ +import { ITeam, CreateTeamDTO, UpdateTeamDTO } from '../types/index'; + +export interface ITeamRepository { + create(workshopId: string, teamData: CreateTeamDTO): Promise; + findById(id: string): Promise; + findByIds(ids: string[]): Promise; + findByWorkshop(workshopId: string): Promise; + findByMemberInWorkshop(workshopId: string, userId: string): Promise; + update(id: string, updates: UpdateTeamDTO): Promise; + delete(id: string): Promise; + deleteByWorkshop(workshopId: string): Promise; + addMember(teamId: string, userId: string): Promise; + removeMember(teamId: string, userId: string): Promise; + isMember(teamId: string, userId: string): Promise; + countMembers(teamId: string): Promise; + assignInternalRole(teamId: string, roleName: string, userId: string): Promise; + removeInternalRole(teamId: string, roleName: string, userId: string): Promise; + getUserInternalRoles(teamId: string, userId: string): Promise; + countByWorkshop(workshopId: string): Promise; +} diff --git a/backend/src/modules/team/interfaces/ITeamService.ts b/backend/src/modules/team/interfaces/ITeamService.ts new file mode 100644 index 0000000..819f4a5 --- /dev/null +++ b/backend/src/modules/team/interfaces/ITeamService.ts @@ -0,0 +1,18 @@ +import { ITeam, CreateTeamDTO, UpdateTeamDTO } from '../types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; + +export interface ITeamService { + setSocketService(socketService: ISocketService): void; + createTeam(workshopId: string, actorId: string, data: CreateTeamDTO): Promise; + getTeam(teamId: string): Promise; + getWorkshopTeams(workshopId: string): Promise; + getUserTeamsInWorkshop(workshopId: string, userId: string): Promise; + updateTeam(teamId: string, actorId: string, updates: UpdateTeamDTO): Promise; + deleteTeam(teamId: string, actorId: string): Promise; + addMemberToTeam(teamId: string, actorId: string, userId: string): Promise; + removeMemberFromTeam(teamId: string, actorId: string, userId: string): Promise; + assignInternalRole(teamId: string, actorId: string, userId: string, roleName: string): Promise; + removeInternalRole(teamId: string, actorId: string, userId: string, roleName: string): Promise; + isMember(teamId: string, userId: string): Promise; + getMemberCount(teamId: string): Promise; +} diff --git a/backend/src/models/Membership.ts b/backend/src/modules/team/models/Membership.ts similarity index 94% rename from backend/src/models/Membership.ts rename to backend/src/modules/team/models/Membership.ts index 046e47b..1cb4e2f 100644 --- a/backend/src/models/Membership.ts +++ b/backend/src/modules/team/models/Membership.ts @@ -1,9 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { - IMembership, - MembershipState, - MembershipSource -} from '../types'; +import { IMembership, MembershipState, MembershipSource } from '../types/index'; const membershipSchema = new Schema( { diff --git a/backend/src/models/Team.ts b/backend/src/modules/team/models/Team.ts similarity index 96% rename from backend/src/models/Team.ts rename to backend/src/modules/team/models/Team.ts index 3b77cb2..147d5c2 100644 --- a/backend/src/models/Team.ts +++ b/backend/src/modules/team/models/Team.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { ITeam, ITeamRole } from '../types'; +import { ITeam, ITeamRole } from '../types/index'; const teamRoleSchema = new Schema( { diff --git a/backend/src/repositories/MembershipRepository.ts b/backend/src/modules/team/repositories/MembershipRepository.ts similarity index 95% rename from backend/src/repositories/MembershipRepository.ts rename to backend/src/modules/team/repositories/MembershipRepository.ts index f8d3987..a5811b2 100644 --- a/backend/src/repositories/MembershipRepository.ts +++ b/backend/src/modules/team/repositories/MembershipRepository.ts @@ -1,17 +1,11 @@ import { Membership } from '../models/Membership'; -import { IMembership, MembershipState, MembershipSource } from '../types'; +import { IMembership, MembershipState, MembershipSource, CreateMembershipDTO } from '../types/index'; import { Types } from 'mongoose'; -import { NotFoundError } from '../utils/errors'; +import { NotFoundError } from '../../../shared/utils/errors'; +import { IMembershipRepository } from '../interfaces/IMembershipRepository'; -export interface CreateMembershipDTO { - workshopId: string; - userId: string; - source: MembershipSource; - invitedBy?: string; - state?: MembershipState; -} -export class MembershipRepository { +export class MembershipRepository implements IMembershipRepository { private readonly populateUser = { path: 'user', select: 'name email profilePhoto' }; private readonly populateWorkshop = { path: 'workshop', select: 'name description visibility' }; private readonly populateInvitedBy = { path: 'invitedBy', select: 'name email' }; diff --git a/backend/src/repositories/TeamRepository.ts b/backend/src/modules/team/repositories/TeamRepository.ts similarity index 87% rename from backend/src/repositories/TeamRepository.ts rename to backend/src/modules/team/repositories/TeamRepository.ts index bd36e58..023c8d4 100644 --- a/backend/src/repositories/TeamRepository.ts +++ b/backend/src/modules/team/repositories/TeamRepository.ts @@ -1,9 +1,10 @@ import { Team } from '../models/Team'; -import { ITeam, CreateTeamDTO, UpdateTeamDTO } from '../types'; +import { ITeam, CreateTeamDTO, UpdateTeamDTO } from '../types/index'; import { Types } from 'mongoose'; -import { NotFoundError } from '../utils/errors'; +import { NotFoundError } from '../../../shared/utils/errors'; +import { ITeamRepository } from '../interfaces/ITeamRepository'; -export class TeamRepository { +export class TeamRepository implements ITeamRepository { private readonly populateMembers = { path: 'members', select: 'name email profilePhoto skills' }; private readonly populateWorkshop = { path: 'workshop', select: 'name description' }; @@ -24,6 +25,11 @@ export class TeamRepository { .populate(this.populateWorkshop); } + async findByIds(ids: string[]): Promise { + return await Team.find({ _id: { $in: ids.map(id => new Types.ObjectId(id)) } }) + .populate(this.populateMembers); + } + async findByWorkshop(workshopId: string): Promise { return await Team.find({ workshop: new Types.ObjectId(workshopId) }) .populate(this.populateMembers) @@ -105,7 +111,7 @@ export class TeamRepository { async isMember(teamId: string, userId: string): Promise { const team = await Team.findById(teamId); - return team?.members.some(m => m.toString() === userId) || false; + return team?.members.some((m: Types.ObjectId) => m.toString() === userId) || false; } async countMembers(teamId: string): Promise { @@ -176,8 +182,8 @@ export class TeamRepository { if (!team) return []; const roles: string[] = []; - for (const role of team.internalRoles) { - if (role.members.some(m => m.toString() === userId)) { + for (const role of (team.internalRoles || [])) { + if (role.members.some((m: Types.ObjectId) => m.toString() === userId)) { roles.push(role.name); } } diff --git a/backend/src/modules/team/routes/teamRoutes.ts b/backend/src/modules/team/routes/teamRoutes.ts new file mode 100644 index 0000000..f07bef9 --- /dev/null +++ b/backend/src/modules/team/routes/teamRoutes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { authMiddleware, requireWorkshopMembership, requireWorkshopManager } from '@middlewares'; +import { TEAM_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createTeamRoutes = (container: Container) => { + const router = Router({ mergeParams: true }); + const teamController = container.teamCtrl; + + router.use(authMiddleware); + + router.post(TEAM_ROUTES.BASE, requireWorkshopManager, teamController.createTeam); + router.get(TEAM_ROUTES.BY_ID, requireWorkshopMembership, teamController.getTeam); + router.put(TEAM_ROUTES.BY_ID, requireWorkshopManager, teamController.updateTeam); + router.delete(TEAM_ROUTES.BY_ID, requireWorkshopManager, teamController.deleteTeam); + router.post(TEAM_ROUTES.MEMBERS, requireWorkshopManager, teamController.addMember); + router.delete(TEAM_ROUTES.MEMBER_BY_ID, requireWorkshopManager, teamController.removeMember); + router.get(TEAM_ROUTES.USER_TEAMS, requireWorkshopMembership, teamController.getUserTeams); + + return router; +}; + +export default createTeamRoutes; \ No newline at end of file diff --git a/backend/src/services/TeamService.ts b/backend/src/modules/team/services/TeamService.ts similarity index 87% rename from backend/src/services/TeamService.ts rename to backend/src/modules/team/services/TeamService.ts index 3e59dc2..43590b2 100644 --- a/backend/src/services/TeamService.ts +++ b/backend/src/modules/team/services/TeamService.ts @@ -1,17 +1,14 @@ import { Types } from 'mongoose'; -import { - ITeam, - CreateTeamDTO, - UpdateTeamDTO -} from '../types'; -import { TeamRepository } from '../repositories/TeamRepository'; -import { MembershipRepository } from '../repositories/MembershipRepository'; -import { WorkshopRepository } from '../repositories/WorkshopRepository'; -import { AuditService } from './AuditService'; -import { PermissionService } from './PermissionService'; -import { ChatService } from './ChatService'; -import { SocketService } from './SocketService'; -import { NotFoundError, AuthorizationError, ValidationError } from '../utils/errors'; +import { ITeam, CreateTeamDTO, UpdateTeamDTO } from '../types/index'; +import { ITeamRepository } from '../interfaces/ITeamRepository'; +import { IMembershipRepository } from '../interfaces/IMembershipRepository'; +import { IWorkshopRepository } from '../../workshop/interfaces/IWorkshopRepository'; +import { IAuditService } from '../../audit/interfaces/IAuditService'; +import { IPermissionService } from '../../access-control/interfaces/IPermissionService'; +import { IChatService } from '../../chat/interfaces/IChatService'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; +import { ITeamService } from '../interfaces/ITeamService'; +import { NotFoundError, AuthorizationError, ValidationError } from '../../../shared/utils/errors'; function getIdString(ref: Types.ObjectId | { _id: Types.ObjectId } | any): string { if (ref && typeof ref === 'object' && '_id' in ref) { @@ -20,25 +17,18 @@ function getIdString(ref: Types.ObjectId | { _id: Types.ObjectId } | any): strin return ref.toString(); } -export class TeamService { - private teamRepository: TeamRepository; - private membershipRepository: MembershipRepository; - private workshopRepository: WorkshopRepository; - private auditService: AuditService; - private permissionService: PermissionService; - private chatService: ChatService; - private socketService?: SocketService; - - constructor() { - this.teamRepository = new TeamRepository(); - this.membershipRepository = new MembershipRepository(); - this.workshopRepository = new WorkshopRepository(); - this.auditService = new AuditService(); - this.permissionService = PermissionService.getInstance(); - this.chatService = new ChatService(); - } - - setSocketService(socketService: SocketService): void { +export class TeamService implements ITeamService { + constructor( + private teamRepository: ITeamRepository, + private membershipRepository: IMembershipRepository, + private workshopRepository: IWorkshopRepository, + private auditService: IAuditService, + private permissionService: IPermissionService, + private chatService: IChatService, + private socketService: ISocketService | null = null + ) { } + + setSocketService(socketService: ISocketService): void { this.socketService = socketService; this.chatService.setSocketService(socketService); } @@ -48,7 +38,6 @@ export class TeamService { actorId: string, data: CreateTeamDTO ): Promise { - const permission = await this.permissionService.checkPermission(actorId, workshopId, 'create', 'team'); if (!permission.granted) { await this.auditService.logUnauthorizedAccess(workshopId, actorId, 'create', 'team'); diff --git a/backend/src/modules/team/types/index.ts b/backend/src/modules/team/types/index.ts new file mode 100644 index 0000000..4edf7aa --- /dev/null +++ b/backend/src/modules/team/types/index.ts @@ -0,0 +1,69 @@ + +import { Document, Types } from 'mongoose'; + +export enum MembershipState { + PENDING = 'pending', + ACTIVE = 'active', + INACTIVE = 'inactive', + REJECTED = 'rejected', + LEFT = 'left', + REMOVED = 'removed' +} + +export enum MembershipSource { + INVITE = 'invite', + INVITATION = 'invite', + JOIN_REQUEST = 'join_request', + ADDED = 'added', + OPEN_ACCESS = 'open_access' +} + +export interface IMembership extends Document { + user: Types.ObjectId; + team: Types.ObjectId; + workshop: Types.ObjectId; + state: MembershipState; + source: MembershipSource; + role: Types.ObjectId; + invitedBy?: Types.ObjectId; + removedAt?: Date; + removedBy?: Types.ObjectId; + joinedAt: Date; + leftAt?: Date; +} + +export interface ITeamRole { + name: string; + permissions: string[]; + members: Types.ObjectId[]; +} + +export interface ITeam extends Document { + name: string; + description?: string; + workshop: Types.ObjectId; + members: Types.ObjectId[]; + roles?: ITeamRole[]; + internalRoles?: ITeamRole[]; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTeamDTO { + name: string; + description?: string; + workshopId?: string; +} + +export interface UpdateTeamDTO { + name?: string; + description?: string; +} + +export interface CreateMembershipDTO { + workshopId: string; + userId: string; + source: MembershipSource; + invitedBy?: string; + state?: MembershipState; +} diff --git a/backend/src/modules/user/interfaces/IUserRepository.ts b/backend/src/modules/user/interfaces/IUserRepository.ts new file mode 100644 index 0000000..71ec00f --- /dev/null +++ b/backend/src/modules/user/interfaces/IUserRepository.ts @@ -0,0 +1,16 @@ +import { IUser } from '../types/index'; + +export interface IUserRepository { + create(userData: Partial): Promise; + findById(id: string): Promise; + findByIdWithPassword(id: string): Promise; + findByEmail(email: string): Promise; + findByEmailWithoutPassword(email: string): Promise; + findByVerificationToken(token: string): Promise; + findByGoogleId(googleId: string): Promise; + findByGithubId(githubId: string): Promise; + update(id: string, updates: Partial): Promise; + updatePresence(id: string, isOnline: boolean): Promise; + findMultipleByIds(ids: string[]): Promise; + searchBySkills(skills: string[]): Promise; +} diff --git a/backend/src/models/User.ts b/backend/src/modules/user/models/User.ts similarity index 97% rename from backend/src/models/User.ts rename to backend/src/modules/user/models/User.ts index b75b6a1..7ca3d9a 100644 --- a/backend/src/models/User.ts +++ b/backend/src/modules/user/models/User.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { IUser } from '../types'; +import { IUser } from '../types/index'; const userSchema = new Schema( { diff --git a/backend/src/repositories/UserRepository.ts b/backend/src/modules/user/repositories/UserRepository.ts similarity index 92% rename from backend/src/repositories/UserRepository.ts rename to backend/src/modules/user/repositories/UserRepository.ts index c1efba0..1626fb2 100644 --- a/backend/src/repositories/UserRepository.ts +++ b/backend/src/modules/user/repositories/UserRepository.ts @@ -1,8 +1,9 @@ import { User } from '../models/User'; -import { IUser } from '../types'; +import { IUser } from '../types/index'; import { Types } from 'mongoose'; +import { IUserRepository } from '../interfaces/IUserRepository'; -export class UserRepository { +export class UserRepository implements IUserRepository { async create(userData: Partial): Promise { const user = new User(userData); return await user.save(); diff --git a/backend/src/modules/user/types/index.ts b/backend/src/modules/user/types/index.ts new file mode 100644 index 0000000..7078bd6 --- /dev/null +++ b/backend/src/modules/user/types/index.ts @@ -0,0 +1,20 @@ + +import { Document, Types } from 'mongoose'; + +export interface IUser extends Document { + _id: Types.ObjectId; + name: string; + email: string; + password: string; + profilePhoto?: string; + skills: string[]; + interests: string[]; + isOnline: boolean; + lastActive: Date; + createdAt: Date; + updatedAt: Date; + isVerified?: boolean; + verificationToken?: string; + googleId?: string; + githubId?: string; +} diff --git a/backend/src/controllers/WorkshopController.ts b/backend/src/modules/workshop/controllers/WorkshopController.ts similarity index 95% rename from backend/src/controllers/WorkshopController.ts rename to backend/src/modules/workshop/controllers/WorkshopController.ts index 5c85055..5a9211e 100644 --- a/backend/src/controllers/WorkshopController.ts +++ b/backend/src/modules/workshop/controllers/WorkshopController.ts @@ -1,16 +1,14 @@ import { Request, Response, NextFunction } from 'express'; -import { WorkshopService } from '../services/WorkshopService'; -import { AuthRequest, WorkshopVisibility } from '../types'; +import { IWorkshopService } from '../interfaces/IWorkshopService'; +import { AuthRequest } from '../../../shared/types/index'; +import { WorkshopVisibility } from '../types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; export class WorkshopController { - private workshopService: WorkshopService; + constructor(private workshopService: IWorkshopService) { } - constructor() { - this.workshopService = new WorkshopService(); - } - - setSocketService(socketService: any): void { - this.workshopService.setSocketService(socketService); + setSocketService(socketService: ISocketService): void { + (this.workshopService as any).setSocketService?.(socketService); } createWorkshop = async (req: AuthRequest, res: Response, next: NextFunction): Promise => { @@ -57,7 +55,6 @@ export class WorkshopController { getPublicWorkshops = async (req: Request, res: Response, next: NextFunction): Promise => { try { const { search, category, tags, sort, page = 1, limit = 20 } = req.query; - const skip = (Number(page) - 1) * Number(limit); const currentUserId = (req as any).user?.id; const result = await this.workshopService.getPublicWorkshops({ @@ -65,7 +62,7 @@ export class WorkshopController { category: category as string, tags: tags ? (Array.isArray(tags) ? tags as string[] : [tags as string]) : undefined, limit: Number(limit), - skip, + page: Number(page), sort: sort as string }, currentUserId); @@ -76,7 +73,7 @@ export class WorkshopController { page: Number(page), limit: Number(limit), total: result.total, - pages: Math.ceil(result.total / Number(limit)) + pages: result.pages }, message: 'Public workshops retrieved successfully' }); diff --git a/backend/src/modules/workshop/interfaces/IWorkshopRepository.ts b/backend/src/modules/workshop/interfaces/IWorkshopRepository.ts new file mode 100644 index 0000000..6eab639 --- /dev/null +++ b/backend/src/modules/workshop/interfaces/IWorkshopRepository.ts @@ -0,0 +1,20 @@ +import { IWorkshop, CreateWorkshopDTO, UpdateWorkshopDTO } from '../types/index'; + +export interface IWorkshopRepository { + create(ownerId: string, workshopData: CreateWorkshopDTO): Promise; + findById(id: string): Promise; + findByUser(userId: string): Promise; + findPublic(options?: any): Promise; + countPublic(options?: any): Promise; + update(id: string, updates: UpdateWorkshopDTO): Promise; + delete(id: string): Promise; + addManager(workshopId: string, managerId: string): Promise; + removeManager(workshopId: string, managerId: string): Promise; + isOwner(workshopId: string, userId: string): Promise; + isManager(workshopId: string, userId: string): Promise; + isOwnerOrManager(workshopId: string, userId: string): Promise; + getManagerCount(workshopId: string): Promise; + searchPublic(searchTerm: string, limit?: number): Promise; + incrementVote(workshopId: string, amount: number, isUpvote: boolean): Promise; + updateVoteStats(workshopId: string, upvotes: number, downvotes: number): Promise; +} diff --git a/backend/src/modules/workshop/interfaces/IWorkshopService.ts b/backend/src/modules/workshop/interfaces/IWorkshopService.ts new file mode 100644 index 0000000..c7045c1 --- /dev/null +++ b/backend/src/modules/workshop/interfaces/IWorkshopService.ts @@ -0,0 +1,29 @@ +import { IWorkshop, CreateWorkshopDTO, UpdateWorkshopDTO } from '../types/index'; +import { IMembership, MembershipState } from '../../team/types/index'; +import { IInvitation } from '../../invitation/types/index'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; + +export interface IWorkshopService { + setSocketService(socketService: ISocketService): void; + createWorkshop(ownerId: string, data: CreateWorkshopDTO): Promise; + getWorkshop(workshopId: string): Promise; + getUserWorkshops(userId: string): Promise; + updateWorkshop(workshopId: string, actorId: string, updates: UpdateWorkshopDTO): Promise; + deleteWorkshop(workshopId: string, actorId: string): Promise; + assignManager(workshopId: string, actorId: string, managerId: string): Promise; + removeManager(workshopId: string, actorId: string, managerId: string): Promise; + inviteMember(workshopId: string, actorId: string, invitedEmail: string, roleId?: string): Promise; + acceptInvitationByToken(invitation: IInvitation, userId: string): Promise; + handleJoinRequest(workshopId: string, userId: string): Promise; + approveJoinRequest(workshopId: string, actorId: string, membershipId: string): Promise; + rejectJoinRequest(workshopId: string, actorId: string, membershipId: string, reason?: string): Promise; + revokeMembership(workshopId: string, actorId: string, userId: string, reason?: string): Promise; + handleMemberLeave(workshopId: string, userId: string): Promise; + getMembers(workshopId: string, state?: MembershipState): Promise; + getPendingRequests(workshopId: string): Promise; + getPublicWorkshops(options?: any, currentUserId?: string): Promise<{ workshops: any[]; total: number; pages: number }>; + upvoteWorkshop(userId: string, workshopId: string): Promise; + downvoteWorkshop(userId: string, workshopId: string): Promise; + isMember(workshopId: string, userId: string): Promise; + checkPermission(userId: string, workshopId: string, action: string, resource: string, context?: any): Promise; +} diff --git a/backend/src/models/Workshop.ts b/backend/src/modules/workshop/models/Workshop.ts similarity index 99% rename from backend/src/models/Workshop.ts rename to backend/src/modules/workshop/models/Workshop.ts index f0ad520..e10bf44 100644 --- a/backend/src/models/Workshop.ts +++ b/backend/src/modules/workshop/models/Workshop.ts @@ -5,7 +5,7 @@ import { WorkshopVisibility, ProjectCategory, DEFAULT_WORKSHOP_SETTINGS -} from '../types'; +} from '../types/index'; const workshopSettingsSchema = new Schema( { diff --git a/backend/src/repositories/WorkshopRepository.ts b/backend/src/modules/workshop/repositories/WorkshopRepository.ts similarity index 94% rename from backend/src/repositories/WorkshopRepository.ts rename to backend/src/modules/workshop/repositories/WorkshopRepository.ts index ed8f1f1..211e9be 100644 --- a/backend/src/repositories/WorkshopRepository.ts +++ b/backend/src/modules/workshop/repositories/WorkshopRepository.ts @@ -1,9 +1,10 @@ import { Workshop } from '../models/Workshop'; -import { IWorkshop, CreateWorkshopDTO, UpdateWorkshopDTO, WorkshopVisibility } from '../types'; +import { IWorkshop, CreateWorkshopDTO, UpdateWorkshopDTO, WorkshopVisibility } from '../types/index'; import { Types } from 'mongoose'; -import { NotFoundError } from '../utils/errors'; +import { NotFoundError } from '../../../shared/utils/errors'; +import { IWorkshopRepository } from '../interfaces/IWorkshopRepository'; -export class WorkshopRepository { +export class WorkshopRepository implements IWorkshopRepository { private readonly populateOwner = { path: 'owner', select: 'name email profilePhoto' }; private readonly populateManagers = { path: 'managers', select: 'name email profilePhoto' }; @@ -163,7 +164,7 @@ export class WorkshopRepository { async isManager(workshopId: string, userId: string): Promise { const workshop = await Workshop.findById(workshopId); - return workshop?.managers.some(m => m.toString() === userId) || false; + return workshop?.managers.some((m: Types.ObjectId) => m.toString() === userId) || false; } async isOwnerOrManager(workshopId: string, userId: string): Promise { @@ -171,7 +172,7 @@ export class WorkshopRepository { if (!workshop) return false; return workshop.owner.toString() === userId || - workshop.managers.some(m => m.toString() === userId); + workshop.managers.some((m: Types.ObjectId) => m.toString() === userId); } async getManagerCount(workshopId: string): Promise { diff --git a/backend/src/modules/workshop/routes/workshopRoutes.ts b/backend/src/modules/workshop/routes/workshopRoutes.ts new file mode 100644 index 0000000..2eb0f72 --- /dev/null +++ b/backend/src/modules/workshop/routes/workshopRoutes.ts @@ -0,0 +1,77 @@ +import { Router } from 'express'; +import { authMiddleware, optionalAuthenticate, requireWorkshopMembership, requirePermission } from '@middlewares'; +import { WORKSHOP_ROUTES, TEAM_ROUTES, PROJECT_ROUTES, TASK_ROUTES } from '@constants'; +import { Container } from '@di/types'; + +export const createWorkshopRoutes = (container: Container) => { + const router = Router(); + const workshopController = container.workshopCtrl; + const projectController = container.workshopProjectCtrl; + const taskController = container.workshopTaskCtrl; + const teamController = container.teamCtrl; + + router.post(WORKSHOP_ROUTES.BASE, authMiddleware, workshopController.createWorkshop); + router.get(WORKSHOP_ROUTES.MY_WORKSHOPS, authMiddleware, workshopController.getUserWorkshops); + router.get(WORKSHOP_ROUTES.PUBLIC, optionalAuthenticate, workshopController.getPublicWorkshops); + router.post(WORKSHOP_ROUTES.UPVOTE, authMiddleware, workshopController.upvoteWorkshop); + router.post(WORKSHOP_ROUTES.DOWNVOTE, authMiddleware, workshopController.downvoteWorkshop); + router.get(WORKSHOP_ROUTES.CHECK_PERMISSION, authMiddleware, workshopController.checkPermission); + router.get(WORKSHOP_ROUTES.BY_ID, authMiddleware, requireWorkshopMembership, workshopController.getWorkshop); + router.put(WORKSHOP_ROUTES.BY_ID, authMiddleware, requirePermission('update', 'workshop'), workshopController.updateWorkshop); + router.delete(WORKSHOP_ROUTES.BY_ID, authMiddleware, requirePermission('delete', 'workshop'), workshopController.deleteWorkshop); + + router.get(WORKSHOP_ROUTES.MEMBERS, authMiddleware, requireWorkshopMembership, workshopController.getMembers); + router.get(WORKSHOP_ROUTES.PENDING_REQUESTS, authMiddleware, requirePermission('manage', 'membership'), workshopController.getPendingRequests); + router.post(WORKSHOP_ROUTES.INVITE, authMiddleware, requirePermission('invite', 'membership'), workshopController.inviteMember); + router.post(WORKSHOP_ROUTES.JOIN, authMiddleware, workshopController.handleJoinRequest); + router.post(WORKSHOP_ROUTES.APPROVE_REQUEST, authMiddleware, requirePermission('approve', 'membership'), workshopController.approveJoinRequest); + router.post(WORKSHOP_ROUTES.REJECT_REQUEST, authMiddleware, requirePermission('reject', 'membership'), workshopController.rejectJoinRequest); + router.delete(WORKSHOP_ROUTES.REVOKE_MEMBERSHIP, authMiddleware, requirePermission('revoke', 'membership'), workshopController.revokeMembership); + router.post(WORKSHOP_ROUTES.LEAVE, authMiddleware, requireWorkshopMembership, workshopController.leaveWorkshop); + + router.post(WORKSHOP_ROUTES.ASSIGN_MANAGER, authMiddleware, requirePermission('assign_manager', 'workshop'), workshopController.assignManager); + router.delete(WORKSHOP_ROUTES.REMOVE_MANAGER, authMiddleware, requirePermission('remove_manager', 'workshop'), workshopController.removeManager); + + router.get(TEAM_ROUTES.USER_TEAMS, authMiddleware, requireWorkshopMembership, teamController.getUserTeams); // Adjusted as it's /user/:userId + router.get(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.BASE, authMiddleware, requireWorkshopMembership, teamController.getWorkshopTeams); + router.post(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.BASE, authMiddleware, requirePermission('create', 'team'), teamController.createTeam); + router.get(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.BY_ID, authMiddleware, requireWorkshopMembership, teamController.getTeam); + router.put(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.BY_ID, authMiddleware, requirePermission('update', 'team'), teamController.updateTeam); + router.delete(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.BY_ID, authMiddleware, requirePermission('delete', 'team'), teamController.deleteTeam); + router.post(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.MEMBERS, authMiddleware, requirePermission('manage', 'team'), teamController.addMember); + router.delete(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.MEMBER_BY_ID, authMiddleware, requirePermission('manage', 'team'), teamController.removeMember); + router.get(WORKSHOP_ROUTES.BY_ID + TEAM_ROUTES.BY_ID + '/tasks', authMiddleware, requireWorkshopMembership, taskController.getTeamTasks); + + router.get(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BASE, authMiddleware, requireWorkshopMembership, projectController.getProjects); + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BASE, authMiddleware, requirePermission('create', 'project'), projectController.createProject); + router.get(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID, authMiddleware, requireWorkshopMembership, projectController.getProject); + router.put(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID, authMiddleware, requirePermission('update', 'project'), projectController.updateProject); + router.delete(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID, authMiddleware, requirePermission('delete', 'project'), projectController.deleteProject); + + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.TEAMS, authMiddleware, requirePermission('assign', 'project'), projectController.assignTeam); + router.delete(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.TEAM_BY_ID, authMiddleware, requirePermission('assign', 'project'), projectController.removeTeam); + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.INDIVIDUALS, authMiddleware, requirePermission('assign', 'project'), projectController.assignIndividual); + router.delete(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.INDIVIDUAL_BY_ID, authMiddleware, requirePermission('assign', 'project'), projectController.removeIndividual); + + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.MANAGER, authMiddleware, requirePermission('manage', 'project'), projectController.assignProjectManager); + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.MAINTAINERS, authMiddleware, requirePermission('manage', 'project'), projectController.addMaintainer); + router.delete(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.MAINTAINER_BY_ID, authMiddleware, requirePermission('manage', 'project'), projectController.removeMaintainer); + + router.get(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.BASE, authMiddleware, requireWorkshopMembership, taskController.getProjectTasks); + router.get(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.BOARD, authMiddleware, requireWorkshopMembership, taskController.getProjectTaskBoard); + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.BASE, authMiddleware, requirePermission('create', 'task'), taskController.createTask); + router.get(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.BY_ID, authMiddleware, requireWorkshopMembership, taskController.getTask); + router.put(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.BY_ID, authMiddleware, requirePermission('update', 'task'), taskController.updateTask); + router.put(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.STATUS, authMiddleware, requirePermission('update', 'task'), taskController.updateTaskStatus); + router.delete(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.BY_ID, authMiddleware, requirePermission('delete', 'task'), taskController.deleteTask); + router.get(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.ACTIVITY, authMiddleware, requireWorkshopMembership, taskController.getTaskActivities); + + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.TEAMS, authMiddleware, requirePermission('assign', 'task'), taskController.assignTeam); + router.post(WORKSHOP_ROUTES.BY_ID + PROJECT_ROUTES.BY_ID + TASK_ROUTES.INDIVIDUALS, authMiddleware, requirePermission('assign', 'task'), taskController.assignIndividual); + + router.get(WORKSHOP_ROUTES.BY_ID + TASK_ROUTES.MY_TASKS, authMiddleware, requireWorkshopMembership, taskController.getMyTasks); + + return router; +}; + +export default createWorkshopRoutes; \ No newline at end of file diff --git a/backend/src/services/WorkshopService.ts b/backend/src/modules/workshop/services/WorkshopService.ts similarity index 85% rename from backend/src/services/WorkshopService.ts rename to backend/src/modules/workshop/services/WorkshopService.ts index 24b17bd..8295a37 100644 --- a/backend/src/services/WorkshopService.ts +++ b/backend/src/modules/workshop/services/WorkshopService.ts @@ -1,32 +1,26 @@ -import { - IWorkshop, - IMembership, - CreateWorkshopDTO, - UpdateWorkshopDTO, - MembershipState, - MembershipSource, - AuditAction, - - PermissionScope, - PermissionType -} from '../types/workshop'; - -import { WorkshopRepository } from '../repositories/WorkshopRepository'; -import { MembershipRepository } from '../repositories/MembershipRepository'; -import { TeamRepository } from '../repositories/TeamRepository'; -import { RoleRepository } from '../repositories/RoleRepository'; -import { RoleAssignmentRepository } from '../repositories/RoleAssignmentRepository'; -import { WorkshopProjectRepository } from '../repositories/WorkshopProjectRepository'; -import { AuditService } from './AuditService'; -import { PermissionService } from './PermissionService'; -import { NotFoundError, AuthorizationError, ValidationError } from '../utils/errors'; -import { SocketService } from './SocketService'; -import { EmailService } from './EmailService'; -import { ChatService } from './ChatService'; +import { IWorkshop, CreateWorkshopDTO, UpdateWorkshopDTO } from '../types/index'; +import { IMembership, MembershipState, MembershipSource } from '../../team/types/index'; +import { AuditAction } from '../../audit/types/index'; +import { PermissionScope, PermissionType } from '../../access-control/types/index'; + +import { IWorkshopRepository } from '../interfaces/IWorkshopRepository'; +import { IMembershipRepository } from '../../team/interfaces/IMembershipRepository'; +import { ITeamRepository } from '../../team/interfaces/ITeamRepository'; +import { IRoleRepository } from '../../access-control/interfaces/IRoleRepository'; +import { IRoleAssignmentRepository } from '../../access-control/interfaces/IRoleAssignmentRepository'; +import { IWorkshopProjectRepository } from '../../project/interfaces/IWorkshopProjectRepository'; +import { IAuditService } from '../../audit/interfaces/IAuditService'; +import { IPermissionService } from '../../access-control/interfaces/IPermissionService'; +import { ISocketService } from '../../../shared/interfaces/ISocketService'; +import { IEmailService } from '../../../shared/interfaces/IEmailService'; +import { IChatService } from '../../chat/interfaces/IChatService'; +import { IWorkshopService } from '../interfaces/IWorkshopService'; +import { NotFoundError, AuthorizationError, ValidationError } from '../../../shared/utils/errors'; import { Types } from 'mongoose'; -import { Membership } from '../models/Membership'; -import { User } from '../models/User'; -import { Invitation, IInvitation } from '../models/Invitation'; +import { Membership } from '../../team/models/Membership'; +import { User } from '../../user/models/User'; +import { Invitation } from '../../invitation/models/Invitation'; +import { IInvitation } from '../../invitation/types/index'; import crypto from 'crypto'; function getIdString(ref: any): string { @@ -36,33 +30,22 @@ function getIdString(ref: any): string { return ref?.toString() || ''; } -export class WorkshopService { - private workshopRepository: WorkshopRepository; - private membershipRepository: MembershipRepository; - private teamRepository: TeamRepository; - private roleRepository: RoleRepository; - private roleAssignmentRepository: RoleAssignmentRepository; - private projectRepository: WorkshopProjectRepository; - private auditService: AuditService; - private permissionService: PermissionService; - private socketService: SocketService | null = null; - private emailService: EmailService; - private chatService: ChatService; - - constructor() { - this.workshopRepository = new WorkshopRepository(); - this.membershipRepository = new MembershipRepository(); - this.teamRepository = new TeamRepository(); - this.roleRepository = new RoleRepository(); - this.roleAssignmentRepository = new RoleAssignmentRepository(); - this.projectRepository = new WorkshopProjectRepository(); - this.auditService = new AuditService(); - this.permissionService = PermissionService.getInstance(); - this.emailService = new EmailService(); - this.chatService = new ChatService(); - } - - setSocketService(socketService: SocketService): void { +export class WorkshopService implements IWorkshopService { + constructor( + private workshopRepository: IWorkshopRepository, + private membershipRepository: IMembershipRepository, + private teamRepository: ITeamRepository, + private roleRepository: IRoleRepository, + private roleAssignmentRepository: IRoleAssignmentRepository, + private projectRepository: IWorkshopProjectRepository, + private auditService: IAuditService, + private permissionService: IPermissionService, + private emailService: IEmailService, + private chatService: IChatService, + private socketService: ISocketService | null = null + ) { } + + setSocketService(socketService: ISocketService): void { this.socketService = socketService; this.chatService.setSocketService(socketService); } @@ -234,7 +217,6 @@ export class WorkshopService { const email = invitedEmail.toLowerCase(); - // Check if already a member const existingUser = await User.findOne({ email }); if (existingUser) { const existingMembership = await this.membershipRepository.findByWorkshopAndUser(workshopId, existingUser._id.toString()); @@ -243,7 +225,6 @@ export class WorkshopService { } } - // If user exists, create a pending membership so it shows up in UI let membership: IMembership | undefined; if (existingUser) { const existing = await this.membershipRepository.findByWorkshopAndUser(workshopId, existingUser._id.toString()); @@ -260,10 +241,8 @@ export class WorkshopService { } } - // Generate token const token = crypto.randomBytes(32).toString('hex'); - // Create invitation record await Invitation.create({ token, email, @@ -272,15 +251,12 @@ export class WorkshopService { invitedBy: new Types.ObjectId(actorId) }); - // Log audit await this.auditService.logMemberInvited(workshopId, actorId, email); - // Emit socket event if we have a membership if (membership && this.socketService) { this.socketService.emitToWorkshop(workshopId, 'membership:invited', membership); } - // Send email const inviter = await User.findById(actorId); const workshop = await this.workshopRepository.findById(workshopId); @@ -298,15 +274,12 @@ export class WorkshopService { async acceptInvitationByToken(invitation: IInvitation, userId: string): Promise { const workshopId = invitation.workshop.toString(); - // Create membership const membership = await this.handleJoinRequest(workshopId, userId); - // If it was a pending request, auto-approve it because they have a valid invite if (membership.state === MembershipState.PENDING) { await this.approveJoinRequest(workshopId, invitation.invitedBy.toString(), membership._id.toString()); } - // If there was a specific role assigned in the invitation, assign it if (invitation.role) { const roleId = invitation.role.toString(); const role = await this.roleRepository.findById(roleId); @@ -338,7 +311,6 @@ export class WorkshopService { let membership: IMembership; if (existing) { - membership = await this.membershipRepository.updateState( existing._id.toString(), state, @@ -436,9 +408,16 @@ export class WorkshopService { return await this.membershipRepository.findPendingByWorkshop(workshopId); } - async getPublicWorkshops(options?: any, currentUserId?: string): Promise<{ workshops: any[]; total: number }> { - const workshops = await this.workshopRepository.findPublic(options); - const total = await this.workshopRepository.countPublic(options); + async getPublicWorkshops(options?: any, currentUserId?: string): Promise<{ workshops: any[]; total: number; pages: number }> { + const limit = options?.limit || 20; + const page = options?.page || 1; + const skip = (page - 1) * limit; + + const findOptions = { ...options, skip }; + delete findOptions.page; + + const workshops = await this.workshopRepository.findPublic(findOptions); + const total = await this.workshopRepository.countPublic(findOptions); const enrichedWorkshops = await Promise.all(workshops.map(async (workshop) => { const workshopObj = workshop.toObject(); @@ -460,7 +439,11 @@ export class WorkshopService { return workshopObj; })); - return { workshops: enrichedWorkshops, total }; + return { + workshops: enrichedWorkshops, + total, + pages: Math.ceil(total / limit) + }; } async upvoteWorkshop(_userId: string, workshopId: string): Promise { diff --git a/backend/src/modules/workshop/types/index.ts b/backend/src/modules/workshop/types/index.ts new file mode 100644 index 0000000..cb74b63 --- /dev/null +++ b/backend/src/modules/workshop/types/index.ts @@ -0,0 +1,74 @@ + +import { Document, Types } from 'mongoose'; + +export enum ProjectCategory { + WEB_DEVELOPMENT = 'web_development', + MOBILE_DEVELOPMENT = 'mobile_development', + DATA_SCIENCE = 'data_science', + DESIGN = 'design', + MARKETING = 'marketing', + OTHER = 'other' +} + +export enum WorkshopVisibility { + PUBLIC = 'public', + PRIVATE = 'private', + INVITE_ONLY = 'invite_only', + TEAM = 'team' +} + +export interface IWorkshopSettings { + allowOpenContribution: boolean; + requireApprovalForJoin: boolean; + publicInfoFields: string[]; +} + +export const DEFAULT_WORKSHOP_SETTINGS: IWorkshopSettings = { + allowOpenContribution: false, + requireApprovalForJoin: true, + publicInfoFields: ['name', 'description', 'tags'] +} + +export interface IVote { + userId: Types.ObjectId; + voteType: 'upvote' | 'downvote'; + createdAt: Date; +} + +export interface IWorkshop extends Document { + name: string; + description: string; + visibility: WorkshopVisibility; + category: ProjectCategory; + tags: string[]; + requiredSkills: string[]; + owner: Types.ObjectId; + managers: Types.ObjectId[]; + votes: IVote[]; + upvoteCount: number; + downvoteCount: number; + voteScore: number; + settings: IWorkshopSettings; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateWorkshopDTO { + name: string; + description: string; + visibility: WorkshopVisibility; + category: ProjectCategory; + tags?: string[]; + requiredSkills?: string[]; + settings?: IWorkshopSettings; +} + +export interface UpdateWorkshopDTO { + name?: string; + description?: string; + visibility?: WorkshopVisibility; + category?: ProjectCategory; + tags?: string[]; + requiredSkills?: string[]; + settings?: IWorkshopSettings; +} diff --git a/backend/src/routes/activityRoutes.ts b/backend/src/routes/activityRoutes.ts deleted file mode 100644 index 0c05ea8..0000000 --- a/backend/src/routes/activityRoutes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Router } from 'express'; -import { ActivityController } from '../controllers/ActivityController'; -import { authenticate } from '../middlewares/auth'; -import { requireWorkshopMembership } from '../middlewares/permission'; - -const router = Router(); -const activityController = new ActivityController(); - -router.get('/workshops/:workshopId/activity', authenticate, requireWorkshopMembership, activityController.getWorkshopActivity); -router.get('/workshops/:workshopId/activity/stats', authenticate, requireWorkshopMembership, activityController.getWorkshopActivityStats); - -router.get('/users/:userId/activity', authenticate, activityController.getUserActivity); - -router.get('/activity/:entityType/:entityId', authenticate, activityController.getEntityActivity); - -router.get('/activity/recent', authenticate, activityController.getRecentActivities); - -export default router; \ No newline at end of file diff --git a/backend/src/routes/auditRoutes.ts b/backend/src/routes/auditRoutes.ts deleted file mode 100644 index 3ced233..0000000 --- a/backend/src/routes/auditRoutes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Router } from 'express'; -import { AuditController } from '../controllers/AuditController'; -import { authenticate } from '../middlewares/auth'; - -const router = Router({ mergeParams: true }); -const auditController = new AuditController(); - -router.use(authenticate); - -router.get('/', auditController.getAuditLogs); -router.get('/recent', auditController.getRecentLogs); -router.get('/stats', auditController.getAuditStats); -router.get('/user/:targetUserId', auditController.getUserActivityLogs); -router.get('/user/:targetUserId/summary', auditController.getUserActivitySummary); -router.get('/target/:targetId', auditController.getTargetLogs); - -export default router; -export { auditController }; \ No newline at end of file diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts deleted file mode 100644 index 6ed936a..0000000 --- a/backend/src/routes/authRoutes.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Router } from 'express'; -import passport from 'passport'; -import { AuthController } from '../controllers/AuthController'; -import { authenticate } from '../middlewares/auth'; -import { configurePassport, isStrategyEnabled } from '../config/passport'; -import { generateToken, generateRefreshToken } from '../config/jwt'; - -const router = Router(); -const authController = new AuthController(); - -configurePassport(); - -router.post('/register', authController.register as any); -router.post('/verify-otp', authController.verifyOTP as any); -router.post('/resend-otp', authController.resendOTP as any); -router.post('/login', authController.login as any); -router.post('/refresh-token', authController.refreshToken as any); -router.post('/forgot-password', authController.forgotPassword as any); -router.post('/reset-password', authController.resetPassword as any); -router.get('/me', authenticate as any, authController.getProfile as any); -router.put('/profile', authenticate as any, authController.updateProfile as any); - -router.get('/google', (req, res, next) => { - if (!isStrategyEnabled('google')) { - return res.status(400).json({ - success: false, - message: 'Google authentication is not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the backend .env file.' - }); - } - return passport.authenticate('google', { scope: ['profile', 'email'] })(req, res, next); -}); - -router.get('/google/callback', (req, res, next) => { - if (!isStrategyEnabled('google')) { - return res.redirect((process.env.FRONTEND_URL || 'http://localhost:3000') + '/login?error=google_not_configured'); - } - return passport.authenticate('google', { session: false, failureRedirect: '/login' })(req, res, next); -}, (req, res) => { - const user = req.user as any; - const token = generateToken({ id: user._id.toString(), email: user.email }); - const refreshToken = generateRefreshToken({ id: user._id.toString(), email: user.email }); - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; - res.redirect(`${frontendUrl}/social-callback?token=${token}&refreshToken=${refreshToken}`); -}); - -router.get('/github', (req, res, next) => { - if (!isStrategyEnabled('github')) { - return res.status(400).json({ - success: false, - message: 'GitHub authentication is not configured. Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in the backend .env file.' - }); - } - return passport.authenticate('github', { scope: ['user:email'] })(req, res, next); -}); - -router.get('/github/callback', (req, res, next) => { - if (!isStrategyEnabled('github')) { - return res.redirect((process.env.FRONTEND_URL || 'http://localhost:3000') + '/login?error=github_not_configured'); - } - return passport.authenticate('github', { session: false, failureRedirect: '/login' })(req, res, next); -}, (req, res) => { - const user = req.user as any; - const token = generateToken({ id: user._id.toString(), email: user.email }); - const refreshToken = generateRefreshToken({ id: user._id.toString(), email: user.email }); - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; - res.redirect(`${frontendUrl}/social-callback?token=${token}&refreshToken=${refreshToken}`); -}); - -export default router; \ No newline at end of file diff --git a/backend/src/routes/chatRoutes.ts b/backend/src/routes/chatRoutes.ts deleted file mode 100644 index 7d9a2ca..0000000 --- a/backend/src/routes/chatRoutes.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Router } from 'express'; -import { ChatController } from '../controllers/ChatController'; -import { authenticate } from '../middlewares/auth'; - -const router = Router(); -export const chatController = new ChatController(); - -router.post('/workshops/:workshopId/chat/rooms', authenticate, chatController.createRoom); -router.get('/workshops/:workshopId/chat/rooms', authenticate, chatController.getRooms); -router.post('/workshops/:workshopId/chat/direct', authenticate, chatController.getOrCreateDirectRoom); - -router.get('/rooms/:roomId', authenticate, chatController.getRoom); -router.put('/rooms/:roomId', authenticate, chatController.updateRoom); -router.delete('/rooms/:roomId', authenticate, chatController.deleteRoom); - -router.post('/rooms/:roomId/messages', authenticate, chatController.sendMessage); -router.get('/rooms/:roomId/messages', authenticate, chatController.getMessages); - -router.put('/messages/:messageId', authenticate, chatController.editMessage); -router.delete('/messages/:messageId', authenticate, chatController.deleteMessage); - -router.put('/messages/:messageId/seen', authenticate, chatController.markAsSeen); -router.put('/rooms/:roomId/seen', authenticate, chatController.markAllAsSeen); -router.get('/rooms/:roomId/unread', authenticate, chatController.getUnreadCount); - -router.post('/messages/:messageId/reactions', authenticate, chatController.addReaction); -router.delete('/messages/:messageId/reactions', authenticate, chatController.removeReaction); - -router.get('/rooms/:roomId/search', authenticate, chatController.searchMessages); - -router.post('/upload', authenticate, chatController.uploadMiddleware, chatController.uploadMedia); -router.post('/upload-only', authenticate, chatController.uploadMiddleware, chatController.uploadOnly); - -export default router; \ No newline at end of file diff --git a/backend/src/routes/inviteRoutes.ts b/backend/src/routes/inviteRoutes.ts deleted file mode 100644 index 662cd74..0000000 --- a/backend/src/routes/inviteRoutes.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router } from 'express'; -import { InviteController } from '../controllers/InviteController'; -import { authenticate } from '../middlewares/auth'; - -const router = Router(); -const inviteController = new InviteController(); - -router.get('/:token', inviteController.getInviteDetails as any); -router.post('/:token/accept', authenticate as any, inviteController.acceptInvite as any); - -export default router; diff --git a/backend/src/routes/notificationRoutes.ts b/backend/src/routes/notificationRoutes.ts deleted file mode 100644 index 780f230..0000000 --- a/backend/src/routes/notificationRoutes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Router } from 'express'; -import { NotificationController } from '../controllers/NotificationController'; -import { authenticate } from '../middlewares/auth'; -import { NotificationService } from '../services/NotificationService'; - -const router = Router(); -const notificationService = new NotificationService(); -const notificationController = new NotificationController(notificationService); - -router.use(authenticate); - -router.get('/', notificationController.getNotifications); -router.get('/unread', notificationController.getUnreadNotifications); -router.get('/count', notificationController.getUnreadCount); -router.put('/:id/read', notificationController.markAsRead); -router.put('/read-all', notificationController.markAllAsRead); -router.delete('/:id', notificationController.deleteNotification); - -export default router; -export { notificationService }; \ No newline at end of file diff --git a/backend/src/routes/permissionRoutes.ts b/backend/src/routes/permissionRoutes.ts deleted file mode 100644 index 5fb3dde..0000000 --- a/backend/src/routes/permissionRoutes.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from 'express'; -import { PermissionController } from '../controllers/PermissionController'; -import { authenticate } from '../middlewares/auth'; -import { requireWorkshopMembership } from '../middlewares/permission'; -import { PermissionService } from '../services/PermissionService'; - -const router = Router({ mergeParams: true }); -const permissionService = PermissionService.getInstance(); -const permissionController = new PermissionController(permissionService); - -router.use(authenticate); -router.use(requireWorkshopMembership); - -router.post('/check', permissionController.checkPermission); - -export default router; -export { permissionService, permissionController }; \ No newline at end of file diff --git a/backend/src/routes/roleRoutes.ts b/backend/src/routes/roleRoutes.ts deleted file mode 100644 index 26e9ba1..0000000 --- a/backend/src/routes/roleRoutes.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Router } from 'express'; -import { RoleController } from '../controllers/RoleController'; -import { authenticate } from '../middlewares/auth'; - -const router = Router({ mergeParams: true }); -const roleController = new RoleController(); - -router.use(authenticate); - -router.post('/', roleController.createRole); -router.get('/', roleController.getRoles); -router.get('/:id', roleController.getRole); -router.put('/:id', roleController.updateRole); -router.delete('/:id', roleController.deleteRole); - -router.post('/:id/assign', roleController.assignRole); -router.delete('/:id/assign/:userId', roleController.revokeRole); - -router.get('/user/:userId', roleController.getUserRoles); - -export default router; -export { roleController }; \ No newline at end of file diff --git a/backend/src/routes/teamRoutes.ts b/backend/src/routes/teamRoutes.ts deleted file mode 100644 index 2a11532..0000000 --- a/backend/src/routes/teamRoutes.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Router } from 'express'; -import { TeamController } from '../controllers/TeamController'; -import { authenticate } from '../middlewares/auth'; -import { requireWorkshopMembership, requireWorkshopManager } from '../middlewares/permission'; -import { TeamService } from '../services/TeamService'; - -const router = Router({ mergeParams: true }); -const teamService = new TeamService(); -const teamController = new TeamController(); - -router.use(authenticate); - -router.post('/', requireWorkshopManager, teamController.createTeam); - -router.get('/:id', requireWorkshopMembership, teamController.getTeam); -router.put('/:id', requireWorkshopManager, teamController.updateTeam); -router.delete('/:id', requireWorkshopManager, teamController.deleteTeam); - -router.post('/:id/members', requireWorkshopManager, teamController.addMember); -router.delete('/:id/members/:userId', requireWorkshopManager, teamController.removeMember); - -router.get('/user/:userId', requireWorkshopMembership, teamController.getUserTeams); - -export default router; -export { teamService, teamController }; \ No newline at end of file diff --git a/backend/src/routes/workshopProjectRoutes.ts b/backend/src/routes/workshopProjectRoutes.ts deleted file mode 100644 index 3734c25..0000000 --- a/backend/src/routes/workshopProjectRoutes.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Router } from 'express'; -import { WorkshopProjectController } from '../controllers/WorkshopProjectController'; -import { authenticate } from '../middlewares/auth'; -import { WorkshopProjectService } from '../services/WorkshopProjectService'; - -const router = Router({ mergeParams: true }); -const projectService = new WorkshopProjectService(); -const projectController = new WorkshopProjectController(projectService); - -router.use(authenticate); - -router.post('/', projectController.createProject); -router.get('/', projectController.getProjects); -router.get('/accessible', projectController.getAccessibleProjects); -router.get('/:projectId', projectController.getProject); -router.put('/:projectId', projectController.updateProject); -router.delete('/:projectId', projectController.deleteProject); - -router.post('/:projectId/teams', projectController.assignTeam); -router.delete('/:projectId/teams/:teamId', projectController.removeTeam); - -router.post('/:projectId/individuals', projectController.assignIndividual); -router.delete('/:projectId/individuals/:userId', projectController.removeIndividual); - -router.post('/:projectId/manager', projectController.assignProjectManager); - -router.post('/:projectId/maintainers', projectController.addMaintainer); -router.delete('/:projectId/maintainers/:maintainerId', projectController.removeMaintainer); - -export default router; -export { projectService, projectController }; \ No newline at end of file diff --git a/backend/src/routes/workshopRoutes.ts b/backend/src/routes/workshopRoutes.ts deleted file mode 100644 index c3c9ae8..0000000 --- a/backend/src/routes/workshopRoutes.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Router } from 'express'; -import { WorkshopController } from '../controllers/WorkshopController'; -import { WorkshopProjectController } from '../controllers/WorkshopProjectController'; -import { WorkshopTaskController } from '../controllers/WorkshopTaskController'; -import { TeamController } from '../controllers/TeamController'; -import { authenticate, optionalAuthenticate } from '../middlewares/auth'; -import { requireWorkshopMembership, requirePermission } from '../middlewares/permission'; - -const router = Router(); - -export const workshopController = new WorkshopController(); -export const projectController = new WorkshopProjectController(); -export const taskController = new WorkshopTaskController(); -export const teamController = new TeamController(); - -router.post('/', authenticate, workshopController.createWorkshop); -router.get('/my-workshops', authenticate, workshopController.getUserWorkshops); -router.get('/public', optionalAuthenticate, workshopController.getPublicWorkshops); -router.post('/:workshopId/upvote', authenticate, workshopController.upvoteWorkshop); -router.post('/:workshopId/downvote', authenticate, workshopController.downvoteWorkshop); -router.get('/:workshopId/permissions/check', authenticate, workshopController.checkPermission); -router.get('/:workshopId', authenticate, requireWorkshopMembership, workshopController.getWorkshop); -router.put('/:workshopId', authenticate, requirePermission('update', 'workshop'), workshopController.updateWorkshop); -router.delete('/:workshopId', authenticate, requirePermission('delete', 'workshop'), workshopController.deleteWorkshop); - -router.get('/:workshopId/members', authenticate, requireWorkshopMembership, workshopController.getMembers); -router.get('/:workshopId/pending-requests', authenticate, requirePermission('manage', 'membership'), workshopController.getPendingRequests); -router.post('/:workshopId/invite', authenticate, requirePermission('invite', 'membership'), workshopController.inviteMember); -router.post('/:workshopId/join', authenticate, workshopController.handleJoinRequest); -router.post('/:workshopId/approve/:membershipId', authenticate, requirePermission('approve', 'membership'), workshopController.approveJoinRequest); -router.post('/:workshopId/reject/:membershipId', authenticate, requirePermission('reject', 'membership'), workshopController.rejectJoinRequest); -router.delete('/:workshopId/members/:userId', authenticate, requirePermission('revoke', 'membership'), workshopController.revokeMembership); -router.post('/:workshopId/leave', authenticate, requireWorkshopMembership, workshopController.leaveWorkshop); - -router.post('/:workshopId/managers/:managerId', authenticate, requirePermission('assign_manager', 'workshop'), workshopController.assignManager); -router.delete('/:workshopId/managers/:managerId', authenticate, requirePermission('remove_manager', 'workshop'), workshopController.removeManager); - -router.get('/:workshopId/teams', authenticate, requireWorkshopMembership, teamController.getWorkshopTeams); -router.post('/:workshopId/teams', authenticate, requirePermission('create', 'team'), teamController.createTeam); -router.get('/:workshopId/teams/:teamId', authenticate, requireWorkshopMembership, teamController.getTeam); -router.put('/:workshopId/teams/:teamId', authenticate, requirePermission('update', 'team'), teamController.updateTeam); -router.delete('/:workshopId/teams/:teamId', authenticate, requirePermission('delete', 'team'), teamController.deleteTeam); -router.post('/:workshopId/teams/:teamId/members/:userId', authenticate, requirePermission('manage', 'team'), teamController.addMember); -router.delete('/:workshopId/teams/:teamId/members/:userId', authenticate, requirePermission('manage', 'team'), teamController.removeMember); -router.get('/:workshopId/teams/:teamId/tasks', authenticate, requireWorkshopMembership, taskController.getTeamTasks); - -router.get('/:workshopId/projects', authenticate, requireWorkshopMembership, projectController.getProjects); -router.post('/:workshopId/projects', authenticate, requirePermission('create', 'project'), projectController.createProject); -router.get('/:workshopId/projects/:projectId', authenticate, requireWorkshopMembership, projectController.getProject); -router.put('/:workshopId/projects/:projectId', authenticate, requirePermission('update', 'project'), projectController.updateProject); -router.delete('/:workshopId/projects/:projectId', authenticate, requirePermission('delete', 'project'), projectController.deleteProject); - -router.post('/:workshopId/projects/:projectId/teams', authenticate, requirePermission('assign', 'project'), projectController.assignTeam); -router.delete('/:workshopId/projects/:projectId/teams/:teamId', authenticate, requirePermission('assign', 'project'), projectController.removeTeam); -router.post('/:workshopId/projects/:projectId/individuals', authenticate, requirePermission('assign', 'project'), projectController.assignIndividual); -router.delete('/:workshopId/projects/:projectId/individuals/:individualId', authenticate, requirePermission('assign', 'project'), projectController.removeIndividual); - -router.post('/:workshopId/projects/:projectId/manager', authenticate, requirePermission('manage', 'project'), projectController.assignProjectManager); -router.post('/:workshopId/projects/:projectId/maintainers', authenticate, requirePermission('manage', 'project'), projectController.addMaintainer); -router.delete('/:workshopId/projects/:projectId/maintainers/:maintainerId', authenticate, requirePermission('manage', 'project'), projectController.removeMaintainer); - -router.get('/:workshopId/projects/:projectId/tasks', authenticate, requireWorkshopMembership, taskController.getProjectTasks); -router.get('/:workshopId/projects/:projectId/tasks/board', authenticate, requireWorkshopMembership, taskController.getProjectTaskBoard); -router.post('/:workshopId/projects/:projectId/tasks', authenticate, requirePermission('create', 'task'), taskController.createTask); -router.get('/:workshopId/projects/:projectId/tasks/:taskId', authenticate, requireWorkshopMembership, taskController.getTask); -router.put('/:workshopId/projects/:projectId/tasks/:taskId', authenticate, requirePermission('update', 'task'), taskController.updateTask); -router.put('/:workshopId/projects/:projectId/tasks/:taskId/status', authenticate, requirePermission('update', 'task'), taskController.updateTaskStatus); -router.delete('/:workshopId/projects/:projectId/tasks/:taskId', authenticate, requirePermission('delete', 'task'), taskController.deleteTask); -router.get('/:workshopId/projects/:projectId/tasks/:taskId/activity', authenticate, requireWorkshopMembership, taskController.getTaskActivities); - -router.post('/:workshopId/projects/:projectId/tasks/:taskId/teams', authenticate, requirePermission('assign', 'task'), taskController.assignTeam); -router.post('/:workshopId/projects/:projectId/tasks/:taskId/individuals', authenticate, requirePermission('assign', 'task'), taskController.assignIndividual); - -router.get('/:workshopId/my-tasks', authenticate, requireWorkshopMembership, taskController.getMyTasks); - -export default router; \ No newline at end of file diff --git a/backend/src/routes/workshopTaskRoutes.ts b/backend/src/routes/workshopTaskRoutes.ts deleted file mode 100644 index f9facc6..0000000 --- a/backend/src/routes/workshopTaskRoutes.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Router } from 'express'; -import { authenticate } from '../middlewares/auth'; -import { taskController } from './workshopRoutes'; -const router = Router({ mergeParams: true }); - -router.use(authenticate); - -router.post('/', taskController.createTask); -router.get('/', taskController.getProjectTasks); -router.get('/board', taskController.getProjectTaskBoard); - -router.get('/:taskId', taskController.getTask); -router.put('/:taskId', taskController.updateTask); -router.delete('/:taskId', taskController.deleteTask); - -router.put('/:taskId/status', taskController.updateTaskStatus); - -router.post('/:taskId/comments', taskController.addComment); - -router.post('/:taskId/attachments', taskController.addAttachment); - -router.post('/:taskId/teams', taskController.assignTeam); - -router.post('/:taskId/individuals', taskController.assignIndividual); - -router.get('/:taskId/activity', taskController.getTaskActivities); - -export const userTaskRouter = Router(); -userTaskRouter.use(authenticate); -userTaskRouter.get('/my-tasks', taskController.getMyTasks); - -export const teamTaskRouter = Router(); -teamTaskRouter.use(authenticate); -teamTaskRouter.get('/:teamId/tasks', taskController.getTeamTasks); - -export default router; -export { router as taskRouter }; \ No newline at end of file diff --git a/backend/src/server.ts b/backend/src/server.ts index 9564f5b..5e42792 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,92 +1,17 @@ -import dotenv from 'dotenv'; +import "./config/env.init"; +import "./config/cloudinary.config"; +import { bootstrap } from "./bootstrap"; -dotenv.config(); - -import express, { Application } from 'express'; -import http from 'http'; -import cors from 'cors'; -import { connectDatabase } from './config/database'; -import { SocketService } from './services/SocketService'; -import { errorHandler } from './middlewares/errorMiddleware'; - -import authRoutes from './routes/authRoutes'; -import notificationRoutes, { notificationService } from './routes/notificationRoutes'; - -import workshopRoutes, { workshopController, teamController, projectController, taskController } from './routes/workshopRoutes'; -import roleRoutes, { roleController } from './routes/roleRoutes'; -import workshopProjectRoutes from './routes/workshopProjectRoutes'; -import workshopTaskRoutes, { taskRouter, userTaskRouter, teamTaskRouter } from './routes/workshopTaskRoutes'; -import auditRoutes from './routes/auditRoutes'; -import permissionRoutes from './routes/permissionRoutes'; -import chatRoutes, { chatController } from './routes/chatRoutes'; -import activityRoutes from './routes/activityRoutes'; -import inviteRoutes from './routes/inviteRoutes'; -import morgan from 'morgan'; - -const app: Application = express(); -const server = http.createServer(app); - -const PORT = process.env.PORT || 5001; - -app.use(cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:3000', - credentials: true -})); - -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(morgan('dev')); - -const socketService = new SocketService(server); - -notificationService.setSocketService(socketService); -workshopController.setSocketService(socketService); -roleController.setSocketService(socketService); -teamController.setSocketService(socketService); -projectController.setSocketService(socketService); -taskController.setSocketService(socketService); -chatController.setSocketService(socketService); - -app.use('/api/auth', authRoutes); -app.use('/api/notifications', notificationRoutes); - -app.use('/api/workshops', workshopRoutes); -app.use('/api/workshops/:workshopId/roles', roleRoutes); -app.use('/api/workshops/:workshopId/projects', workshopProjectRoutes); -app.use('/api/workshops/:workshopId/projects/:projectId/tasks', workshopTaskRoutes); -app.use('/api/workshops/:workshopId/audit', auditRoutes); -app.use('/api/workshops/:workshopId/permissions', permissionRoutes); - -app.use('/api/workshop-tasks', taskRouter); -app.use('/api/users', userTaskRouter); -app.use('/api/teams', teamTaskRouter); - -app.use('/api/chat', chatRoutes); -app.use('/api/invites', inviteRoutes); -app.use('/api', activityRoutes); - -app.use(errorHandler); - -const startServer = async (): Promise => { - try { - await connectDatabase(); - - server.listen(PORT, () => { - console.log(`Server Started on port ${PORT}`); - }); - } catch (error) { - console.error('Failed to start server:', error); - process.exit(1); - } -}; - -startServer(); +bootstrap().catch((error) => { + console.error("Fatal error during bootstrap:", error); + process.exit(1); +}); -process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); +process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); }); -process.on('uncaughtException', (error) => { - console.error('Uncaught Exception:', error); +process.on("uncaughtException", (error) => { + console.error("Uncaught Exception:", error); process.exit(1); }); \ No newline at end of file diff --git a/backend/src/services/EmailService.ts b/backend/src/services/EmailService.ts deleted file mode 100644 index 2e689df..0000000 --- a/backend/src/services/EmailService.ts +++ /dev/null @@ -1,293 +0,0 @@ -import nodemailer from 'nodemailer'; - -export class EmailService { - private transporter: nodemailer.Transporter | null = null; - private fromEmail: string; - private appUrl: string; - private emailEnabled: boolean; - - constructor() { - const emailUser = process.env.EMAIL_USER || process.env.SMTP_USER; - const emailPass = process.env.EMAIL_PASS || process.env.SMTP_PASS; - - this.fromEmail = process.env.EMAIL_FROM || emailUser || 'noreply@teamup.com'; - this.appUrl = process.env.FRONTEND_URL || process.env.APP_URL || 'http://localhost:3000'; - const hasCredentials = !!(emailUser && emailPass); - this.emailEnabled = hasCredentials; - - if (hasCredentials) { - this.transporter = nodemailer.createTransport({ - service: 'gmail', - auth: { - user: emailUser, - pass: emailPass - } - }); - console.log('[EmailService] Service initialized with Gmail:', emailUser); - } else { - console.log('[EmailService] Service running in console-only mode (no credentials found)'); - } - } - - async sendProjectInvitation( - toEmail: string, - inviterName: string, - projectTitle: string, - _projectId: string, - inviteToken: string - ): Promise { - const inviteLink = `${this.appUrl}/invite/${inviteToken}`; - - console.log('\n----------------------------------------'); - console.log('PROJECT INVITATION'); - console.log('----------------------------------------'); - console.log(`To: ${toEmail}`); - console.log(`From: ${inviterName}`); - console.log(`Project: ${projectTitle}`); - console.log(`Link: ${inviteLink}`); - console.log('----------------------------------------\n'); - - if (!this.emailEnabled || !this.transporter) { - console.log('[EmailService] SMTP not configured. Share link manually.'); - return true; - } - - const mailOptions = { - from: `"TeamUp" <${this.fromEmail}>`, - to: toEmail, - subject: `Invitation to join project: ${projectTitle}`, - html: this.getInvitationEmailHtml(inviterName, projectTitle, inviteLink), - text: `You have been invited to join project "${projectTitle}" on TeamUp.\n\n${inviterName} has invited you to collaborate.\n\nAccept invitation: ${inviteLink}\n\nThis invitation will expire in 7 days.` - }; - - try { - const info = await this.transporter.sendMail(mailOptions); - console.log('[EmailService] Message sent:', info.messageId); - return true; - } catch (error) { - console.error('[EmailService] Error sending email:', error); - console.log('[EmailService] Manual link share required:', inviteLink); - return true; - } - } - - async sendJoinRequestNotification( - toEmail: string, - requesterName: string, - postTitle: string, - postId: string - ): Promise { - const viewLink = `${this.appUrl}/community?post=${postId}`; - - console.log('\n----------------------------------------'); - console.log('JOIN REQUEST NOTIFICATION'); - console.log('----------------------------------------'); - console.log(`To: ${toEmail}`); - console.log(`Requester: ${requesterName}`); - console.log(`Post: ${postTitle}`); - console.log(`Link: ${viewLink}`); - console.log('----------------------------------------\n'); - - if (!this.emailEnabled || !this.transporter) { - return true; - } - - const mailOptions = { - from: `"TeamUp" <${this.fromEmail}>`, - to: toEmail, - subject: `New join request: ${postTitle}`, - html: this.getJoinRequestEmailHtml(requesterName, postTitle, viewLink), - text: `${requesterName} has requested to join your project "${postTitle}". View the request here: ${viewLink}` - }; - - try { - await this.transporter.sendMail(mailOptions); - return true; - } catch (error) { - console.error('[EmailService] Error sending notification:', error); - return true; - } - } - - async sendJoinRequestResponse( - toEmail: string, - postTitle: string, - status: 'approved' | 'rejected', - projectId?: string - ): Promise { - const isApproved = status === 'approved'; - const projectLink = projectId ? `${this.appUrl}/projects/${projectId}` : ''; - - console.log('\n----------------------------------------'); - console.log(`JOIN REQUEST ${status.toUpperCase()}`); - console.log('----------------------------------------'); - console.log(`To: ${toEmail}`); - console.log(`Post: ${postTitle}`); - if (projectLink) console.log(`Link: ${projectLink}`); - console.log('----------------------------------------\n'); - - if (!this.emailEnabled || !this.transporter) { - return true; - } - - const mailOptions = { - from: `"TeamUp" <${this.fromEmail}>`, - to: toEmail, - subject: `Update on your request: ${postTitle}`, - html: this.getJoinResponseEmailHtml(postTitle, isApproved, projectLink), - text: isApproved - ? `Your request to join "${postTitle}" was approved. ${projectLink ? `View project: ${projectLink}` : ''}` - : `Your request to join "${postTitle}" was not approved at this time.` - }; - - try { - await this.transporter.sendMail(mailOptions); - return true; - } catch (error) { - console.error('[EmailService] Error sending response:', error); - return true; - } - } - - async sendWorkshopInvitation( - toEmail: string, - inviterName: string, - workshopName: string, - _workshopId: string, - token: string - ): Promise { - const inviteLink = `${this.appUrl}/invite/${token}`; - - console.log('\n----------------------------------------'); - console.log('WORKSHOP INVITATION'); - console.log('----------------------------------------'); - console.log(`To: ${toEmail}`); - console.log(`From: ${inviterName}`); - console.log(`Workshop: ${workshopName}`); - console.log(`Link: ${inviteLink}`); - console.log('----------------------------------------\n'); - - if (!this.emailEnabled || !this.transporter) { - console.log('[EmailService] SMTP not configured. Share link manually.'); - return true; - } - - const mailOptions = { - from: `"TeamUp" <${this.fromEmail}>`, - to: toEmail, - subject: `Invitation to join workshop: ${workshopName}`, - html: this.getWorkshopInvitationEmailHtml(inviterName, workshopName, inviteLink), - text: `You have been invited to join workshop "${workshopName}" on TeamUp.\n\n${inviterName} has invited you to collaborate.\n\nAccept invitation: ${inviteLink}` - }; - - try { - const info = await this.transporter.sendMail(mailOptions); - console.log('[EmailService] Message sent:', info.messageId); - return true; - } catch (error) { - console.error('[EmailService] Error sending email:', error); - console.log('[EmailService] Manual link share required:', inviteLink); - return true; - } - } - - private getInvitationEmailHtml(inviterName: string, projectTitle: string, inviteLink: string): string { - return ` - -Invitation - -
-
-

TeamUp

-
-

Project Invitation

-

${inviterName} has invited you to collaborate on the project:

-
-

${projectTitle}

-
- Accept Invitation -
-

- If you did not expect this invitation, you can safely ignore this email.
- Link: ${inviteLink} -

-
-
- -`; - } - - private getWorkshopInvitationEmailHtml(inviterName: string, workshopName: string, workshopLink: string): string { - return ` - -Invitation - -
-
-

TeamUp

-
-

Workshop Invitation

-

${inviterName} has invited you to join the workshop:

-
-

${workshopName}

-
- Accept Invitation -
-

- If you did not expect this invitation, you can safely ignore this email. -

-
-
- -`; - } - - private getJoinRequestEmailHtml(requesterName: string, postTitle: string, viewLink: string): string { - return ` - -Join Request - -
-
-

TeamUp

-
-

New Join Request

-

${requesterName} has requested to join your project:

-
-

${postTitle}

-
- View Request -
- -`; - } - - private getJoinResponseEmailHtml(postTitle: string, isApproved: boolean, projectLink: string): string { - return ` - -Request Update - -
-
-

TeamUp

-
-

Request Update

-

- ${isApproved - ? `Your request to join "${postTitle}" has been approved.` - : `Your request to join "${postTitle}" was not approved at this time.` - } -

- ${isApproved && projectLink ? ` - ` : ''} - ${!isApproved ? ` -

- Feel free to explore other projects looking for collaborators on the community page. -

` : ''} -
- -`; - } -} \ No newline at end of file diff --git a/backend/src/shared/constants/index.ts b/backend/src/shared/constants/index.ts new file mode 100644 index 0000000..a382098 --- /dev/null +++ b/backend/src/shared/constants/index.ts @@ -0,0 +1 @@ +export * from './routes'; diff --git a/backend/src/shared/constants/routes.ts b/backend/src/shared/constants/routes.ts new file mode 100644 index 0000000..e8bfc11 --- /dev/null +++ b/backend/src/shared/constants/routes.ts @@ -0,0 +1,148 @@ +export const API_PREFIX = '/api'; + +export const MODULE_BASE = { + AUTH: '/auth', + NOTIFICATIONS: '/notifications', + WORKSHOPS: '/workshops', + PERMISSION_CHECK: '/workshops/:workshopId/permissions', + CHAT: '/chat', + INVITES: '/invites', + ACTIVITY: '/', + TASKS: '/workshop-tasks', + USER_TASKS: '/users', + TEAM_TASKS: '/teams', + AUDIT: '/workshops/:workshopId/audit', + ROLES: '/workshops/:workshopId/roles', + TEAMS: '/workshops/:workshopId/teams', + PROJECTS: '/workshops/:workshopId/projects', + PROJECT_TASKS: '/workshops/:workshopId/projects/:projectId/tasks' +}; + +export const AUTH_ROUTES = { + REGISTER: '/register', + VERIFY_OTP: '/verify-otp', + RESEND_OTP: '/resend-otp', + LOGIN: '/login', + REFRESH_TOKEN: '/refresh-token', + FORGOT_PASSWORD: '/forgot-password', + RESET_PASSWORD: '/reset-password', + ME: '/me', + PROFILE: '/profile', + GOOGLE: '/google', + GOOGLE_CALLBACK: '/google/callback', + GITHUB: '/github', + GITHUB_CALLBACK: '/github/callback' +}; + +export const WORKSHOP_ROUTES = { + BASE: '/', + MY_WORKSHOPS: '/my-workshops', + PUBLIC: '/public', + UPVOTE: '/:workshopId/upvote', + DOWNVOTE: '/:workshopId/downvote', + CHECK_PERMISSION: '/:workshopId/permissions/check', + BY_ID: '/:workshopId', + MEMBERS: '/:workshopId/members', + PENDING_REQUESTS: '/:workshopId/pending-requests', + INVITE: '/:workshopId/invite', + JOIN: '/:workshopId/join', + APPROVE_REQUEST: '/:workshopId/approve/:membershipId', + REJECT_REQUEST: '/:workshopId/reject/:membershipId', + REVOKE_MEMBERSHIP: '/:workshopId/members/:userId', + LEAVE: '/:workshopId/leave', + ASSIGN_MANAGER: '/:workshopId/managers/:managerId', + REMOVE_MANAGER: '/:workshopId/managers/:managerId' +}; + +export const NOTIFICATION_ROUTES = { + BASE: '/', + UNREAD: '/unread', + COUNT: '/count', + MARK_READ: '/:id/read', + MARK_ALL_READ: '/read-all', + DELETE: '/:id' +}; + +export const TEAM_ROUTES = { + BASE: '/', + BY_ID: '/:id', + MEMBERS: '/:id/members', + MEMBER_BY_ID: '/:id/members/:userId', + USER_TEAMS: '/user/:userId' +}; + +export const ROLE_ROUTES = { + BASE: '/', + BY_ID: '/:id', + ASSIGN: '/:id/assign', + REVOKE: '/:id/assign/:userId', + USER_ROLES: '/user/:userId' +}; + +export const PERMISSION_ROUTES = { + CHECK: '/check' +}; + +export const PROJECT_ROUTES = { + BASE: '/', + ACCESSIBLE: '/accessible', + BY_ID: '/:projectId', + TEAMS: '/:projectId/teams', + TEAM_BY_ID: '/:projectId/teams/:teamId', + INDIVIDUALS: '/:projectId/individuals', + INDIVIDUAL_BY_ID: '/:projectId/individuals/:userId', + MANAGER: '/:projectId/manager', + MAINTAINERS: '/:projectId/maintainers', + MAINTAINER_BY_ID: '/:projectId/maintainers/:maintainerId' +}; + +export const TASK_ROUTES = { + BASE: '/', + BOARD: '/board', + BY_ID: '/:taskId', + STATUS: '/:taskId/status', + COMMENTS: '/:taskId/comments', + ATTACHMENTS: '/:taskId/attachments', + TEAMS: '/:taskId/teams', + INDIVIDUALS: '/:taskId/individuals', + ACTIVITY: '/:taskId/activity', + MY_TASKS: '/my-tasks', + TEAM_TASKS: '/:teamId/tasks' +}; + +export const AUDIT_ROUTES = { + BASE: '/', + RECENT: '/recent', + STATS: '/stats', + USER_ACTIVITY: '/user/:targetUserId', + USER_SUMMARY: '/user/:targetUserId/summary', + TARGET: '/target/:targetId' +}; + +export const ACTIVITY_ROUTES = { + WORKSHOP_ACTIVITY: '/workshops/:workshopId/activity', + WORKSHOP_STATS: '/workshops/:workshopId/activity/stats', + USER_ACTIVITY: '/users/:userId/activity', + ENTITY_ACTIVITY: '/activity/:entityType/:entityId', + RECENT: '/activity/recent' +}; + +export const CHAT_ROUTES = { + WORKSHOP_ROOMS: '/workshops/:workshopId/chat/rooms', + DIRECT: '/workshops/:workshopId/chat/direct', + BY_ID: '/rooms/:roomId', + MESSAGES: '/rooms/:roomId/messages', + MESSAGE_BY_ID: '/messages/:messageId', + MESSAGE_SEEN: '/messages/:messageId/seen', + ROOM_SEEN: '/rooms/:roomId/seen', + ROOM_UNREAD: '/rooms/:roomId/unread', + REACTIONS: '/messages/:messageId/reactions', + SEARCH: '/rooms/:roomId/search', + UPLOAD: '/upload', + UPLOAD_ONLY: '/upload-only' +}; + +export const INVITE_ROUTES = { + BY_TOKEN: '/:token', + ACCEPT: '/:token/accept' +}; diff --git a/backend/src/shared/interfaces/ICloudinaryService.ts b/backend/src/shared/interfaces/ICloudinaryService.ts new file mode 100644 index 0000000..ef2bbaa --- /dev/null +++ b/backend/src/shared/interfaces/ICloudinaryService.ts @@ -0,0 +1,20 @@ +export interface CloudinaryUploadResult { + publicId: string; + url: string; + secureUrl: string; + format: string; + resourceType: string; + bytes: number; + width?: number; + height?: number; + duration?: number; +} + +export interface ICloudinaryService { + uploadImage(file: Express.Multer.File, folder?: string): Promise; + uploadAudio(file: Express.Multer.File, folder?: string): Promise; + uploadDocument(file: Express.Multer.File, folder?: string): Promise; + deleteFile(publicId: string, resourceType?: 'image' | 'video' | 'raw'): Promise; + getOptimizedImageUrl(publicId: string, width?: number, height?: number): string; + getSignedUrl(publicId: string, expiresIn?: number): string; +} diff --git a/backend/src/shared/interfaces/IEmailProvider.ts b/backend/src/shared/interfaces/IEmailProvider.ts new file mode 100644 index 0000000..b3eb9a8 --- /dev/null +++ b/backend/src/shared/interfaces/IEmailProvider.ts @@ -0,0 +1,3 @@ +export interface IEmailProvider { + sendEmail(to: string, subject: string, html: string): Promise; +} diff --git a/backend/src/shared/interfaces/IEmailService.ts b/backend/src/shared/interfaces/IEmailService.ts new file mode 100644 index 0000000..157c038 --- /dev/null +++ b/backend/src/shared/interfaces/IEmailService.ts @@ -0,0 +1,28 @@ +export interface IEmailService { + sendProjectInvitation( + toEmail: string, + inviterName: string, + projectTitle: string, + projectId: string, + inviteToken: string + ): Promise; + sendJoinRequestNotification( + toEmail: string, + requesterName: string, + postTitle: string, + postId: string + ): Promise; + sendJoinRequestResponse( + toEmail: string, + postTitle: string, + status: 'approved' | 'rejected', + projectId?: string + ): Promise; + sendWorkshopInvitation( + toEmail: string, + inviterName: string, + workshopName: string, + workshopId: string, + token: string + ): Promise; +} diff --git a/backend/src/shared/interfaces/IHashProvider.ts b/backend/src/shared/interfaces/IHashProvider.ts new file mode 100644 index 0000000..d17bb66 --- /dev/null +++ b/backend/src/shared/interfaces/IHashProvider.ts @@ -0,0 +1,4 @@ +export interface IHashProvider { + hash(payload: string): Promise; + compare(payload: string, hashed: string): Promise; +} diff --git a/backend/src/shared/interfaces/ISocketService.ts b/backend/src/shared/interfaces/ISocketService.ts new file mode 100644 index 0000000..3b8f9f7 --- /dev/null +++ b/backend/src/shared/interfaces/ISocketService.ts @@ -0,0 +1,11 @@ +export interface ISocketService { + emitToWorkshop(workshopId: string, event: string, data: any): void; + emitToTeam(teamId: string, event: string, data: any): void; + emitToUser(userId: string, event: string, data: any): void; + emitToProject(projectId: string, event: string, data: any): void; + emitToChatRoom(roomId: string, event: string, data: any): void; + emitToCommunity(event: string, data: any): void; + emitToAll(event: string, data: any): void; + isUserOnline(userId: string): boolean; + getOnlineUsersCount(): number; +} diff --git a/backend/src/shared/interfaces/ITokenProvider.ts b/backend/src/shared/interfaces/ITokenProvider.ts new file mode 100644 index 0000000..eda2874 --- /dev/null +++ b/backend/src/shared/interfaces/ITokenProvider.ts @@ -0,0 +1,11 @@ +export interface JwtPayload { + id: string; + email: string; +} + +export interface ITokenProvider { + generateToken(payload: JwtPayload): string; + generateRefreshToken(payload: JwtPayload): string; + verifyToken(token: string): JwtPayload; + verifyRefreshToken(token: string): JwtPayload; +} diff --git a/backend/src/middlewares/auth.ts b/backend/src/shared/middlewares/auth.ts similarity index 96% rename from backend/src/middlewares/auth.ts rename to backend/src/shared/middlewares/auth.ts index d26d035..16fc84d 100644 --- a/backend/src/middlewares/auth.ts +++ b/backend/src/shared/middlewares/auth.ts @@ -1,5 +1,5 @@ import { Response, NextFunction, Request } from 'express'; -import { verifyToken } from '../config/jwt'; +import { verifyToken } from '../../config/jwt'; import { AuthenticationError } from '../utils/errors'; export const authenticate = (req: Request, _res: Response, next: NextFunction): void => { diff --git a/backend/src/shared/middlewares/di.ts b/backend/src/shared/middlewares/di.ts new file mode 100644 index 0000000..02aa969 --- /dev/null +++ b/backend/src/shared/middlewares/di.ts @@ -0,0 +1,10 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../types/index'; +import { Container } from '../../di/types'; + +export const injectContainer = (container: Container) => { + return (req: AuthRequest, _res: Response, next: NextFunction) => { + req.container = container; + next(); + }; +}; diff --git a/backend/src/shared/middlewares/errorMiddleware.ts b/backend/src/shared/middlewares/errorMiddleware.ts new file mode 100644 index 0000000..697ef65 --- /dev/null +++ b/backend/src/shared/middlewares/errorMiddleware.ts @@ -0,0 +1,44 @@ +import { NextFunction, Request, Response } from 'express'; +import { AppError } from '../utils/errors'; +import { env } from '../../config/env'; + +export const asyncHandler = (fn: (req: Request, res: Response, next: NextFunction) => Promise) => + (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; + +export const errorHandler = ( + err: Error | AppError, + _req: Request, + res: Response, + _next: NextFunction +): void => { + let statusCode = 500; + let message = 'Internal Server Error'; + let errors: any[] | undefined; + + if (err instanceof AppError) { + statusCode = err.statusCode; + message = err.message; + // AppError doesn't have errors property in def, ValidationError does details. + if ('details' in err) { + errors = [(err as any).details]; + } + } else if (err instanceof Error) { + message = err.message; + } + + // Filter out sensitive error details in production + if (env.NODE_ENV === 'production' && statusCode === 500) { + message = 'Internal Server Error'; + } + + const response: any = { + success: false, + message, + ...(errors && { errors }), + ...(env.NODE_ENV !== 'production' && { stack: (err as Error).stack }), + }; + + res.status(statusCode).json(response); +}; diff --git a/backend/src/shared/middlewares/index.ts b/backend/src/shared/middlewares/index.ts new file mode 100644 index 0000000..f68235a --- /dev/null +++ b/backend/src/shared/middlewares/index.ts @@ -0,0 +1,4 @@ +export { authenticate as authMiddleware, optionalAuthenticate } from './auth'; +export { requireWorkshopMembership, requireWorkshopManager, requirePermission } from './permission'; +export { errorHandler } from './errorMiddleware'; +export { injectContainer } from './di'; diff --git a/backend/src/middlewares/permission.ts b/backend/src/shared/middlewares/permission.ts similarity index 84% rename from backend/src/middlewares/permission.ts rename to backend/src/shared/middlewares/permission.ts index a394119..231d5a9 100644 --- a/backend/src/middlewares/permission.ts +++ b/backend/src/shared/middlewares/permission.ts @@ -1,6 +1,5 @@ import { Response, NextFunction } from 'express'; -import { AuthRequest } from '../types'; -import { PermissionService } from '../services/PermissionService'; +import { AuthRequest } from '../types/index'; import { AuthorizationError } from '../utils/errors'; export const requirePermission = (action: string, resource: string, scopeParam?: string) => { @@ -13,7 +12,11 @@ export const requirePermission = (action: string, resource: string, scopeParam?: throw new AuthorizationError('Workshop ID is required'); } - const permissionService = PermissionService.getInstance(); + if (!req.container) { + throw new Error('DI Container not found in request'); + } + + const permissionService = req.container.permissionSrv; const context: any = {}; @@ -27,7 +30,6 @@ export const requirePermission = (action: string, resource: string, scopeParam?: } } } else { - if (req.params.projectId) { context.projectId = req.params.projectId; } @@ -64,7 +66,11 @@ export const requireWorkshopMembership = async (req: AuthRequest, _res: Response throw new AuthorizationError('Workshop ID is required'); } - const permissionService = PermissionService.getInstance(); + if (!req.container) { + throw new Error('DI Container not found in request'); + } + + const permissionService = req.container.permissionSrv; const hasPermission = await permissionService.checkPermission( userId, @@ -92,7 +98,11 @@ export const requireWorkshopOwner = async (req: AuthRequest, _res: Response, nex throw new AuthorizationError('Workshop ID is required'); } - const permissionService = PermissionService.getInstance(); + if (!req.container) { + throw new Error('DI Container not found in request'); + } + + const permissionService = req.container.permissionSrv; const hasPermission = await permissionService.checkPermission( userId, workshopId, @@ -119,7 +129,11 @@ export const requireWorkshopManager = async (req: AuthRequest, _res: Response, n throw new AuthorizationError('Workshop ID is required'); } - const permissionService = PermissionService.getInstance(); + if (!req.container) { + throw new Error('DI Container not found in request'); + } + + const permissionService = req.container.permissionSrv; const hasPermission = await permissionService.checkPermission( userId, workshopId, diff --git a/backend/src/middlewares/validation.ts b/backend/src/shared/middlewares/validation.ts similarity index 100% rename from backend/src/middlewares/validation.ts rename to backend/src/shared/middlewares/validation.ts diff --git a/backend/src/shared/providers/EmailProvider.ts b/backend/src/shared/providers/EmailProvider.ts new file mode 100644 index 0000000..09160bc --- /dev/null +++ b/backend/src/shared/providers/EmailProvider.ts @@ -0,0 +1,26 @@ +import nodemailer from 'nodemailer'; +import { IEmailProvider } from '../interfaces/IEmailProvider'; + +export class EmailProvider implements IEmailProvider { + private transporter; + + constructor() { + this.transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: parseInt(process.env.EMAIL_PORT || '587'), + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + } + + async sendEmail(to: string, subject: string, html: string): Promise { + await this.transporter.sendMail({ + from: `"Team Up" <${process.env.EMAIL_FROM || 'noreply@teamup.com'}>`, + to, + subject, + html, + }); + } +} diff --git a/backend/src/shared/providers/HashProvider.ts b/backend/src/shared/providers/HashProvider.ts new file mode 100644 index 0000000..d275c15 --- /dev/null +++ b/backend/src/shared/providers/HashProvider.ts @@ -0,0 +1,12 @@ +import bcrypt from 'bcryptjs'; +import { IHashProvider } from '../interfaces/IHashProvider'; + +export class HashProvider implements IHashProvider { + async hash(payload: string): Promise { + return await bcrypt.hash(payload, 10); + } + + async compare(payload: string, hashed: string): Promise { + return await bcrypt.compare(payload, hashed); + } +} diff --git a/backend/src/shared/providers/TokenProvider.ts b/backend/src/shared/providers/TokenProvider.ts new file mode 100644 index 0000000..b05b18b --- /dev/null +++ b/backend/src/shared/providers/TokenProvider.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken'; +import { env } from '../../config/env'; +import { ITokenProvider, JwtPayload } from '../interfaces/ITokenProvider'; + +export class TokenProvider implements ITokenProvider { + private readonly secret = env.JWT_SECRET; + private readonly refreshSecret = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret'; + + generateToken(payload: JwtPayload): string { + return jwt.sign(payload, this.secret, { expiresIn: '15m' }); + } + + generateRefreshToken(payload: JwtPayload): string { + return jwt.sign(payload, this.refreshSecret, { expiresIn: '7d' }); + } + + verifyToken(token: string): JwtPayload { + return jwt.verify(token, this.secret) as JwtPayload; + } + + verifyRefreshToken(token: string): JwtPayload { + return jwt.verify(token, this.refreshSecret) as JwtPayload; + } +} diff --git a/backend/src/shared/providers/index.ts b/backend/src/shared/providers/index.ts new file mode 100644 index 0000000..8bc24e6 --- /dev/null +++ b/backend/src/shared/providers/index.ts @@ -0,0 +1,3 @@ +export * from "./TokenProvider"; +export * from "./EmailProvider"; +export * from "./HashProvider"; diff --git a/backend/src/services/CloudinaryService.ts b/backend/src/shared/services/CloudinaryService.ts similarity index 89% rename from backend/src/services/CloudinaryService.ts rename to backend/src/shared/services/CloudinaryService.ts index 0f5a483..40213ff 100644 --- a/backend/src/services/CloudinaryService.ts +++ b/backend/src/shared/services/CloudinaryService.ts @@ -1,25 +1,9 @@ import { v2 as cloudinary, UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'; import { Readable } from 'stream'; +import { ICloudinaryService, CloudinaryUploadResult } from '../interfaces/ICloudinaryService'; -export interface CloudinaryUploadResult { - publicId: string; - url: string; - secureUrl: string; - format: string; - resourceType: string; - bytes: number; - width?: number; - height?: number; - duration?: number; -} - -export class CloudinaryService { +export class CloudinaryService implements ICloudinaryService { constructor() { - cloudinary.config({ - cloud_name: process.env.CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET - }); } async uploadImage( diff --git a/backend/src/shared/services/EmailService.ts b/backend/src/shared/services/EmailService.ts new file mode 100644 index 0000000..9792623 --- /dev/null +++ b/backend/src/shared/services/EmailService.ts @@ -0,0 +1,115 @@ +import { IEmailProvider } from '../interfaces/IEmailProvider'; +import { IEmailService } from '../interfaces/IEmailService'; +import { + projectInvitationTemplate, + workshopInvitationTemplate, + joinRequestTemplate, + joinResponseTemplate +} from '../templates/email'; + +export class EmailService implements IEmailService { + private appUrl: string; + + constructor(private emailProv: IEmailProvider) { + this.appUrl = process.env.FRONTEND_URL || process.env.APP_URL || 'http://localhost:3000'; + } + + async sendProjectInvitation( + toEmail: string, + inviterName: string, + projectTitle: string, + _projectId: string, + inviteToken: string + ): Promise { + const inviteLink = `${this.appUrl}/invite/${inviteToken}`; + + const subject = `Invitation to join project: ${projectTitle}`; + const html = this.getInvitationEmailHtml(inviterName, projectTitle, inviteLink); + + try { + await this.emailProv.sendEmail(toEmail, subject, html); + return true; + } catch (error) { + console.error('[EmailService] Error sending project invitation:', error); + return false; + } + } + + async sendJoinRequestNotification( + toEmail: string, + requesterName: string, + postTitle: string, + postId: string + ): Promise { + const viewLink = `${this.appUrl}/community?post=${postId}`; + + const subject = `New join request: ${postTitle}`; + const html = this.getJoinRequestEmailHtml(requesterName, postTitle, viewLink); + + try { + await this.emailProv.sendEmail(toEmail, subject, html); + return true; + } catch (error) { + console.error('[EmailService] Error sending join request notification:', error); + return false; + } + } + + async sendJoinRequestResponse( + toEmail: string, + postTitle: string, + status: 'approved' | 'rejected', + projectId?: string + ): Promise { + const isApproved = status === 'approved'; + const projectLink = projectId ? `${this.appUrl}/projects/${projectId}` : ''; + + const subject = `Update on your request: ${postTitle}`; + const html = this.getJoinResponseEmailHtml(postTitle, isApproved, projectLink); + + try { + await this.emailProv.sendEmail(toEmail, subject, html); + return true; + } catch (error) { + console.error('[EmailService] Error sending join request response:', error); + return false; + } + } + + async sendWorkshopInvitation( + toEmail: string, + inviterName: string, + workshopName: string, + _workshopId: string, + token: string + ): Promise { + const inviteLink = `${this.appUrl}/invite/${token}`; + + const subject = `Invitation to join workshop: ${workshopName}`; + const html = this.getWorkshopInvitationEmailHtml(inviterName, workshopName, inviteLink); + + try { + await this.emailProv.sendEmail(toEmail, subject, html); + return true; + } catch (error) { + console.error('[EmailService] Error sending workshop invitation:', error); + return false; + } + } + + private getInvitationEmailHtml(inviterName: string, projectTitle: string, inviteLink: string): string { + return projectInvitationTemplate(inviterName, projectTitle, inviteLink); + } + + private getWorkshopInvitationEmailHtml(inviterName: string, workshopName: string, workshopLink: string): string { + return workshopInvitationTemplate(inviterName, workshopName, workshopLink); + } + + private getJoinRequestEmailHtml(requesterName: string, postTitle: string, viewLink: string): string { + return joinRequestTemplate(requesterName, postTitle, viewLink); + } + + private getJoinResponseEmailHtml(postTitle: string, isApproved: boolean, projectLink: string): string { + return joinResponseTemplate(postTitle, isApproved, projectLink); + } +} \ No newline at end of file diff --git a/backend/src/shared/templates/email/index.ts b/backend/src/shared/templates/email/index.ts new file mode 100644 index 0000000..e44c3d4 --- /dev/null +++ b/backend/src/shared/templates/email/index.ts @@ -0,0 +1,6 @@ +export * from './verificationOtp'; +export * from './passwordReset'; +export * from './projectInvitation'; +export * from './workshopInvitation'; +export * from './joinRequest'; +export * from './joinResponse'; diff --git a/backend/src/shared/templates/email/joinRequest.ts b/backend/src/shared/templates/email/joinRequest.ts new file mode 100644 index 0000000..82e8550 --- /dev/null +++ b/backend/src/shared/templates/email/joinRequest.ts @@ -0,0 +1,19 @@ +export const joinRequestTemplate = (requesterName: string, postTitle: string, viewLink: string): string => ` + + +Join Request + +
+
+

TeamUp

+
+

New Join Request

+

${requesterName} has requested to join your project:

+
+

${postTitle}

+
+ View Request +
+ + +`; diff --git a/backend/src/shared/templates/email/joinResponse.ts b/backend/src/shared/templates/email/joinResponse.ts new file mode 100644 index 0000000..bed0b0a --- /dev/null +++ b/backend/src/shared/templates/email/joinResponse.ts @@ -0,0 +1,28 @@ +export const joinResponseTemplate = (postTitle: string, isApproved: boolean, projectLink: string): string => ` + + +Request Update + +
+
+

TeamUp

+
+

Request Update

+

+ ${isApproved + ? `Your request to join "${postTitle}" has been approved.` + : `Your request to join "${postTitle}" was not approved at this time.` + } +

+ ${isApproved && projectLink ? ` + ` : ''} + ${!isApproved ? ` +

+ Feel free to explore other projects looking for collaborators on the community page. +

` : ''} +
+ + +`; diff --git a/backend/src/shared/templates/email/passwordReset.ts b/backend/src/shared/templates/email/passwordReset.ts new file mode 100644 index 0000000..a47e49b --- /dev/null +++ b/backend/src/shared/templates/email/passwordReset.ts @@ -0,0 +1,17 @@ +export const passwordResetTemplate = (userName: string, resetUrl: string): string => ` +
+

Reset Your Password

+

Hi ${userName},

+

You requested to reset your password for your Team Up account. Click the button below to reset it:

+ +

This link will expire in 1 hour for security reasons.

+

If you didn't request this password reset, please ignore this email.

+
+

+ If the button doesn't work, copy and paste this link into your browser:
+ ${resetUrl} +

+
+`; diff --git a/backend/src/shared/templates/email/projectInvitation.ts b/backend/src/shared/templates/email/projectInvitation.ts new file mode 100644 index 0000000..5b4941c --- /dev/null +++ b/backend/src/shared/templates/email/projectInvitation.ts @@ -0,0 +1,25 @@ +export const projectInvitationTemplate = (inviterName: string, projectTitle: string, inviteLink: string): string => ` + + +Invitation + +
+
+

TeamUp

+
+

Project Invitation

+

${inviterName} has invited you to collaborate on the project:

+
+

${projectTitle}

+
+ Accept Invitation +
+

+ If you did not expect this invitation, you can safely ignore this email.
+ Link: ${inviteLink} +

+
+
+ + +`; diff --git a/backend/src/shared/templates/email/verificationOtp.ts b/backend/src/shared/templates/email/verificationOtp.ts new file mode 100644 index 0000000..bb7868f --- /dev/null +++ b/backend/src/shared/templates/email/verificationOtp.ts @@ -0,0 +1,11 @@ +export const verificationOtpTemplate = (otp: string): string => ` +
+

Welcome to Team Up!

+

Your verification code is:

+
+ ${otp} +
+

This code will expire in 10 minutes.

+

If you didn't request this, please ignore this email.

+
+`; diff --git a/backend/src/shared/templates/email/workshopInvitation.ts b/backend/src/shared/templates/email/workshopInvitation.ts new file mode 100644 index 0000000..9dccd19 --- /dev/null +++ b/backend/src/shared/templates/email/workshopInvitation.ts @@ -0,0 +1,24 @@ +export const workshopInvitationTemplate = (inviterName: string, workshopName: string, workshopLink: string): string => ` + + +Invitation + +
+
+

TeamUp

+
+

Workshop Invitation

+

${inviterName} has invited you to join the workshop:

+
+

${workshopName}

+
+ Accept Invitation +
+

+ If you did not expect this invitation, you can safely ignore this email. +

+
+
+ + +`; diff --git a/backend/src/shared/types/index.ts b/backend/src/shared/types/index.ts new file mode 100644 index 0000000..7520cee --- /dev/null +++ b/backend/src/shared/types/index.ts @@ -0,0 +1,21 @@ + +import { Request } from 'express'; +import { Container as DIContainer } from '../../di/types'; + +declare global { + namespace Express { + interface User { + id: string; + email: string; + } + } +} + +export type AuthRequest = Request & { + container?: DIContainer; +}; + +export interface Pagination { + page: number; + limit: number; +} diff --git a/backend/src/utils/errors.ts b/backend/src/shared/utils/errors.ts similarity index 100% rename from backend/src/utils/errors.ts rename to backend/src/shared/utils/errors.ts diff --git a/backend/src/services/SocketService.ts b/backend/src/socket/SocketService.ts similarity index 90% rename from backend/src/services/SocketService.ts rename to backend/src/socket/SocketService.ts index 99b78da..8722b8b 100644 --- a/backend/src/services/SocketService.ts +++ b/backend/src/socket/SocketService.ts @@ -1,19 +1,23 @@ import { Server as SocketIOServer, Socket } from 'socket.io'; import { Server as HTTPServer } from 'http'; -import { verifyToken } from '../config/jwt'; -import { UserRepository } from '../repositories/UserRepository'; +import { IUserRepository } from '../modules/user/interfaces/IUserRepository'; +import { ITokenProvider } from '../shared/interfaces/ITokenProvider'; +import { ISocketService } from '../shared/interfaces/ISocketService'; interface AuthenticatedSocket extends Socket { userId?: string; email?: string; } -export class SocketService { +export class SocketService implements ISocketService { private io: SocketIOServer; - private userRepository: UserRepository; private connectedUsers: Map = new Map(); - constructor(httpServer: HTTPServer) { + constructor( + httpServer: HTTPServer, + private userRepository: IUserRepository, + private tokenProvider: ITokenProvider + ) { this.io = new SocketIOServer(httpServer, { cors: { origin: process.env.FRONTEND_URL || 'http://localhost:3000', @@ -22,7 +26,6 @@ export class SocketService { } }); - this.userRepository = new UserRepository(); this.setupMiddleware(); this.setupConnectionHandlers(); } @@ -36,7 +39,7 @@ export class SocketService { return next(new Error('Authentication error: No token provided')); } - const decoded = verifyToken(token); + const decoded = this.tokenProvider.verifyToken(token); socket.userId = decoded.id; socket.email = decoded.email; @@ -50,7 +53,7 @@ export class SocketService { private setupConnectionHandlers(): void { this.io.on('connection', async (socket: AuthenticatedSocket) => { const userId = socket.userId!; - console.log(`✅ User connected: ${userId} (${socket.id})`); + console.log(`User connected: ${userId} (${socket.id})`); if (!this.connectedUsers.has(userId)) { this.connectedUsers.set(userId, []); @@ -142,7 +145,7 @@ export class SocketService { }); socket.on('disconnect', async () => { - console.log(`❌ User disconnected: ${userId} (${socket.id})`); + console.log(`User disconnected: ${userId} (${socket.id})`); const userSockets = this.connectedUsers.get(userId); if (userSockets) { diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts deleted file mode 100644 index d30cd9e..0000000 --- a/backend/src/types/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Request } from 'express'; -import { Types, Document } from 'mongoose'; - -export * from './workshop'; - -declare global { - namespace Express { - interface User { - id: string; - email: string; - } - } -} - -export type AuthRequest = Request; - -export interface SocketUser { - userId: string; - socketId: string; - email: string; -} - -export enum TaskStatus { - TODO = 'todo', - IN_PROGRESS = 'in_progress', - DONE = 'done' -} - -export enum ProjectCategory { - WEB_DEVELOPMENT = 'web_development', - MOBILE_DEVELOPMENT = 'mobile_development', - DATA_SCIENCE = 'data_science', - DESIGN = 'design', - MARKETING = 'marketing', - OTHER = 'other' -} - -export enum CommitmentType { - FULL_TIME = 'full_time', - PART_TIME = 'part_time', - FREELANCE = 'freelance', - VOLUNTEER = 'volunteer', - OPEN_SOURCE = 'open_source' -} - -export enum SortOrder { - NEW = 'new', - TOP = 'top', - TRENDING = 'trending' -} - -export enum NotificationType { - TASK_ASSIGNED = 'task_assigned', - TASK_UPDATED = 'task_updated', - MESSAGE = 'message', - PROJECT_INVITE = 'project_invite', - PROJECT_ASSIGNED = 'project_assigned', - JOIN_REQUEST = 'join_request', - COMMENT = 'comment', - WORKSHOP_INVITE = 'workshop_invite', - TEAM_ASSIGNED = 'team_assigned', - ROLE_ASSIGNED = 'role_assigned', - MEMBERSHIP_APPROVED = 'membership_approved', - MEMBERSHIP_REJECTED = 'membership_rejected' -} - -export interface IUser extends Document { - _id: Types.ObjectId; - name: string; - email: string; - password: string; - profilePhoto?: string; - skills: string[]; - interests: string[]; - isOnline: boolean; - lastActive: Date; - createdAt: Date; - updatedAt: Date; - isVerified?: boolean; - verificationToken?: string; - googleId?: string; - githubId?: string; -} - -export interface ICommunityProject extends Document { - _id: Types.ObjectId; - title: string; - description: string; - category: ProjectCategory; - commitmentType: CommitmentType; - tags: string[]; - requiredSkills: string[]; - owner: Types.ObjectId; - votes: IVote[]; - upvoteCount: number; - downvoteCount: number; - voteScore: number; - comments: IComment[]; - joinRequests: IJoinRequest[]; - createdAt: Date; - updatedAt: Date; -} - -export interface IVote { - userId: Types.ObjectId; - voteType: 'upvote' | 'downvote'; - createdAt: Date; -} - -export interface IComment { - _id: Types.ObjectId; - user: Types.ObjectId; - content: string; - createdAt: Date; - updatedAt?: Date; -} - -export interface IJoinRequest { - _id: Types.ObjectId; - user: Types.ObjectId; - status: 'pending' | 'approved' | 'rejected'; - message?: string; - createdAt: Date; - updatedAt: Date; -} - -export interface INotification extends Document { - _id: Types.ObjectId; - user: Types.ObjectId; - type: NotificationType; - title: string; - message: string; - relatedProject?: Types.ObjectId; - relatedWorkshop?: Types.ObjectId; - relatedTask?: Types.ObjectId; - relatedUser?: Types.ObjectId; - isRead: boolean; - createdAt: Date; -} - -export interface IPendingUser extends Document { - _id: Types.ObjectId; - name: string; - email: string; - password: string; - otp: string; - otpExpires: Date; - createdAt: Date; -} \ No newline at end of file diff --git a/backend/src/types/workshop.ts b/backend/src/types/workshop.ts deleted file mode 100644 index 4b9cd08..0000000 --- a/backend/src/types/workshop.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { Types, Document } from 'mongoose'; - -export enum WorkshopVisibility { - PRIVATE = 'private', - PUBLIC = 'public' -} - -export enum MembershipState { - PENDING = 'pending', - ACTIVE = 'active', - REMOVED = 'removed' -} - -export enum MembershipSource { - INVITATION = 'invitation', - JOIN_REQUEST = 'join_request', - OPEN_ACCESS = 'open_access' -} - -export enum TaskType { - BUG = 'bug', - FEATURE = 'feature', - ENHANCEMENT = 'enhancement', - DISCUSSION = 'discussion' -} - -export enum ProjectCategory { - WEB_DEVELOPMENT = 'web_development', - MOBILE_DEVELOPMENT = 'mobile_development', - DATA_SCIENCE = 'data_science', - DESIGN = 'design', - MARKETING = 'marketing', - OTHER = 'other' -} - -export enum PermissionType { - GRANT = 'grant', - DENY = 'deny' -} - -export enum PermissionScope { - WORKSHOP = 'workshop', - PROJECT = 'project', - TEAM = 'team', - INDIVIDUAL = 'individual' -} - -export enum AuditAction { - - WORKSHOP_CREATED = 'workshop_created', - WORKSHOP_UPDATED = 'workshop_updated', - WORKSHOP_DELETED = 'workshop_deleted', - - MANAGER_ASSIGNED = 'manager_assigned', - MANAGER_REMOVED = 'manager_removed', - - MEMBER_INVITED = 'member_invited', - MEMBER_JOINED = 'member_joined', - MEMBER_LEFT = 'member_left', - MEMBER_REMOVED = 'member_removed', - JOIN_REQUEST_APPROVED = 'join_request_approved', - JOIN_REQUEST_REJECTED = 'join_request_rejected', - - TEAM_CREATED = 'team_created', - TEAM_UPDATED = 'team_updated', - TEAM_DELETED = 'team_deleted', - TEAM_MEMBER_ADDED = 'team_member_added', - TEAM_MEMBER_REMOVED = 'team_member_removed', - - PROJECT_CREATED = 'project_created', - PROJECT_UPDATED = 'project_updated', - PROJECT_DELETED = 'project_deleted', - PROJECT_TEAM_ASSIGNED = 'project_team_assigned', - PROJECT_TEAM_REMOVED = 'project_team_removed', - PROJECT_INDIVIDUAL_ASSIGNED = 'project_individual_assigned', - PROJECT_INDIVIDUAL_REMOVED = 'project_individual_removed', - PROJECT_MANAGER_ASSIGNED = 'project_manager_assigned', - PROJECT_MAINTAINER_ASSIGNED = 'project_maintainer_assigned', - - ROLE_CREATED = 'role_created', - ROLE_UPDATED = 'role_updated', - ROLE_DELETED = 'role_deleted', - ROLE_ASSIGNED = 'role_assigned', - ROLE_REVOKED = 'role_revoked', - - PERMISSION_CHANGED = 'permission_changed', - PERMISSION_DENIED = 'permission_denied', - UNAUTHORIZED_ACCESS = 'unauthorized_access', - - TASK_CREATED = 'task_created', - TASK_UPDATED = 'task_updated', - TASK_DELETED = 'task_deleted', - TASK_ASSIGNED = 'task_assigned', - TASK_STATUS_CHANGED = 'task_status_changed' -} - -export interface IWorkshopSettings { - - allowOpenContribution: boolean; - - requireApprovalForJoin: boolean; - - publicInfoFields: string[]; -} - -export interface IWorkshop extends Document { - _id: Types.ObjectId; - name: string; - description: string; - visibility: WorkshopVisibility; - category: ProjectCategory; - tags: string[]; - requiredSkills: string[]; - owner: Types.ObjectId; - managers: Types.ObjectId[]; - settings: IWorkshopSettings; - votes: { - userId: Types.ObjectId; - voteType: 'upvote' | 'downvote'; - createdAt: Date; - }[]; - upvoteCount: number; - downvoteCount: number; - voteScore: number; - createdAt: Date; - updatedAt: Date; -} - -export interface IMembership extends Document { - _id: Types.ObjectId; - workshop: Types.ObjectId; - user: Types.ObjectId; - state: MembershipState; - source: MembershipSource; - invitedBy?: Types.ObjectId; - joinedAt?: Date; - removedAt?: Date; - removedBy?: Types.ObjectId; - createdAt: Date; - updatedAt: Date; -} - -export interface ITeamRole { - name: string; - permissions: string[]; - members: Types.ObjectId[]; -} - -export interface ITeam extends Document { - _id: Types.ObjectId; - workshop: Types.ObjectId; - name: string; - description: string; - members: Types.ObjectId[]; - internalRoles: ITeamRole[]; - createdAt: Date; - updatedAt: Date; -} - -export interface IPermission { - action: string; - resource: string; - type: PermissionType; -} - -export interface IRole extends Document { - _id: Types.ObjectId; - workshop: Types.ObjectId; - name: string; - description: string; - permissions: IPermission[]; - scope: PermissionScope; - scopeId?: Types.ObjectId; - createdAt: Date; - updatedAt: Date; -} - -export interface IRoleAssignment extends Document { - _id: Types.ObjectId; - workshop: Types.ObjectId; - role: Types.ObjectId; - user: Types.ObjectId; - scope: PermissionScope; - scopeId?: Types.ObjectId; - assignedBy: Types.ObjectId; - createdAt: Date; -} - -export interface IWorkflowTransition { - from: string; - to: string; - allowedRoles?: string[]; -} - -export interface ITaskWorkflow { - statuses: string[]; - transitions: IWorkflowTransition[]; -} - -export interface IProjectSettings { - allowExternalContribution: boolean; - taskWorkflow: ITaskWorkflow; -} - -export interface IWorkshopProject extends Document { - _id: Types.ObjectId; - workshop: Types.ObjectId; - name: string; - description: string; - assignedTeams: Types.ObjectId[]; - assignedIndividuals: Types.ObjectId[]; - projectManager?: Types.ObjectId; - maintainers: Types.ObjectId[]; - settings: IProjectSettings; - createdAt: Date; - updatedAt: Date; -} - -export interface ITaskActivity { - user: Types.ObjectId; - action: string; - changes: Record; - timestamp: Date; -} - -export interface ITaskComment { - _id: Types.ObjectId; - user: Types.ObjectId; - content: string; - mentions: Types.ObjectId[]; - createdAt: Date; - updatedAt: Date; - isEdited: boolean; -} - -export interface ITaskStatusHistory { - _id: Types.ObjectId; - status: string; - changedBy: Types.ObjectId; - changedAt: Date; - comment?: string; - duration?: number; -} - -export interface ITaskAttachment { - _id: Types.ObjectId; - fileName: string; - fileUrl: string; - fileType: string; - fileSize: number; - uploadedBy: Types.ObjectId; - uploadedAt: Date; -} - -export interface IRecurrencePattern { - frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'; - interval: number; - daysOfWeek?: number[]; - dayOfMonth?: number; - endDate?: Date; - occurrences?: number; -} - -export interface IWorkshopTask extends Document { - _id: Types.ObjectId; - workshop: Types.ObjectId; - project?: Types.ObjectId; - - title: string; - description: string; - type: TaskType; - status: string; - - parentTask?: Types.ObjectId; - childTasks: Types.ObjectId[]; - blockedBy: Types.ObjectId[]; - blocking: Types.ObjectId[]; - dependencies: Types.ObjectId[]; - - primaryOwner?: Types.ObjectId; - assignedTeams: Types.ObjectId[]; - assignedIndividuals: Types.ObjectId[]; - contributors: Types.ObjectId[]; - watchers: Types.ObjectId[]; - - priority: number; - severity: number; - labels: string[]; - tags: string[]; - - estimatedHours?: number; - actualHours?: number; - startDate?: Date; - dueDate?: Date; - completedAt?: Date; - - statusHistory: ITaskStatusHistory[]; - activityHistory: ITaskActivity[]; - - comments: ITaskComment[]; - attachments: ITaskAttachment[]; - linkedResources: { - chatRooms?: Types.ObjectId[]; - documents?: Types.ObjectId[]; - relatedTasks?: Types.ObjectId[]; - }; - - isRecurring: boolean; - recurrencePattern?: IRecurrencePattern; - autoAssignmentRules?: Record; - - customFields: Record; - - createdBy: Types.ObjectId; - createdAt: Date; - updatedAt: Date; -} - -export interface IAuditLog extends Document { - _id: Types.ObjectId; - workshop: Types.ObjectId; - action: AuditAction; - actor: Types.ObjectId; - target?: Types.ObjectId; - targetType?: string; - details: Record; - timestamp: Date; -} - -export interface CreateWorkshopDTO { - name: string; - description: string; - visibility: WorkshopVisibility; - category: ProjectCategory; - tags?: string[]; - requiredSkills?: string[]; - settings?: Partial; -} - -export interface UpdateWorkshopDTO { - name?: string; - description?: string; - visibility?: WorkshopVisibility; - category?: ProjectCategory; - tags?: string[]; - requiredSkills?: string[]; - settings?: Partial; -} - -export interface CreateTeamDTO { - name: string; - description?: string; -} - -export interface UpdateTeamDTO { - name?: string; - description?: string; -} - -export interface CreateRoleDTO { - name: string; - description?: string; - permissions: IPermission[]; - scope: PermissionScope; - scopeId?: string; -} - -export interface UpdateRoleDTO { - name?: string; - description?: string; - permissions?: IPermission[]; -} - -export interface CreateWorkshopProjectDTO { - name: string; - description: string; - settings?: Partial; -} - -export interface UpdateWorkshopProjectDTO { - name?: string; - description?: string; - settings?: Partial; -} - -export interface CreateWorkshopTaskDTO { - title: string; - description?: string; - type: TaskType; - priority?: number; - severity?: number; - labels?: string[]; - tags?: string[]; - assignedTeams?: string[]; - assignedIndividuals?: string[]; - - parentTask?: string; - primaryOwner?: string; - contributors?: string[]; - watchers?: string[]; - estimatedHours?: number; - startDate?: Date; - dueDate?: Date; - isRecurring?: boolean; - recurrencePattern?: IRecurrencePattern; - customFields?: Record; -} - -export interface UpdateWorkshopTaskDTO { - title?: string; - description?: string; - type?: TaskType; - status?: string; - priority?: number; - severity?: number; - labels?: string[]; - tags?: string[]; - assignedTeams?: string[]; - assignedIndividuals?: string[]; - - primaryOwner?: string; - contributors?: string[]; - watchers?: string[]; - estimatedHours?: number; - actualHours?: number; - startDate?: Date; - dueDate?: Date; - isRecurring?: boolean; - recurrencePattern?: IRecurrencePattern; - customFields?: Record; - - blockedBy?: string[]; - blocking?: string[]; -} - -export interface PermissionContext { - projectId?: string; - teamId?: string; -} - -export interface PermissionResult { - granted: boolean; - source?: PermissionScope; - reason?: string; -} - -export interface Pagination { - page: number; - limit: number; -} - -export interface AuditLogFilters { - action?: AuditAction; - actor?: string; - target?: string; - targetType?: string; - startDate?: Date; - endDate?: Date; -} - -export const DEFAULT_WORKSHOP_SETTINGS: IWorkshopSettings = { - allowOpenContribution: false, - requireApprovalForJoin: true, - publicInfoFields: ['name', 'description'] -}; - -export const DEFAULT_TASK_WORKFLOW: ITaskWorkflow = { - statuses: ['todo', 'in_progress', 'done'], - transitions: [ - { from: 'todo', to: 'in_progress' }, - { from: 'in_progress', to: 'done' }, - { from: 'in_progress', to: 'todo' }, - { from: 'done', to: 'in_progress' } - ] -}; - -export const DEFAULT_PROJECT_SETTINGS: IProjectSettings = { - allowExternalContribution: false, - taskWorkflow: DEFAULT_TASK_WORKFLOW -}; \ No newline at end of file diff --git a/backend/src/utils/emailService.ts b/backend/src/utils/emailService.ts deleted file mode 100644 index dcc4205..0000000 --- a/backend/src/utils/emailService.ts +++ /dev/null @@ -1,37 +0,0 @@ -import nodemailer from 'nodemailer'; - -export const sendEmail = async (to: string, subject: string, html: string) => { - try { - - if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) { - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: parseInt(process.env.SMTP_PORT || '587'), - secure: process.env.SMTP_SECURE === 'true', - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, - }, - }); - - await transporter.sendMail({ - from: process.env.SMTP_FROM || '"Team Up" ', - to, - subject, - html, - }); - console.log(`Email sent to ${to}`); - } else { - - console.log('---------------------------------------------------'); - console.log(`[Mock Email Service] To: ${to}`); - console.log(`[Mock Email Service] Subject: ${subject}`); - console.log(`[Mock Email Service] HTML Content:`); - console.log(html); - console.log('---------------------------------------------------'); - } - } catch (error) { - console.error('Error sending email:', error); - - } -}; \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 06fefea..a009d61 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -2,7 +2,9 @@ "compilerOptions": { "target": "ES2020", "module": "commonjs", - "lib": ["ES2020"], + "lib": [ + "ES2020" + ], "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -20,8 +22,37 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@config/*": [ + "src/config/*" + ], + "@di/*": [ + "src/di/*" + ], + "@modules/*": [ + "src/modules/*" + ], + "@shared/*": [ + "src/shared/*" + ], + "@middlewares": [ + "src/shared/middlewares/index" + ], + "@middlewares/*": [ + "src/shared/middlewares/*" + ], + "@constants": [ + "src/shared/constants/index" + ] + } }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file