diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47bfb92..d7ef89f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,4 +66,4 @@ jobs: override: true - uses: Swatinem/rust-cache@v2 - run: cargo build --release - # - run: cargo test \ No newline at end of file + # - run: cargo test diff --git a/CONTRIBUTING.MD b/CONTRIBUTING.MD index aca83c6..158392f 100644 --- a/CONTRIBUTING.MD +++ b/CONTRIBUTING.MD @@ -19,6 +19,7 @@ We welcome and appreciate contributions from the community! Here’s how to get ## ⚠️ Avoid Generic Comments Comments such as: + - 🚫 "Can I help with this?" - 🚫 "I’d love to contribute!" - 🚫 "Check out my profile!" @@ -27,6 +28,7 @@ Comments such as: ...will not be considered. Instead, please provide: + - A brief **introduction** about yourself - A concise **plan (3–6 lines max)** outlining how you’ll solve the issue - Your **estimated completion time (ETA)** @@ -53,11 +55,11 @@ Please review our [Code of Conduct](CODE_OF_CONDUCT.md) to help us create a welc ## 🧵 Branch Naming Convention -| Type | Prefix | Example | -|----------|------------------|---------------------------| -| Feature | `feature/` | `feature/add-profile-tab`| -| Bugfix | `fix/` | `fix/login-validation` | -| Docs | `docs/` | `docs/update-contributing`| +| Type | Prefix | Example | +| ------- | ---------- | -------------------------- | +| Feature | `feature/` | `feature/add-profile-tab` | +| Bugfix | `fix/` | `fix/login-validation` | +| Docs | `docs/` | `docs/update-contributing` | ## ✅ Opening Issues diff --git a/README.md b/README.md index 17364a7..2f6565a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Welcome to **ByteChain Academy**, a lightweight Web3 learning platform that offe Follow these instructions to set up the project locally for development and testing purposes. ### 🔹 Backend Setup + The backend is built with [NestJS](https://nestjs.com/): 1. **Clone the Repository:** @@ -39,6 +40,7 @@ The backend is built with [NestJS](https://nestjs.com/): ``` ### 🔹 Frontend Setup + The frontend uses [Next.js](https://nextjs.org/): 1. **Navigate to the Frontend Directory:** @@ -79,6 +81,7 @@ This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for d ## 📧 Contact For inquiries, discussions, or help, feel free to reach out to us: + - 📬 Email: [contact@nexacore.org](mailto:contact@nexacore.org) - 🗣️ Telegram: [https://t.me/ByteChainAcademy](https://t.me/+E_iHswAzaPA4Yzk8) - 🐛 Issues: Open an issue for feature requests or bug reports diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 1ec5fa3..c1c30c5 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,4 +1,8 @@ -import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { RefreshToken } from './entities/refresh-token.entity'; @@ -117,7 +121,8 @@ export class AuthService { default: return 7 * 24 * 60 * 60 * 1000; } - } async requestEmailVerification(email: string): Promise { + } + async requestEmailVerification(email: string): Promise { const user = await this.adminRepo.findOne({ where: { email } }); if (!user) { throw new BadRequestException('User not found'); @@ -126,7 +131,7 @@ export class AuthService { // Invalidate any existing verification tokens await this.emailVerificationRepo.update( { email, verified: false }, - { verified: true } + { verified: true }, ); const token = randomBytes(32).toString('hex'); @@ -174,10 +179,7 @@ export class AuthService { } // Invalidate any existing reset tokens - await this.passwordResetRepo.update( - { email, used: false }, - { used: true } - ); + await this.passwordResetRepo.update({ email, used: false }, { used: true }); const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour @@ -219,4 +221,3 @@ export class AuthService { await this.passwordResetRepo.save(reset); } } - diff --git a/backend/src/auth/dto/email-verification.dto.ts b/backend/src/auth/dto/email-verification.dto.ts index c1ebbdf..a22085e 100644 --- a/backend/src/auth/dto/email-verification.dto.ts +++ b/backend/src/auth/dto/email-verification.dto.ts @@ -10,4 +10,4 @@ export class RequestEmailVerificationDto { @IsEmail() @IsNotEmpty() email: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/dto/password-reset.dto.ts b/backend/src/auth/dto/password-reset.dto.ts index 0549f5e..b5986a0 100644 --- a/backend/src/auth/dto/password-reset.dto.ts +++ b/backend/src/auth/dto/password-reset.dto.ts @@ -15,4 +15,4 @@ export class ResetPasswordDto { @IsNotEmpty() @MinLength(8) newPassword: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/entities/password-reset.entity.ts b/backend/src/auth/entities/password-reset.entity.ts index a81c33c..9aa2b66 100644 --- a/backend/src/auth/entities/password-reset.entity.ts +++ b/backend/src/auth/entities/password-reset.entity.ts @@ -1,4 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + CreateDateColumn, +} from 'typeorm'; import { Admin } from '../../admin/entities/admin.entity'; @Entity() @@ -23,4 +29,4 @@ export class PasswordReset { @ManyToOne(() => Admin) user: Admin; -} \ No newline at end of file +} diff --git a/backend/src/auth/services/email.service.ts b/backend/src/auth/services/email.service.ts index 9f1fd74..723df0f 100644 --- a/backend/src/auth/services/email.service.ts +++ b/backend/src/auth/services/email.service.ts @@ -19,7 +19,7 @@ export class EmailService { async sendVerificationEmail(email: string, token: string): Promise { const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${token}`; - + await this.transporter.sendMail({ from: process.env.SMTP_FROM, to: email, @@ -35,7 +35,7 @@ export class EmailService { async sendPasswordResetEmail(email: string, token: string): Promise { const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; - + await this.transporter.sendMail({ from: process.env.SMTP_FROM, to: email, @@ -49,4 +49,4 @@ export class EmailService { `, }); } -} \ No newline at end of file +} diff --git a/backend/src/notification/README.md b/backend/src/notification/README.md new file mode 100644 index 0000000..2c4e3ef --- /dev/null +++ b/backend/src/notification/README.md @@ -0,0 +1,110 @@ +# Notification Module + +A comprehensive notification system for handling real-time and persistent notifications across different user roles (Student, Tutor, Admin). + +## Features + +- **Multi-role Support**: Handles notifications for Students, Tutors, and Admins +- **Type-based Notifications**: Predefined notification types for different platform events +- **Real-time Ready**: Architecture supports future WebSocket/SSE integration +- **Bulk Operations**: Send notifications to multiple recipients +- **Filtering & Pagination**: Advanced querying capabilities +- **Role-based Access Control**: Proper authentication and authorization +- **Extensible**: Easy to add new notification types and features + +## API Endpoints + +### User Endpoints (Authenticated) + +- `GET /notification` - Get current user's notifications +- `GET /notification/unread-count` - Get unread notification count +- `PATCH /notification/:id/read` - Mark notification as read +- `PATCH /notification/mark-all-read` - Mark all notifications as read +- `DELETE /notification/:id` - Delete a notification + +### Admin/Tutor Endpoints + +- `POST /notification/send` - Send a notification (Admin/Tutor only) +- `POST /notification/send/bulk` - Send bulk notifications (Admin only) +- `GET /notification/admin/all` - Get all notifications (Admin only) +- `DELETE /notification/admin/:id` - Delete any notification (Admin only) + +## Notification Types + +- `COURSE_ENROLLMENT` - Course enrollment notifications +- `COURSE_COMPLETION` - Course completion notifications +- `NEW_LESSON` - New lesson notifications +- `LESSON_COMPLETION` - Lesson completion notifications +- `QUIZ_RESULT` - Quiz result notifications +- `QUIZ_REMINDER` - Quiz reminder notifications +- `DAO_UPDATE` - DAO update notifications +- `DAO_PROPOSAL` - DAO proposal notifications +- `DAO_VOTING` - DAO voting notifications +- `SYSTEM_ANNOUNCEMENT` - System announcements +- `MAINTENANCE` - Maintenance notifications +- `PROFILE_UPDATE` - Profile update notifications +- `PASSWORD_CHANGE` - Password change notifications + +## Usage Examples + +### Triggering Notifications from Other Services + +\`\`\`typescript +// Inject NotificationService in your service +constructor(private notificationService: NotificationService) {} + +// Trigger a quiz result notification +await this.notificationService.triggerNotification( +studentId, +UserRole.STUDENT, +NotificationType.QUIZ_RESULT, +`You scored 85% on the JavaScript Basics quiz!`, +{ quizName: 'JavaScript Basics', score: 85, passed: true } +); +\`\`\` + +### Using Notification Hooks + +\`\`\`typescript +// Inject NotificationHooks in your service +constructor(private notificationHooks: NotificationHooks) {} + +// Trigger course enrollment notification +await this.notificationHooks.onCourseEnrollment(studentId, courseName); + +// Trigger quiz result notification +await this.notificationHooks.onQuizResult(studentId, quizName, score, passed); +\`\`\` + +## Database Schema + +The notification entity includes: + +- `id` - Unique identifier +- `recipientId` - ID of the notification recipient +- `recipientRole` - Role of the recipient (STUDENT, TUTOR, ADMIN) +- `type` - Type of notification +- `message` - Notification message +- `isRead` - Read status +- `metadata` - Additional data (JSON) +- `senderId` - ID of the sender (optional) +- `senderRole` - Role of the sender (optional) +- `createdAt` - Creation timestamp +- `updatedAt` - Update timestamp + +## Testing + +Run the tests: + +\`\`\`bash +npm run test src/modules/notification +\`\`\` + +## Future Enhancements + +- WebSocket integration for real-time notifications +- Email notification service integration +- Push notification support +- Notification templates +- Scheduled notifications +- Notification preferences per user diff --git a/backend/src/notification/dto/create-notification.dto.ts b/backend/src/notification/dto/create-notification.dto.ts new file mode 100644 index 0000000..46eebe4 --- /dev/null +++ b/backend/src/notification/dto/create-notification.dto.ts @@ -0,0 +1,35 @@ +import { + IsString, + IsUUID, + IsEnum, + IsOptional, + IsObject, +} from 'class-validator'; +import { NotificationType } from '../enums/notification.enums'; +import { UserRole } from 'src/roles/roles.enum'; + +export class CreateNotificationDto { + @IsUUID() + recipientId: string; + + @IsEnum(UserRole) + recipientRole: UserRole; + + @IsEnum(NotificationType) + type: NotificationType; + + @IsString() + message: string; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsUUID() + senderId?: string; + + @IsOptional() + @IsEnum(UserRole) + senderRole?: UserRole; +} diff --git a/backend/src/notification/dto/query-notification.dto.ts b/backend/src/notification/dto/query-notification.dto.ts new file mode 100644 index 0000000..bd077cc --- /dev/null +++ b/backend/src/notification/dto/query-notification.dto.ts @@ -0,0 +1,30 @@ +import { IsOptional, IsEnum, IsBoolean, IsDateString } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { NotificationType } from '../enums/notification.enums'; + +export class QueryNotificationDto { + @IsOptional() + @IsEnum(NotificationType) + type?: NotificationType; + + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + isRead?: boolean; + + @IsOptional() + @IsDateString() + fromDate?: string; + + @IsOptional() + @IsDateString() + toDate?: string; + + @IsOptional() + @Transform(({ value }) => Number.parseInt(String(value))) + page?: number = 1; + + @IsOptional() + @Transform(({ value }) => Number.parseInt(String(value))) + limit?: number = 20; +} diff --git a/backend/src/notification/dto/send-notification.dto.ts b/backend/src/notification/dto/send-notification.dto.ts new file mode 100644 index 0000000..1a25210 --- /dev/null +++ b/backend/src/notification/dto/send-notification.dto.ts @@ -0,0 +1,46 @@ +import { + IsString, + IsUUID, + IsEnum, + IsOptional, + IsObject, + IsArray, +} from 'class-validator'; +import { NotificationType, UserRole } from '../enums/notification.enums'; + +export class SendNotificationDto { + @IsUUID() + recipientId: string; + + @IsEnum(UserRole) + recipientRole: UserRole; + + @IsEnum(NotificationType) + type: NotificationType; + + @IsString() + message: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class BulkSendNotificationDto { + @IsArray() + @IsUUID(undefined, { each: true }) + recipientIds: string[]; + + @IsEnum(UserRole) + recipientRole: UserRole; + + @IsEnum(NotificationType) + type: NotificationType; + + @IsString() + message: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/backend/src/notification/entities/notification.entity.ts b/backend/src/notification/entities/notification.entity.ts new file mode 100644 index 0000000..f057cd2 --- /dev/null +++ b/backend/src/notification/entities/notification.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { NotificationType, UserRole } from '../enums/notification.enums'; + +@Entity('notifications') +@Index(['recipientId', 'recipientRole']) +@Index(['isRead']) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + @Index() + recipientId: string; + + @Column({ + type: 'enum', + enum: UserRole, + }) + recipientRole: UserRole; + + @Column({ + type: 'enum', + enum: NotificationType, + }) + type: NotificationType; + + @Column('text') + message: string; + + @Column({ default: false }) + @Index() + isRead: boolean; + + @Column('json', { nullable: true }) + metadata?: Record; + + @Column('uuid', { nullable: true }) + senderId?: string; + + @Column({ + type: 'enum', + enum: UserRole, + nullable: true, + }) + senderRole?: UserRole; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/notification/enums/notification.enums.ts b/backend/src/notification/enums/notification.enums.ts new file mode 100644 index 0000000..e34a8df --- /dev/null +++ b/backend/src/notification/enums/notification.enums.ts @@ -0,0 +1,39 @@ +export enum NotificationType { + // Course related + COURSE_ENROLLMENT = 'COURSE_ENROLLMENT', + COURSE_COMPLETION = 'COURSE_COMPLETION', + + // Lesson related + NEW_LESSON = 'NEW_LESSON', + LESSON_COMPLETION = 'LESSON_COMPLETION', + + // Quiz related + QUIZ_RESULT = 'QUIZ_RESULT', + QUIZ_REMINDER = 'QUIZ_REMINDER', + + // DAO related + DAO_UPDATE = 'DAO_UPDATE', + DAO_PROPOSAL = 'DAO_PROPOSAL', + DAO_VOTING = 'DAO_VOTING', + + // System related + SYSTEM_ANNOUNCEMENT = 'SYSTEM_ANNOUNCEMENT', + MAINTENANCE = 'MAINTENANCE', + + // User related + PROFILE_UPDATE = 'PROFILE_UPDATE', + PASSWORD_CHANGE = 'PASSWORD_CHANGE', +} + +export enum UserRole { + STUDENT = 'STUDENT', + TUTOR = 'TUTOR', + ADMIN = 'ADMIN', +} + +export enum NotificationPriority { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + URGENT = 'URGENT', +} diff --git a/backend/src/notification/hooks/notification.hooks.ts b/backend/src/notification/hooks/notification.hooks.ts new file mode 100644 index 0000000..76a8f6f --- /dev/null +++ b/backend/src/notification/hooks/notification.hooks.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationType, UserRole } from '../enums/notification.enums'; +import { NotificationService } from '../notification.service'; + +@Injectable() +export class NotificationHooks { + constructor(private readonly notificationService: NotificationService) {} + + async onCourseEnrollment(studentId: string, courseName: string) { + await this.notificationService.triggerNotification( + studentId, + UserRole.STUDENT, + NotificationType.COURSE_ENROLLMENT, + `You have successfully enrolled in ${courseName}`, + { courseName }, + ); + } + + async onLessonCompletion( + studentId: string, + lessonName: string, + courseName: string, + ) { + await this.notificationService.triggerNotification( + studentId, + UserRole.STUDENT, + NotificationType.LESSON_COMPLETION, + `Congratulations! You completed the lesson "${lessonName}" in ${courseName}`, + { lessonName, courseName }, + ); + } + + async onQuizResult( + studentId: string, + quizName: string, + score: number, + passed: boolean, + ) { + const message = passed + ? `Great job! You scored ${score}% on "${quizName}" and passed!` + : `You scored ${score}% on "${quizName}". Keep practicing!`; + + await this.notificationService.triggerNotification( + studentId, + UserRole.STUDENT, + NotificationType.QUIZ_RESULT, + message, + { quizName, score, passed }, + ); + } + + async onDAOProposal(recipientIds: string[], proposalTitle: string) { + await this.notificationService.sendBulkNotifications({ + recipientIds, + recipientRole: UserRole.ADMIN, + type: NotificationType.DAO_PROPOSAL, + message: `New DAO proposal: "${proposalTitle}" requires your attention`, + metadata: { proposalTitle }, + }); + } + + async onNewLesson(tutorId: string, lessonName: string, courseName: string) { + await this.notificationService.triggerNotification( + tutorId, + UserRole.TUTOR, + NotificationType.NEW_LESSON, + `New lesson "${lessonName}" has been added to ${courseName}`, + { lessonName, courseName }, + ); + } + + onSystemAnnouncement(message: string, targetRole?: UserRole) { + // This would typically get all users of a specific role + // For now, this is a placeholder for the implementation + console.log( + `System announcement for ${targetRole || 'all users'}: ${message}`, + ); + } +} diff --git a/backend/src/notification/notification.controller.ts b/backend/src/notification/notification.controller.ts new file mode 100644 index 0000000..5fe24de --- /dev/null +++ b/backend/src/notification/notification.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Query, + UseGuards, + Request, + ParseUUIDPipe, + Body, +} from '@nestjs/common'; +import type { NotificationService } from './notification.service'; +import type { + SendNotificationDto, + BulkSendNotificationDto, +} from './dto/send-notification.dto'; +import type { QueryNotificationDto } from './dto/query-notification.dto'; +import { RolesGuard } from '../roles/roles.guard'; +import { Roles } from '../roles/roles.decorator'; +import { UserRole } from './enums/notification.enums'; + +interface RequestUser { + id: string; + role: UserRole; +} + +interface RequestWithUser extends Request { + user: RequestUser; +} + +@Controller('notification') +export class NotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Post() + async sendNotification( + @Request() req: RequestWithUser, + @Body() sendNotificationDto: SendNotificationDto, + ) { + return await this.notificationService.sendNotification( + sendNotificationDto, + req.user.id, + req.user.role, + ); + } + + @Post('bulk') + async sendBulkNotifications( + @Request() req: RequestWithUser, + @Body() bulkSendDto: BulkSendNotificationDto, + ) { + return await this.notificationService.sendBulkNotifications( + bulkSendDto, + req.user.id, + req.user.role, + ); + } + + @Get() + async getUserNotifications( + @Request() req: RequestWithUser, + @Query() queryDto: QueryNotificationDto, + ) { + return await this.notificationService.findUserNotifications( + req.user.id, + req.user.role, + queryDto, + ); + } + + @Get('unread-count') + async getUnreadCount(@Request() req: RequestWithUser) { + const count = await this.notificationService.getUnreadCount( + req.user.id, + req.user.role, + ); + return { unreadCount: count }; + } + + @Patch(':id/read') + async markAsRead( + @Param('id', ParseUUIDPipe) id: string, + @Request() req: RequestWithUser, + ) { + return await this.notificationService.markAsRead( + id, + req.user.id, + req.user.role, + ); + } + + @Patch('read-all') + async markAllAsRead(@Request() req: RequestWithUser) { + await this.notificationService.markAllAsRead(req.user.id, req.user.role); + return { message: 'All notifications marked as read' }; + } + + @Delete(':id') + async deleteNotification( + @Param('id', ParseUUIDPipe) id: string, + @Request() req: RequestWithUser, + ) { + await this.notificationService.deleteNotification( + id, + req.user.id, + req.user.role, + ); + return { message: 'Notification deleted successfully' }; + } + + @Delete('admin/:id') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + async deleteNotificationAsAdmin(@Param('id', ParseUUIDPipe) id: string) { + await this.notificationService.deleteNotificationAsAdmin(id); + return { message: 'Notification deleted successfully' }; + } +} diff --git a/backend/src/notification/notification.module.ts b/backend/src/notification/notification.module.ts new file mode 100644 index 0000000..1a31e68 --- /dev/null +++ b/backend/src/notification/notification.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationController } from './notification.controller'; +import { NotificationService } from './notification.service'; +import { Notification } from './entities/notification.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Notification])], + controllers: [NotificationController], + providers: [NotificationService], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/backend/src/notification/notification.service.ts b/backend/src/notification/notification.service.ts new file mode 100644 index 0000000..5ccd207 --- /dev/null +++ b/backend/src/notification/notification.service.ts @@ -0,0 +1,260 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { type Repository, type FindOptionsWhere, Between } from 'typeorm'; +import type { Notification } from './entities/notification.entity'; +import type { CreateNotificationDto } from './dto/create-notification.dto'; +import type { + SendNotificationDto, + BulkSendNotificationDto, +} from './dto/send-notification.dto'; +import type { QueryNotificationDto } from './dto/query-notification.dto'; +import type { UserRole } from './enums/notification.enums'; + +@Injectable() +export class NotificationService { + private notificationRepository: Repository; + + constructor(notificationRepository: Repository) { + this.notificationRepository = notificationRepository; + } + + async create( + createNotificationDto: CreateNotificationDto, + ): Promise { + const notification = this.notificationRepository.create( + createNotificationDto, + ); + return await this.notificationRepository.save(notification); + } + + async sendNotification( + sendNotificationDto: SendNotificationDto, + senderId?: string, + senderRole?: UserRole, + ): Promise { + const notificationData: CreateNotificationDto = { + ...sendNotificationDto, + senderId, + senderRole, + }; + + const notification = await this.create(notificationData); + + // Here you can add real-time notification logic (WebSocket, SSE, etc.) + // await this.sendRealTimeNotification(notification); + + return notification; + } + + async sendBulkNotifications( + bulkSendDto: BulkSendNotificationDto, + senderId?: string, + senderRole?: UserRole, + ): Promise { + const notifications = bulkSendDto.recipientIds.map((recipientId) => + this.notificationRepository.create({ + recipientId, + recipientRole: bulkSendDto.recipientRole, + type: bulkSendDto.type, + message: bulkSendDto.message, + metadata: bulkSendDto.metadata, + senderId, + senderRole, + }), + ); + + const savedNotifications = + await this.notificationRepository.save(notifications); + + // Send real-time notifications for each recipient + // for (const notification of savedNotifications) { + // await this.sendRealTimeNotification(notification); + // } + + return savedNotifications; + } + + async findUserNotifications( + userId: string, + userRole: UserRole, + queryDto: QueryNotificationDto, + ) { + const { type, isRead, fromDate, toDate, page = 1, limit = 20 } = queryDto; + + const where: FindOptionsWhere = { + recipientId: userId, + recipientRole: userRole, + }; + + if (type) { + where.type = type; + } + + if (typeof isRead === 'boolean') { + where.isRead = isRead; + } + + if (fromDate || toDate) { + where.createdAt = Between( + fromDate ? new Date(fromDate) : new Date('1970-01-01'), + toDate ? new Date(toDate) : new Date(), + ); + } + + const [notifications, total] = + await this.notificationRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + notifications, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async markAsRead( + notificationId: string, + userId: string, + userRole: UserRole, + ): Promise { + const notification = await this.notificationRepository.findOne({ + where: { + id: notificationId, + recipientId: userId, + recipientRole: userRole, + }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + notification.isRead = true; + return await this.notificationRepository.save(notification); + } + + async markAllAsRead(userId: string, userRole: UserRole): Promise { + await this.notificationRepository.update( + { + recipientId: userId, + recipientRole: userRole, + isRead: false, + }, + { isRead: true }, + ); + } + + async deleteNotification( + notificationId: string, + userId: string, + userRole: UserRole, + ): Promise { + const notification = await this.notificationRepository.findOne({ + where: { + id: notificationId, + recipientId: userId, + recipientRole: userRole, + }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + await this.notificationRepository.remove(notification); + } + + async getUnreadCount(userId: string, userRole: UserRole): Promise { + return await this.notificationRepository.count({ + where: { + recipientId: userId, + recipientRole: userRole, + isRead: false, + }, + }); + } + + // Admin-only methods + async findAllNotifications(queryDto: QueryNotificationDto) { + const { type, isRead, fromDate, toDate, page = 1, limit = 20 } = queryDto; + + const where: FindOptionsWhere = {}; + + if (type) { + where.type = type; + } + + if (typeof isRead === 'boolean') { + where.isRead = isRead; + } + + if (fromDate || toDate) { + where.createdAt = Between( + fromDate ? new Date(fromDate) : new Date('1970-01-01'), + toDate ? new Date(toDate) : new Date(), + ); + } + + const [notifications, total] = + await this.notificationRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + notifications, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async deleteNotificationAsAdmin(notificationId: string): Promise { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + await this.notificationRepository.remove(notification); + } + + // Helper method for triggering notifications from other services + async triggerNotification( + recipientId: string, + recipientRole: UserRole, + type: string, + message: string, + metadata?: Record, + senderId?: string, + senderRole?: UserRole, + ): Promise { + return await this.sendNotification( + { + recipientId, + recipientRole, + type: type as unknown as Notification['type'], + message, + metadata, + }, + senderId, + senderRole, + ); + } + + // Future: Real-time notification method + // private async sendRealTimeNotification(notification: Notification): Promise { + // // Implement WebSocket or Server-Sent Events logic here + // // Example: this.websocketGateway.sendToUser(notification.recipientId, notification); + // } +} diff --git a/frontend/app/(root)/page.tsx b/frontend/app/(root)/page.tsx index 8831216..b270d6e 100644 --- a/frontend/app/(root)/page.tsx +++ b/frontend/app/(root)/page.tsx @@ -1,8 +1,5 @@ import LandingPage from "@/components/templates/LandingTemplate"; export default function Home() { - - return ( - - ); + return ; } diff --git a/frontend/app/dashboard/layout.tsx b/frontend/app/dashboard/layout.tsx index 5f34bd1..447710a 100644 --- a/frontend/app/dashboard/layout.tsx +++ b/frontend/app/dashboard/layout.tsx @@ -2,9 +2,7 @@ import { ReactNode } from "react"; const RootLayout = ({ children }: { children: ReactNode }) => { return ( -
- {children} -
+
{children}
); }; diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 86122d2..db4d387 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,8 +1,5 @@ - const page = () => { - return ( -
page
- ) -} + return
page
; +}; -export default page \ No newline at end of file +export default page; diff --git a/frontend/app/globals.css b/frontend/app/globals.css index c61e7ad..5a84c2f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,9 +1,8 @@ -@import url('https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,100..900;1,100..900&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,100..900;1,100..900&display=swap"); @import "tailwindcss"; @import "tw-animate-css"; - @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2c43960..77975a8 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -16,11 +16,7 @@ export default function RootLayout({ }>) { return ( - - {children} - + {children} ); } diff --git a/frontend/components.json b/frontend/components.json index 2883c94..3d296c4 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/frontend/components/atoms/Title.tsx b/frontend/components/atoms/Title.tsx index 4e5e2e6..f457cfe 100644 --- a/frontend/components/atoms/Title.tsx +++ b/frontend/components/atoms/Title.tsx @@ -1,10 +1,13 @@ -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Title = ({ className, ...props }: React.ComponentProps<"h2">) => { return (

); diff --git a/frontend/components/atoms/skeleton.tsx b/frontend/components/atoms/skeleton.tsx index ec91895..3c43103 100644 --- a/frontend/components/atoms/skeleton.tsx +++ b/frontend/components/atoms/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { className={cn("bg-[#94A3B8] animate-pulse rounded-md", className)} {...props} /> - ) + ); } -export { Skeleton } +export { Skeleton }; diff --git a/frontend/components/molecules/InputLabel.tsx b/frontend/components/molecules/InputLabel.tsx index e304361..e9902d6 100644 --- a/frontend/components/molecules/InputLabel.tsx +++ b/frontend/components/molecules/InputLabel.tsx @@ -31,7 +31,9 @@ export const InputWithLabel = ({ const errorId = `${name}-error`; return (
- + {error && touched && ( -

+

{error}

)} diff --git a/frontend/components/organisms/landing-page/CoursesSection.tsx b/frontend/components/organisms/landing-page/CoursesSection.tsx index 9159855..9aa8c33 100644 --- a/frontend/components/organisms/landing-page/CoursesSection.tsx +++ b/frontend/components/organisms/landing-page/CoursesSection.tsx @@ -1,61 +1,61 @@ -import React from "react"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import Image from "next/image"; -import { coursesData } from "@/utils/coursesData"; -import Title from "@/components/atoms/Title"; - -const CoursesSection = () => { - return ( -
-
- Courses -
-
- {coursesData.map((course, index) => ( - - {course.imageAlt} - - - - {course.title} - -

- Duration: {course.duration} -

-
- -

{course.description}

-
- - - -
- ))} -
-
- ); -}; - -export default CoursesSection; +import React from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import Image from "next/image"; +import { coursesData } from "@/utils/coursesData"; +import Title from "@/components/atoms/Title"; + +const CoursesSection = () => { + return ( +
+
+ Courses +
+
+ {coursesData.map((course, index) => ( + + {course.imageAlt} + + + + {course.title} + +

+ Duration: {course.duration} +

+
+ +

{course.description}

+
+ + + +
+ ))} +
+
+ ); +}; + +export default CoursesSection; diff --git a/frontend/components/organisms/landing-page/CurrencyHub.tsx b/frontend/components/organisms/landing-page/CurrencyHub.tsx index cc2d6f5..810d480 100644 --- a/frontend/components/organisms/landing-page/CurrencyHub.tsx +++ b/frontend/components/organisms/landing-page/CurrencyHub.tsx @@ -53,7 +53,7 @@ export default function CurrencyHub() { const toggleFavorite = (rank: number) => { setFavorites((prev) => - prev.includes(rank) ? prev.filter((id) => id !== rank) : [...prev, rank] + prev.includes(rank) ? prev.filter((id) => id !== rank) : [...prev, rank], ); }; @@ -68,7 +68,9 @@ export default function CurrencyHub() { # Name - Price + + Price + 1h % diff --git a/frontend/components/organisms/landing-page/Footer.tsx b/frontend/components/organisms/landing-page/Footer.tsx index 6c9c39c..651b30e 100644 --- a/frontend/components/organisms/landing-page/Footer.tsx +++ b/frontend/components/organisms/landing-page/Footer.tsx @@ -1,33 +1,33 @@ -import Link from "next/link"; -import { - RiFacebookLine, - RiInstagramLine, - RiTwitterXLine, -} from "react-icons/ri"; - -export default function Footer() { - return ( -
-
-
- © 2025. All rights reserved. Nexacore Org. -
-

-
- {/* instagram */} - - - - {/* twitter */} - - - - {/* facebook */} - - - -
-
-
- ); -} +import Link from "next/link"; +import { + RiFacebookLine, + RiInstagramLine, + RiTwitterXLine, +} from "react-icons/ri"; + +export default function Footer() { + return ( +
+
+
+ © 2025. All rights reserved. Nexacore Org. +
+

+
+ {/* instagram */} + + + + {/* twitter */} + + + + {/* facebook */} + + + +
+
+
+ ); +} diff --git a/frontend/components/templates/LandingTemplate.tsx b/frontend/components/templates/LandingTemplate.tsx index a66fc61..2022a22 100644 --- a/frontend/components/templates/LandingTemplate.tsx +++ b/frontend/components/templates/LandingTemplate.tsx @@ -7,9 +7,8 @@ import LandingPageSkeleton from "../skeleton/LandingPageSkeleton"; import WhoItsFor from "../organisms/landing-page/WhoItsFor"; import { useLoadingStore } from "@/store/useLoadingStore"; - export default function LandingPage() { - const {loading, setLoading} = useLoadingStore(); + const { loading, setLoading } = useLoadingStore(); // Simulate data loading useEffect(() => { diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index a2df8dc..2adaf00 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -32,8 +32,8 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } -) + }, +); function Button({ className, @@ -43,9 +43,9 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx index d05bbc6..113d66c 100644 --- a/frontend/components/ui/card.tsx +++ b/frontend/components/ui/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) { data-slot="card" className={cn( "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", - className + className, )} {...props} /> - ) + ); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-header" className={cn( "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", - className + className, )} {...props} /> - ) + ); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { className={cn("leading-none font-semibold", className)} {...props} /> - ) + ); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { @@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { className={cn("text-muted-foreground text-sm", className)} {...props} /> - ) + ); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { @@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-action" className={cn( "col-start-2 row-span-2 row-start-1 self-start justify-self-end", - className + className, )} {...props} /> - ) + ); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { @@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) { className={cn("px-6", className)} {...props} /> - ) + ); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> - ) + ); } export { @@ -89,4 +89,4 @@ export { CardAction, CardDescription, CardContent, -} +}; diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index 03295ca..b1a060f 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( @@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - className + className, )} {...props} /> - ) + ); } -export { Input } +export { Input }; diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx index fb5fbc3..79d77b4 100644 --- a/frontend/components/ui/label.tsx +++ b/frontend/components/ui/label.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Label({ className, @@ -14,11 +14,11 @@ function Label({ data-slot="label" className={cn( "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", - className + className, )} {...props} /> - ) + ); } -export { Label } +export { Label }; diff --git a/frontend/components/ui/table.tsx b/frontend/components/ui/table.tsx index 51b74dd..4b3c98e 100644 --- a/frontend/components/ui/table.tsx +++ b/frontend/components/ui/table.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Table({ className, ...props }: React.ComponentProps<"table">) { return ( @@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) { {...props} />
- ) + ); } function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { @@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { className={cn("[&_tr]:border-b", className)} {...props} /> - ) + ); } function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { @@ -36,7 +36,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { className={cn("[&_tr:last-child]:border-0", className)} {...props} /> - ) + ); } function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { @@ -45,11 +45,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { data-slot="table-footer" className={cn( "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", - className + className, )} {...props} /> - ) + ); } function TableRow({ className, ...props }: React.ComponentProps<"tr">) { @@ -58,11 +58,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { data-slot="table-row" className={cn( "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", - className + className, )} {...props} /> - ) + ); } function TableHead({ className, ...props }: React.ComponentProps<"th">) { @@ -71,11 +71,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { data-slot="table-head" className={cn( "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", - className + className, )} {...props} /> - ) + ); } function TableCell({ className, ...props }: React.ComponentProps<"td">) { @@ -84,11 +84,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { data-slot="table-cell" className={cn( "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", - className + className, )} {...props} /> - ) + ); } function TableCaption({ @@ -101,7 +101,7 @@ function TableCaption({ className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} /> - ) + ); } export { @@ -113,4 +113,4 @@ export { TableRow, TableCell, TableCaption, -} +}; diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index bd0c391..a5ef193 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/frontend/utils/coursesData.ts b/frontend/utils/coursesData.ts index 1a73fda..413b50d 100644 --- a/frontend/utils/coursesData.ts +++ b/frontend/utils/coursesData.ts @@ -34,4 +34,4 @@ export const coursesData: coursesDataProps[] = [ "Exploring Non-Fungible Tokens, Digital Scarcity, And Applications Beyond Digital Art", imageAlt: "Professional in business attire smiling", }, -]; \ No newline at end of file +]; diff --git a/frontend/utils/validationSchema.ts b/frontend/utils/validationSchema.ts index 4fb8c86..e55d19a 100644 --- a/frontend/utils/validationSchema.ts +++ b/frontend/utils/validationSchema.ts @@ -13,7 +13,7 @@ export const signUpValidationSchema = Yup.object().shape({ .min(8, "Password must be at least 8 characters") .matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, - "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character" + "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character", ), confirmPassword: Yup.string() .required("Confirm Password is required")