diff --git a/backend/package-lock.json b/backend/package-lock.json index 041d381..56bf7f9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -42,6 +42,7 @@ "@types/passport-google-oauth20": "^2.0.17", "nodemon": "^3.0.2", "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3" } }, @@ -2706,6 +2707,19 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -3812,6 +3826,16 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strnum": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", @@ -3926,6 +3950,21 @@ } } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/backend/package.json b/backend/package.json index 28054bb..72c862f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "TeamUp Backend - Real-time collaboration platform", "main": "dist/server.js", "scripts": { - "dev": "nodemon src/server.ts", + "dev": "nodemon -w src -e ts --exec \"ts-node -r tsconfig-paths/register src/server.ts\"", "build": "tsc", "start": "node dist/server.js" }, @@ -50,6 +50,7 @@ "@types/passport-google-oauth20": "^2.0.17", "nodemon": "^3.0.2", "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3" } -} +} \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index 4413420..ab01a67 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,6 +25,8 @@ import createPermissionRoutes from './modules/access-control/routes/permissionRo import createChatRoutes from './modules/chat/routes/chatRoutes'; import createActivityRoutes from './modules/audit/routes/activityRoutes'; import createInviteRoutes from './modules/invitation/routes/inviteRoutes'; +import { setupAutomationRoutes } from './modules/automation/routes'; + import { errorHandler, injectContainer } from '@middlewares'; import { configurePassport } from './config/passport'; @@ -67,6 +69,8 @@ export const createApp = (container: Container) => { 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(`${API_PREFIX}${MODULE_BASE.AUTOMATION}`, setupAutomationRoutes(container.automationCtrl)); + app.use(errorHandler); diff --git a/backend/src/di/container.ts b/backend/src/di/container.ts index fd898b3..207d8d4 100644 --- a/backend/src/di/container.ts +++ b/backend/src/di/container.ts @@ -44,6 +44,10 @@ 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 { AutomationService } from '../modules/automation/services/AutomationService'; +import { AutomationController } from '../modules/automation/controllers/AutomationController'; +import { IAutomationService } from '../modules/automation/interfaces/IAutomationService'; + import { ITokenProvider } from '../shared/interfaces/ITokenProvider'; import { IEmailProvider } from '../shared/interfaces/IEmailProvider'; @@ -111,6 +115,8 @@ export class DIContainer implements Container { public workshopProjectSrv: IWorkshopProjectService; public workshopSrv: IWorkshopService; public workshopTaskSrv: IWorkshopTaskService; + public automationSrv: IAutomationService; + public activityCtrl: ActivityController; public auditCtrl: AuditController; @@ -124,6 +130,8 @@ export class DIContainer implements Container { public workshopCtrl: WorkshopController; public workshopProjectCtrl: WorkshopProjectController; public workshopTaskCtrl: WorkshopTaskController; + public automationCtrl: AutomationController; + constructor(httpServer: HTTPServer) { this.tokenProv = new TokenProvider(); @@ -226,6 +234,13 @@ export class DIContainer implements Container { this.socketSrv ); + this.automationSrv = new AutomationService( + this.workshopTaskRepo, + this.notificationRepo, + this.auditSrv + ); + + this.invitationSrv = new InvitationService( this.invitationRepo, this.workshopSrv, @@ -267,5 +282,11 @@ export class DIContainer implements Container { this.workshopTaskCtrl = new WorkshopTaskController(this.workshopTaskSrv); this.workshopTaskCtrl.setSocketService(this.socketSrv); + + this.automationCtrl = new AutomationController( + this.automationSrv, + this.workshopRepo + ); + } } diff --git a/backend/src/di/types.ts b/backend/src/di/types.ts index 7aad9fb..64a2fce 100644 --- a/backend/src/di/types.ts +++ b/backend/src/di/types.ts @@ -42,6 +42,9 @@ import { WorkshopProjectController } from '../modules/project/controllers/Worksh import { WorkshopTaskController } from '../modules/task/controllers/WorkshopTaskController'; import { IInvitationService } from '../modules/invitation/interfaces/IInvitationService'; import { IInvitationRepository } from '../modules/invitation/interfaces/IInvitationRepository'; +import { IAutomationService } from '../modules/automation/interfaces/IAutomationService'; +import { AutomationController } from '../modules/automation/controllers/AutomationController'; + export interface Container { tokenProv: ITokenProvider; @@ -77,6 +80,8 @@ export interface Container { workshopProjectSrv: IWorkshopProjectService; workshopSrv: IWorkshopService; workshopTaskSrv: IWorkshopTaskService; + automationSrv: IAutomationService; + activityCtrl: ActivityController; auditCtrl: AuditController; @@ -90,4 +95,6 @@ export interface Container { workshopCtrl: WorkshopController; workshopProjectCtrl: WorkshopProjectController; workshopTaskCtrl: WorkshopTaskController; + automationCtrl: AutomationController; + } diff --git a/backend/src/modules/audit/types/index.ts b/backend/src/modules/audit/types/index.ts index 1ebab5f..e2ca520 100644 --- a/backend/src/modules/audit/types/index.ts +++ b/backend/src/modules/audit/types/index.ts @@ -84,9 +84,15 @@ export enum AuditAction { JOIN_REQUEST_REJECTED = 'join_request_rejected', UNAUTHORIZED_ACCESS = 'unauthorized_access', PROJECT_MANAGER_ASSIGNED = 'project_manager_assigned', - PROJECT_MAINTAINER_ASSIGNED = 'project_maintainer_assigned' + PROJECT_MAINTAINER_ASSIGNED = 'project_maintainer_assigned', + + AUTOMATION_RULE_CREATED = 'automation_rule_created', + AUTOMATION_RULE_UPDATED = 'automation_rule_updated', + AUTOMATION_RULE_DELETED = 'automation_rule_deleted', + AUTOMATION_RULE_TRIGGERED = 'automation_rule_triggered' } + export interface IAuditLog extends Document { workshop: Types.ObjectId; action: AuditAction; diff --git a/backend/src/modules/automation/controllers/AutomationController.ts b/backend/src/modules/automation/controllers/AutomationController.ts new file mode 100644 index 0000000..f44eee1 --- /dev/null +++ b/backend/src/modules/automation/controllers/AutomationController.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { IAutomationService } from '../interfaces/IAutomationService'; +import { IWorkshopRepository } from '../../workshop/interfaces/IWorkshopRepository'; +import { AuthorizationError } from '../../../shared/utils/errors'; + +export class AutomationController { + constructor( + private automationService: IAutomationService, + private workshopRepo: IWorkshopRepository + ) { } + + async createRule(req: Request, res: Response) { + const { workshopId } = req.params; + const userId = (req as any).user.id; + + if (!(await this.workshopRepo.isOwnerOrManager(workshopId, userId))) { + throw new AuthorizationError('Only workshop admins can manage automation rules'); + } + + const rule = await this.automationService.createRule(workshopId, userId, req.body); + return res.status(201).json(rule); + } + + async getRules(req: Request, res: Response) { + const { workshopId } = req.params; + + // Any member can likely view rules, but you might want to restrict this + + const rules = await this.automationService.getRules(workshopId); + return res.json(rules); + } + + async updateRule(req: Request, res: Response) { + const { workshopId, ruleId } = req.params; + const userId = (req as any).user.id; + + if (!(await this.workshopRepo.isOwnerOrManager(workshopId, userId))) { + throw new AuthorizationError('Only workshop admins can manage automation rules'); + } + + const rule = await this.automationService.updateRule(ruleId, userId, req.body); + return res.json(rule); + } + + async deleteRule(req: Request, res: Response) { + const { workshopId, ruleId } = req.params; + const userId = (req as any).user.id; + + if (!(await this.workshopRepo.isOwnerOrManager(workshopId, userId))) { + throw new AuthorizationError('Only workshop admins can manage automation rules'); + } + + await this.automationService.deleteRule(ruleId, userId); + return res.status(204).send(); + } + + async toggleRule(req: Request, res: Response) { + const { workshopId, ruleId } = req.params; + const userId = (req as any).user.id; + const { isActive } = req.body; + + if (!(await this.workshopRepo.isOwnerOrManager(workshopId, userId))) { + throw new AuthorizationError('Only workshop admins can manage automation rules'); + } + + const rule = await this.automationService.toggleRule(ruleId, userId, isActive); + return res.json(rule); + } +} diff --git a/backend/src/modules/automation/interfaces/IAutomationService.ts b/backend/src/modules/automation/interfaces/IAutomationService.ts new file mode 100644 index 0000000..aa46081 --- /dev/null +++ b/backend/src/modules/automation/interfaces/IAutomationService.ts @@ -0,0 +1,12 @@ +import { IAutomationRule, CreateAutomationRuleDTO } from '../types/index'; + +export interface IAutomationService { + createRule(workshopId: string, userId: string, data: CreateAutomationRuleDTO): Promise; + getRules(workshopId: string): Promise; + updateRule(ruleId: string, userId: string, updates: Partial): Promise; + deleteRule(ruleId: string, userId: string): Promise; + toggleRule(ruleId: string, userId: string, isActive: boolean): Promise; + + // Execution core + handleEvent(triggerType: string, context: any): Promise; +} diff --git a/backend/src/modules/automation/models/AutomationRule.ts b/backend/src/modules/automation/models/AutomationRule.ts new file mode 100644 index 0000000..4994802 --- /dev/null +++ b/backend/src/modules/automation/models/AutomationRule.ts @@ -0,0 +1,73 @@ +import mongoose, { Schema, Document } from 'mongoose'; +import { IAutomationRule, AutomationTriggerType, AutomationActionType } from '../types/index'; + +const automationRuleSchema = new Schema( + { + workshopId: { + type: Schema.Types.ObjectId, + ref: 'Workshop', + required: true, + index: true + }, + name: { + type: String, + required: true, + trim: true + }, + description: { + type: String, + trim: true + }, + trigger: { + type: { + type: String, + enum: Object.values(AutomationTriggerType), + required: true + }, + config: { + type: Map, + of: Schema.Types.Mixed + } + }, + conditions: [ + { + field: { type: String, required: true }, + operator: { + type: String, + enum: ['equals', 'not_equals', 'contains', 'greater_than', 'less_than'], + required: true + }, + value: { type: Schema.Types.Mixed, required: true } + } + ], + actions: [ + { + type: { + type: String, + enum: Object.values(AutomationActionType), + required: true + }, + config: { + type: Map, + of: Schema.Types.Mixed + } + } + ], + isActive: { + type: Boolean, + default: true + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true + } + }, + { + timestamps: true + } +); + +automationRuleSchema.index({ workshopId: 1, isActive: 1 }); + +export const AutomationRule = mongoose.model('AutomationRule', automationRuleSchema); diff --git a/backend/src/modules/automation/routes/index.ts b/backend/src/modules/automation/routes/index.ts new file mode 100644 index 0000000..6db86dc --- /dev/null +++ b/backend/src/modules/automation/routes/index.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { AutomationController } from '../controllers/AutomationController'; +import { authenticate } from '../../../shared/middlewares/auth'; +import { asyncHandler } from '../../../shared/middlewares/errorMiddleware'; + +export function setupAutomationRoutes(controller: AutomationController): Router { + const router = Router(); + + router.use(authenticate); + + router.post('/:workshopId/rules', asyncHandler((req, res) => controller.createRule(req, res))); + router.get('/:workshopId/rules', asyncHandler((req, res) => controller.getRules(req, res))); + router.put('/:workshopId/rules/:ruleId', asyncHandler((req, res) => controller.updateRule(req, res))); + router.delete('/:workshopId/rules/:ruleId', asyncHandler((req, res) => controller.deleteRule(req, res))); + router.patch('/:workshopId/rules/:ruleId/toggle', asyncHandler((req, res) => controller.toggleRule(req, res))); + + return router; +} diff --git a/backend/src/modules/automation/services/AutomationService.ts b/backend/src/modules/automation/services/AutomationService.ts new file mode 100644 index 0000000..ec78717 --- /dev/null +++ b/backend/src/modules/automation/services/AutomationService.ts @@ -0,0 +1,201 @@ +import { IAutomationService } from '../interfaces/IAutomationService'; +import { + IAutomationRule, + CreateAutomationRuleDTO, + AutomationTriggerType, + AutomationActionType, + IAutomationCondition +} from '../types/index'; +import { AutomationRule } from '../models/AutomationRule'; +import { eventBus } from '../../../shared/utils/EventBus'; +import { IWorkshopTaskRepository } from '../../task/interfaces/IWorkshopTaskRepository'; +import { INotificationRepository } from '../../notification/interfaces/INotificationRepository'; +import { IAuditService } from '../../audit/interfaces/IAuditService'; +import { AuditAction } from '../../audit/types/index'; +import { NotificationType } from '../../notification/types/index'; +import { NotFoundError } from '../../../shared/utils/errors'; + +export class AutomationService implements IAutomationService { + constructor( + private taskRepo: IWorkshopTaskRepository, + private notificationRepo: INotificationRepository, + private auditService: IAuditService + ) { + this.initEventListeners(); + } + + private initEventListeners() { + eventBus.on('task:status:changed', (data) => this.handleEvent(AutomationTriggerType.TASK_STATUS_CHANGED, data)); + eventBus.on('task:priority:changed', (data) => this.handleEvent(AutomationTriggerType.TASK_PRIORITY_CHANGED, data)); + eventBus.on('task:created', (data) => this.handleEvent(AutomationTriggerType.TASK_CREATED, data)); + eventBus.on('member:joined', (data) => this.handleEvent(AutomationTriggerType.MEMBER_JOINED, data)); + eventBus.on('comment:added', (data) => this.handleEvent(AutomationTriggerType.COMMENT_ADDED, data)); + } + + async createRule(workshopId: string, userId: string, data: CreateAutomationRuleDTO): Promise { + const rule = await AutomationRule.create({ + ...data, + workshopId, + createdBy: userId + }); + + await this.auditService.log({ + workshopId, + action: AuditAction.AUTOMATION_RULE_CREATED, + actorId: userId, + targetId: rule._id.toString(), + targetType: 'AutomationRule', + details: { name: rule.name } + }); + + return rule.toObject(); + } + + async getRules(workshopId: string): Promise { + return await AutomationRule.find({ workshopId }).lean(); + } + + async updateRule(ruleId: string, userId: string, updates: Partial): Promise { + const rule = await AutomationRule.findById(ruleId); + if (!rule) throw new NotFoundError('Automation Rule'); + + Object.assign(rule, updates); + await rule.save(); + + await this.auditService.log({ + workshopId: rule.workshopId.toString(), + action: AuditAction.AUTOMATION_RULE_UPDATED, + actorId: userId, + targetId: rule._id.toString(), + targetType: 'AutomationRule', + details: { name: rule.name } + }); + + return rule.toObject(); + } + + async deleteRule(ruleId: string, userId: string): Promise { + const rule = await AutomationRule.findById(ruleId); + if (rule) { + const workshopId = rule.workshopId.toString(); + const ruleName = rule.name; + await AutomationRule.deleteOne({ _id: ruleId }); + + await this.auditService.log({ + workshopId, + action: AuditAction.AUTOMATION_RULE_DELETED, + actorId: userId, + targetId: ruleId, + targetType: 'AutomationRule', + details: { name: ruleName } + }); + } + } + + async toggleRule(ruleId: string, userId: string, isActive: boolean): Promise { + const rule = await AutomationRule.findByIdAndUpdate(ruleId, { isActive }, { new: true }); + if (!rule) throw new NotFoundError('Automation Rule'); + + await this.auditService.log({ + workshopId: rule.workshopId.toString(), + action: AuditAction.AUTOMATION_RULE_UPDATED, + actorId: userId, + targetId: rule._id.toString(), + targetType: 'AutomationRule', + details: { name: rule.name, isActive } + }); + + return rule.toObject(); + } + + async handleEvent(triggerType: AutomationTriggerType, context: any): Promise { + const { workshopId } = context; + if (!workshopId) return; + + try { + const activeRules = await AutomationRule.find({ + workshopId, + 'trigger.type': triggerType, + isActive: true + }); + + for (const rule of activeRules) { + if (this.evaluateConditions(rule.conditions, context)) { + await this.executeActions(rule.actions, context); + + await this.auditService.log({ + workshopId: workshopId.toString(), + action: AuditAction.AUTOMATION_RULE_TRIGGERED, + actorId: 'SYSTEM', + targetId: rule._id.toString(), + targetType: 'AutomationRule', + details: { ruleName: rule.name, triggerType } + }); + } + } + + } catch (error) { + console.error(`Error executing automation rule for ${triggerType}:`, error); + } + } + + private evaluateConditions(conditions: IAutomationCondition[], context: any): boolean { + if (!conditions || conditions.length === 0) return true; + + return conditions.every(condition => { + const actualValue = this.getValueFromPath(context, condition.field); + + switch (condition.operator) { + case 'equals': return actualValue === condition.value; + case 'not_equals': return actualValue !== condition.value; + case 'contains': + if (Array.isArray(actualValue)) return actualValue.includes(condition.value); + return String(actualValue).includes(condition.value); + case 'greater_than': return Number(actualValue) > Number(condition.value); + case 'less_than': return Number(actualValue) < Number(condition.value); + default: return false; + } + }); + } + + private getValueFromPath(obj: any, path: string): any { + return path.split('.').reduce((acc, part) => acc && acc[part], obj); + } + + private async executeActions(actions: any[], context: any): Promise { + for (const action of actions) { + try { + switch (action.type) { + case AutomationActionType.UPDATE_TASK_STATUS: + if (context.task?._id) { + await this.taskRepo.updateStatus(context.task._id.toString(), action.config.status, 'SYSTEM_AUTOMATION'); + } + break; + + case AutomationActionType.NOTIFY_USER: + const userId = action.config.userId === 'TASK_OWNER' ? context.task?.primaryOwner : action.config.userId; + if (userId) { + await this.notificationRepo.create({ + user: userId, + type: NotificationType.TASK_UPDATED, + title: 'Automation Rule Triggered', + message: action.config.message || 'A custom automation rule was applied.', + relatedWorkshop: context.workshopId, + relatedTask: context.task?._id, + isRead: false + } as any); + } + break; + + case AutomationActionType.ADD_TASK_COMMENT: + if (context.task?._id) { + await this.taskRepo.addComment(context.task._id.toString(), 'SYSTEM_AUTOMATION', action.config.content); + } + break; + } + } catch (err) { + console.error(`Failed to execute action ${action.type}:`, err); + } + } + } +} diff --git a/backend/src/modules/automation/types/index.ts b/backend/src/modules/automation/types/index.ts new file mode 100644 index 0000000..5eb2fc6 --- /dev/null +++ b/backend/src/modules/automation/types/index.ts @@ -0,0 +1,55 @@ +export enum AutomationTriggerType { + TASK_STATUS_CHANGED = 'TASK_STATUS_CHANGED', + TASK_PRIORITY_CHANGED = 'TASK_PRIORITY_CHANGED', + TASK_CREATED = 'TASK_CREATED', + MEMBER_JOINED = 'MEMBER_JOINED', + COMMENT_ADDED = 'COMMENT_ADDED' +} + +export enum AutomationActionType { + UPDATE_TASK_STATUS = 'UPDATE_TASK_STATUS', + ASSIGN_USER = 'ASSIGN_USER', + NOTIFY_USER = 'NOTIFY_USER', + ADD_TASK_COMMENT = 'ADD_TASK_COMMENT', + RESTRICT_TRANSITION = 'RESTRICT_TRANSITION' +} + +export interface IAutomationCondition { + field: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than'; + value: any; +} + +export interface IAutomationAction { + type: AutomationActionType; + config: Record; +} + +export interface IAutomationRule { + _id: any; + workshopId: any; + name: string; + description?: string; + trigger: { + type: AutomationTriggerType; + config?: Record; + }; + conditions: IAutomationCondition[]; + actions: IAutomationAction[]; + isActive: boolean; + createdBy: any; + createdAt: Date; + updatedAt: Date; +} + + +export interface CreateAutomationRuleDTO { + name: string; + description?: string; + trigger: { + type: AutomationTriggerType; + config?: Record; + }; + conditions: IAutomationCondition[]; + actions: IAutomationAction[]; +} diff --git a/backend/src/modules/task/services/WorkshopTaskService.ts b/backend/src/modules/task/services/WorkshopTaskService.ts index 9b8b7ed..12fa7c6 100644 --- a/backend/src/modules/task/services/WorkshopTaskService.ts +++ b/backend/src/modules/task/services/WorkshopTaskService.ts @@ -12,6 +12,8 @@ import { ISocketService } from '../../../shared/interfaces/ISocketService'; import { IAuditService } from '../../audit/interfaces/IAuditService'; import { IPermissionService } from '../../access-control/interfaces/IPermissionService'; import { IWorkshopTaskService } from '../interfaces/IWorkshopTaskService'; +import { eventBus } from '../../../shared/utils/EventBus'; + export class WorkshopTaskService implements IWorkshopTaskService { constructor( @@ -171,7 +173,15 @@ export class WorkshopTaskService implements IWorkshopTaskService { this.socketService.emitToProject(projectId, 'workshop:task:created', task); } + eventBus.emit('task:created', { + workshopId, + projectId, + task, + user: userId + }); + return task; + } async getTaskById(taskId: string, userId: string): Promise { @@ -375,7 +385,19 @@ export class WorkshopTaskService implements IWorkshopTaskService { this.socketService.emitToProject(project._id.toString(), 'workshop:task:updated', updatedTask); } + if (updates.priority && updates.priority !== task.priority) { + eventBus.emit('task:priority:changed', { + workshopId, + projectId: project._id.toString(), + task: updatedTask, + oldPriority: task.priority, + newPriority: updates.priority, + user: userId + }); + } + return updatedTask; + } async updateTaskStatus( @@ -424,7 +446,17 @@ export class WorkshopTaskService implements IWorkshopTaskService { this.socketService.emitToProject(project._id.toString(), 'workshop:task:status:changed', updatedTask); } + eventBus.emit('task:status:changed', { + workshopId, + projectId: project._id.toString(), + task: updatedTask, + oldStatus: task.status, + newStatus, + user: userId + }); + return updatedTask; + } async assignTeamToTask( @@ -681,7 +713,16 @@ export class WorkshopTaskService implements IWorkshopTaskService { }); } + eventBus.emit('comment:added', { + workshopId, + projectId: project._id.toString(), + task: updatedTask, + comment: updatedTask.comments[updatedTask.comments.length - 1], + user: userId + }); + return updatedTask; + } async addAttachment( diff --git a/backend/src/modules/workshop/services/WorkshopService.ts b/backend/src/modules/workshop/services/WorkshopService.ts index 8295a37..474fbf1 100644 --- a/backend/src/modules/workshop/services/WorkshopService.ts +++ b/backend/src/modules/workshop/services/WorkshopService.ts @@ -22,6 +22,8 @@ import { User } from '../../user/models/User'; import { Invitation } from '../../invitation/models/Invitation'; import { IInvitation } from '../../invitation/types/index'; import crypto from 'crypto'; +import { eventBus } from '../../../shared/utils/EventBus'; + function getIdString(ref: any): string { if (ref && typeof ref === 'object' && '_id' in ref) { @@ -334,7 +336,14 @@ export class WorkshopService implements IWorkshopService { this.permissionService.invalidateUserCache(userId, workshopId); await this.chatService.syncUserToWorkshopRooms(userId, workshopId); if (this.socketService) this.socketService.emitToWorkshop(workshopId, 'membership:joined', membership); + + eventBus.emit('member:joined', { + workshopId, + member: membership, + user: userId + }); } else { + if (this.socketService) this.socketService.emitToWorkshop(workshopId, 'membership:request:created', membership); } return membership; @@ -349,7 +358,15 @@ export class WorkshopService implements IWorkshopService { await this.auditService.logJoinRequestApproved(workshopId, actorId, getIdString(updated.user)); } if (this.socketService) this.socketService.emitToWorkshop(workshopId, 'membership:request:approved', updated); + + eventBus.emit('member:joined', { + workshopId, + member: updated, + user: actorId + }); + return updated; + } async rejectJoinRequest(workshopId: string, actorId: string, membershipId: string, reason?: string): Promise { diff --git a/backend/src/shared/constants/routes.ts b/backend/src/shared/constants/routes.ts index e8bfc11..890048f 100644 --- a/backend/src/shared/constants/routes.ts +++ b/backend/src/shared/constants/routes.ts @@ -15,9 +15,11 @@ export const MODULE_BASE = { ROLES: '/workshops/:workshopId/roles', TEAMS: '/workshops/:workshopId/teams', PROJECTS: '/workshops/:workshopId/projects', - PROJECT_TASKS: '/workshops/:workshopId/projects/:projectId/tasks' + PROJECT_TASKS: '/workshops/:workshopId/projects/:projectId/tasks', + AUTOMATION: '/workshops' }; + export const AUTH_ROUTES = { REGISTER: '/register', VERIFY_OTP: '/verify-otp', @@ -146,3 +148,11 @@ export const INVITE_ROUTES = { BY_TOKEN: '/:token', ACCEPT: '/:token/accept' }; + +export const AUTOMATION_ROUTES = { + BASE: '/:workshopId/automation', + RULES: '/:workshopId/automation/rules', + RULE_BY_ID: '/:workshopId/automation/rules/:ruleId', + TOGGLE: '/:workshopId/automation/rules/:ruleId/toggle' +}; + diff --git a/backend/src/shared/utils/EventBus.ts b/backend/src/shared/utils/EventBus.ts new file mode 100644 index 0000000..3f02920 --- /dev/null +++ b/backend/src/shared/utils/EventBus.ts @@ -0,0 +1,18 @@ +import { EventEmitter } from 'events'; + +class EventBus extends EventEmitter { + private static instance: EventBus; + + private constructor() { + super(); + } + + public static getInstance(): EventBus { + if (!EventBus.instance) { + EventBus.instance = new EventBus(); + } + return EventBus.instance; + } +} + +export const eventBus = EventBus.getInstance(); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index a009d61..265dce2 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -26,25 +26,25 @@ "baseUrl": ".", "paths": { "@config/*": [ - "src/config/*" + "./src/config/*" ], "@di/*": [ - "src/di/*" + "./src/di/*" ], "@modules/*": [ - "src/modules/*" + "./src/modules/*" ], "@shared/*": [ - "src/shared/*" + "./src/shared/*" ], "@middlewares": [ - "src/shared/middlewares/index" + "./src/shared/middlewares/index" ], "@middlewares/*": [ - "src/shared/middlewares/*" + "./src/shared/middlewares/*" ], "@constants": [ - "src/shared/constants/index" + "./src/shared/constants/index" ] } }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5ee1db6..7329b5b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.34.0", "lucide-react": "^0.562.0", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -3991,6 +3992,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz", + "integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.0", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4366,6 +4394,21 @@ "node": ">= 0.6" } }, + "node_modules/motion-dom": { + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz", + "integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 88ffc50..4f419e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.34.0", "lucide-react": "^0.562.0", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2865209..f73e29a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,8 @@ import TaskDetail from '@/pages/TaskDetail'; import WorkshopAuditLog from '@/pages/WorkshopAuditLog'; import ChatPage from '@/pages/Chat'; import Landing from '@/pages/Landing'; +import WorkshopAutomation from '@/pages/WorkshopAutomation'; + import { Loader2 } from 'lucide-react'; const SocketErrorHandler: React.FC = () => { @@ -175,6 +177,15 @@ function AppRoutes() { } /> + + + + } + /> + void; + onSubmit: () => void; + workshopId: string; + editingRule?: IAutomationRule; +} + +const RuleBuilderModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + workshopId, + editingRule +}) => { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [triggerType, setTriggerType] = useState(AutomationTriggerType.TASK_STATUS_CHANGED); + const [conditions, setConditions] = useState([]); + const [actions, setActions] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (editingRule) { + setName(editingRule.name); + setDescription(editingRule.description || ''); + setTriggerType(editingRule.trigger.type); + setConditions(editingRule.conditions); + setActions(editingRule.actions); + } else { + setName(''); + setDescription(''); + setTriggerType(AutomationTriggerType.TASK_STATUS_CHANGED); + setConditions([]); + setActions([{ type: AutomationActionType.NOTIFY_USER, config: {} }]); + } + }, [editingRule]); + + const handleAddCondition = () => { + setConditions([...conditions, { field: 'task.status', operator: 'equals', value: '' }]); + }; + + const handleRemoveCondition = (index: number) => { + setConditions(conditions.filter((_, i) => i !== index)); + }; + + const handleAddAction = () => { + setActions([...actions, { type: AutomationActionType.NOTIFY_USER, config: {} }]); + }; + + const handleRemoveAction = (index: number) => { + setActions(actions.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + if (!name || actions.length === 0) return; + + setIsSaving(true); + try { + const data = { + name, + description, + trigger: { type: triggerType }, + conditions, + actions, + isActive: true + }; + + if (editingRule) { + await automationApi.updateRule(workshopId, editingRule._id, data); + } else { + await automationApi.createRule(workshopId, data); + } + onSubmit(); + onClose(); + } catch (error) { + console.error('Failed to save rule:', error); + } finally { + setIsSaving(false); + } + }; + + if (!isOpen) return null; + + return ( +
+ +
+
+
+ +
+
+

{editingRule ? 'Edit Automation Rule' : 'New Workflow Rule'}

+

Automation Engine v1.0

+
+
+ +
+ +
+ {/* Metadata Section */} +
+
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+
+ + {/* Trigger Section */} +
+
+
1
+

Listen for Event

+
+
+ {Object.values(AutomationTriggerType).map((type) => ( + + ))} +
+
+ + {/* Conditions Section */} +
+
+
+
2
+

Set Conditional Filters (Optional)

+
+ +
+ + + {conditions.map((condition, index) => ( + + + + + + { + const newConditions = [...conditions]; + newConditions[index].value = e.target.value; + setConditions(newConditions); + }} + /> + + + + ))} + + {conditions.length === 0 && ( +
+ No filters set. This rule will trigger on every event. +
+ )} +
+ + {/* Actions Section */} +
+
+
+
3
+

Execute Automated Actions

+
+ +
+ + {actions.map((action, index) => ( +
+
+ + +
+ +
+ {action.type === AutomationActionType.UPDATE_TASK_STATUS && ( +
+ + +
+ )} + + {action.type === AutomationActionType.NOTIFY_USER && ( + <> +
+ + +
+
+ + { + const newActions = [...actions]; + newActions[index].config = { ...newActions[index].config, message: e.target.value }; + setActions(newActions); + }} + /> +
+ + )} + + {action.type === AutomationActionType.ADD_TASK_COMMENT && ( +
+ +