diff --git a/backend/src/events/events.controller.ts b/backend/src/events/events.controller.ts index ccae3a8..252ac04 100644 --- a/backend/src/events/events.controller.ts +++ b/backend/src/events/events.controller.ts @@ -11,7 +11,12 @@ import { UseGuards, HttpCode, HttpStatus, + UseInterceptors, // Added + UploadedFile, // Added } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; // Added +import { diskStorage } from 'multer'; // Added +import { extname } from 'path'; // Added import { EventsService } from './events.service'; import { CreateEventDto } from './dto/create-event.dto'; import { UpdateEventDto } from './dto/update-event.dto'; @@ -33,12 +38,21 @@ export class EventsController { @Query('date_from') dateFrom?: string, @Query('date_to') dateTo?: string, ) { - return this.eventsService.findAllPublished({ - search, - location, - dateFrom, - dateTo, - }); + return this.eventsService.findAllPublished({ search, location, dateFrom, dateTo }); + } + + @Get('my/events') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ORG) + findMyEvents(@GetUser() user: JwtPayload) { + return this.eventsService.findMyEvents(user.sub); + } + + @Get('my/user-events') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.USER) + findUserEvents(@GetUser() user: JwtPayload) { + return this.eventsService.findUserEvents(user.sub); } @Get(':id') @@ -46,19 +60,32 @@ export class EventsController { return this.eventsService.findOne(id); } - // Organizer - @Post() + // NEW: FILE UPLOAD ENDPOINT + @Post(':id/upload') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ORG) - create(@Body() dto: CreateEventDto, @GetUser() user: JwtPayload) { - return this.eventsService.create(dto, user.sub); + @UseInterceptors(FileInterceptor('file', { + storage: diskStorage({ + destination: './uploads/documents', + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + cb(null, `${file.fieldname}-${uniqueSuffix}${extname(file.originalname)}`); + }, + }), + })) + uploadFile( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: Express.Multer.File, + @GetUser() user: JwtPayload, + ) { + return this.eventsService.addDocument(id, user.sub, file); } - @Get('my/events') + @Post() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ORG) - findMyEvents(@GetUser() user: JwtPayload) { - return this.eventsService.findMyEvents(user.sub); + create(@Body() dto: CreateEventDto, @GetUser() user: JwtPayload) { + return this.eventsService.create(dto, user.sub); } @Patch(':id') @@ -82,51 +109,28 @@ export class EventsController { @Get(':id/participants') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ORG) - getParticipants( - @Param('id', ParseIntPipe) id: number, - @GetUser() user: JwtPayload, - ) { + getParticipants(@Param('id', ParseIntPipe) id: number, @GetUser() user: JwtPayload) { return this.eventsService.getParticipants(id, user.sub); } - // Participant - @Post(':id/register') @UseGuards(JwtAuthGuard) - registerForEvent( - @Param('id', ParseIntPipe) id: number, - @GetUser() user: JwtPayload, - ) { + registerForEvent(@Param('id', ParseIntPipe) id: number, @GetUser() user: JwtPayload) { return this.eventsService.registerParticipant(id, user.sub); } @Delete(':id/cancel-registration') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) - cancelRegistration( - @Param('id', ParseIntPipe) id: number, - @GetUser() user: JwtPayload, - ) { + cancelRegistration(@Param('id', ParseIntPipe) id: number, @GetUser() user: JwtPayload) { return this.eventsService.cancelRegistration(id, user.sub); } - @Get('my/user-events') - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(Role.USER) - findUserEvents(@GetUser() user: JwtPayload) { - return this.eventsService.findUserEvents(user.sub); - } - - // Organizer OR Admin - @Delete(':id') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ORG, Role.ADMIN) @HttpCode(HttpStatus.OK) - cancelEvent( - @Param('id', ParseIntPipe) id: number, - @GetUser() user: JwtPayload, - ) { + cancelEvent(@Param('id', ParseIntPipe) id: number, @GetUser() user: JwtPayload) { return this.eventsService.cancel(id, user.sub, user.role); } -} +} \ No newline at end of file diff --git a/backend/src/events/events.module.ts b/backend/src/events/events.module.ts index a6037c2..270be48 100644 --- a/backend/src/events/events.module.ts +++ b/backend/src/events/events.module.ts @@ -3,9 +3,10 @@ import { EventsService } from './events.service'; import { EventsController } from './events.controller'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; +import { MailModule } from '../mail/mail.module'; @Module({ - imports: [PrismaModule, AuthModule], + imports: [PrismaModule, AuthModule,MailModule], providers: [EventsService], controllers: [EventsController], }) diff --git a/backend/src/events/events.service.ts b/backend/src/events/events.service.ts index 9fdea44..13c8a4f 100644 --- a/backend/src/events/events.service.ts +++ b/backend/src/events/events.service.ts @@ -6,14 +6,18 @@ import { ConflictException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { MailService } from '../mail/mail.service'; import { CreateEventDto } from './dto/create-event.dto'; import { UpdateEventDto } from './dto/update-event.dto'; @Injectable() export class EventsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private mailService: MailService, + ) {} - // R11 — Create event (Organizer only) + // R11 — Create event async create(dto: CreateEventDto, organizerId: number) { return this.prisma.event.create({ data: { @@ -27,7 +31,7 @@ export class EventsService { }); } - // R13 + R33 — List all published events with optional search, location, and date range filters + // R13 + R33 — List all published events async findAllPublished(filters?: { search?: string; location?: string; @@ -68,10 +72,12 @@ export class EventsService { }); } - // List organizer's own events (any status) async findMyEvents(organizerId: number) { return this.prisma.event.findMany({ - where: { organizer_id: organizerId }, + where: { + organizer_id: organizerId, + is_cancelled: false + }, include: { _count: { select: { registrations: true } }, }, @@ -79,7 +85,6 @@ export class EventsService { }); } - // List events the user is registered for async findUserEvents(userId: number) { return this.prisma.event.findMany({ where: { @@ -103,12 +108,14 @@ export class EventsService { orderBy: { created_at: 'desc' }, }); } - // Get a single event by ID + + // UPDATED: Added documents to include async findOne(eventId: number) { const event = await this.prisma.event.findUnique({ where: { event_id: eventId }, include: { organizer: { select: { user_id: true, username: true } }, + documents: true, // <--- ADD THIS LINE _count: { select: { registrations: { where: { status: 'CONFIRMED' } } }, }, @@ -118,7 +125,24 @@ export class EventsService { return event; } - // R30 — Update event details (Organizer, own event only) + // NEW METHOD: Handle the database record for the file + async addDocument(eventId: number, userId: number, file: Express.Multer.File) { + const event = await this.findOne(eventId); + if (event.organizer_id !== userId) + throw new ForbiddenException('You can only upload files to your own events'); + + return this.prisma.document.create({ + data: { + event_id: eventId, + uploaded_by: userId, + file_name: file.originalname, + file_path: `/uploads/documents/${file.filename}`, + file_size_kb: Math.round(file.size / 1024), + }, + }); + } + + // R30 — Update event details async update(eventId: number, dto: UpdateEventDto, userId: number) { const event = await this.findOne(eventId); if (event.organizer_id !== userId) @@ -126,7 +150,7 @@ export class EventsService { if (event.is_cancelled) throw new BadRequestException('Cannot edit a cancelled event'); - return this.prisma.event.update({ + const updatedEvent = await this.prisma.event.update({ where: { event_id: eventId }, data: { ...(dto.title !== undefined && { title: dto.title }), @@ -138,23 +162,12 @@ export class EventsService { ...(dto.capacity !== undefined && { capacity: dto.capacity }), }, }); - } - // Publish event (Organizer, own event) - async publish(eventId: number, userId: number) { - const event = await this.findOne(eventId); - if (event.organizer_id !== userId) - throw new ForbiddenException('You can only publish your own events'); - if (event.is_cancelled) - throw new BadRequestException('Cannot publish a cancelled event'); - - return this.prisma.event.update({ - where: { event_id: eventId }, - data: { is_published: true }, - }); + await this.notifyParticipants(eventId, event.title, 'update'); + return updatedEvent; } - // R21 — Cancel event (Organizer cancels own, Admin cancels any) + // R21 — Cancel event async cancel(eventId: number, userId: number, userRole: string) { const event = await this.findOne(eventId); if (event.is_cancelled) @@ -165,19 +178,47 @@ export class EventsService { if (!isAdmin && !isOwner) throw new ForbiddenException('Not authorised to cancel this event'); - return this.prisma.event.update({ + const cancelledEvent = await this.prisma.event.update({ where: { event_id: eventId }, data: { is_cancelled: true, is_published: false }, }); + + await this.notifyParticipants(eventId, event.title, 'cancel'); + return cancelledEvent; + } + + private async notifyParticipants(eventId: number, title: string, type: 'update' | 'cancel') { + const registrations = await this.prisma.registration.findMany({ + where: { event_id: eventId, status: 'CONFIRMED' }, + include: { user: { select: { email: true } } }, + }); + + for (const reg of registrations) { + if (type === 'update') { + this.mailService.sendEventUpdateEmail(reg.user.email, title); + } else { + this.mailService.sendEventCancellationEmail(reg.user.email, title); + } + } + } + + async publish(eventId: number, userId: number) { + const event = await this.findOne(eventId); + if (event.organizer_id !== userId) + throw new ForbiddenException('You can only publish your own events'); + if (event.is_cancelled) + throw new BadRequestException('Cannot publish a cancelled event'); + + return this.prisma.event.update({ + where: { event_id: eventId }, + data: { is_published: true }, + }); } - // R24 — Participant list for an event (Organizer of that event only) async getParticipants(eventId: number, userId: number) { const event = await this.findOne(eventId); if (event.organizer_id !== userId) - throw new ForbiddenException( - 'Only the organizer can view the participant list', - ); + throw new ForbiddenException('Only the organizer can view the participant list'); return this.prisma.registration.findMany({ where: { event_id: eventId, status: 'CONFIRMED' }, @@ -188,29 +229,23 @@ export class EventsService { }); } - // R14 — Register participant to event (checks R15 capacity) async registerParticipant(eventId: number, userId: number) { const event = await this.findOne(eventId); - - if (!event.is_published) - throw new BadRequestException('Event is not published'); + if (!event.is_published) throw new BadRequestException('Event is not published'); if (event.is_cancelled) throw new BadRequestException('Event is cancelled'); - // R15 — capacity check const count = await this.prisma.registration.count({ where: { event_id: eventId, status: 'CONFIRMED' }, }); - if (count >= event.capacity) - throw new BadRequestException('Event is fully booked'); + if (count >= event.capacity) throw new BadRequestException('Event is fully booked'); - // guard against duplicate registration const existing = await this.prisma.registration.findUnique({ where: { user_id_event_id: { user_id: userId, event_id: eventId } }, }); + if (existing) { if (existing.status === 'CONFIRMED') throw new ConflictException('Already registered for this event'); - // re-register after cancellation return this.prisma.registration.update({ where: { user_id_event_id: { user_id: userId, event_id: eventId } }, data: { status: 'CONFIRMED' }, @@ -222,16 +257,11 @@ export class EventsService { }); } - // R12 — Cancel own registration async cancelRegistration(eventId: number, userId: number) { const existing = await this.prisma.registration.findUnique({ where: { user_id_event_id: { user_id: userId, event_id: eventId } }, }); - - // If it doesn't exist at all, that's a real 404 if (!existing) throw new NotFoundException('Registration not found'); - - // If it's already cancelled, just return the record instead of throwing error if (existing.status === 'CANCELLED') return existing; return this.prisma.registration.update({ @@ -239,4 +269,4 @@ export class EventsService { data: { status: 'CANCELLED' }, }); } -} +} \ No newline at end of file diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index 075d61e..62d0fcd 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -13,7 +13,6 @@ export class MailService { async sendPasswordResetEmail(email: string, token: string) { const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; - await this.transporter.sendMail({ from: `"Event System" <${process.env.SMTP_USER}>`, to: email, @@ -26,4 +25,33 @@ export class MailService { `, }); } -} + + // NEW: Notify users of changes to an event + async sendEventUpdateEmail(email: string, eventTitle: string) { + await this.transporter.sendMail({ + from: `"Event System" <${process.env.SMTP_USER}>`, + to: email, + subject: `Update: Changes to ${eventTitle}`, + html: ` +

Event Update

+

Hello! We wanted to let you know that the details for ${eventTitle} have been updated by the organizer.

+

Please log in to the dashboard to check the new location or time.

+ View Event Details + `, + }); + } + + // NEW: Notify users if an event is cancelled + async sendEventCancellationEmail(email: string, eventTitle: string) { + await this.transporter.sendMail({ + from: `"Event System" <${process.env.SMTP_USER}>`, + to: email, + subject: `Notice: ${eventTitle} has been cancelled`, + html: ` +

Event Cancelled

+

We regret to inform you that ${eventTitle} has been cancelled by the organizer.

+

If you have any questions, please contact the organizer directly.

+ `, + }); + } +} \ No newline at end of file diff --git a/backend/uploads/documents/file-1773515185868-324838748.pdf b/backend/uploads/documents/file-1773515185868-324838748.pdf new file mode 100644 index 0000000..b714960 Binary files /dev/null and b/backend/uploads/documents/file-1773515185868-324838748.pdf differ diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 966d3ce..1f3f9dd 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -6,39 +6,55 @@ import { rolesGuard } from './core/guards/roles.guard'; import { ForgotPasswordComponent } from './auth/forgot-password/forgot-password'; import { ResetPasswordComponent } from './auth/reset-password/reset-password'; import { UserDashboardComponent } from './user-dashboard/user-dashboard'; +import { OrganizerDashboardComponent } from './organizer-dashboard/organizer-dashboard'; import { BrowseEventsComponent } from './events/events'; import { EventDetailsComponent } from './events/event-details/event-details'; +import { CreateEventComponent } from './events/create-event/create-event'; import { ProfileComponent } from './user-dashboard/profile/profile'; +import { ParticipantListComponent } from './events/participant-list/participant-list'; export const routes: Routes = [ - // Public routes { path: 'register', component: RegisterComponent }, { path: 'login', component: LoginComponent }, { path: 'forgot-password', component: ForgotPasswordComponent }, - { path: 'reset-password', component: ResetPasswordComponent }, + + // User Dashboard { path: 'user-dashboard', component: UserDashboardComponent, canActivate: [authGuard, rolesGuard(['USER'])], }, - // Protected routes (canActivate: [authGuard] applied — add components as they are built) - // Any logged-in user + + // Organizer Dashboard + { + path: 'organizer-dashboard', + component: OrganizerDashboardComponent, + canActivate: [authGuard, rolesGuard(['ORG'])], + }, + + // Organizer: Create Event + { + path: 'create-event', + component: CreateEventComponent, + canActivate: [authGuard, rolesGuard(['ORG'])], + }, + + // Participant List (Organizer Only) + { + path: 'events/:id/participants', + component: ParticipantListComponent, + canActivate: [authGuard, rolesGuard(['ORG'])] + }, + + // General Protected Routes { path: 'events', component: BrowseEventsComponent, canActivate: [authGuard] }, { path: 'events/:id', component: EventDetailsComponent, canActivate: [authGuard] }, { path: 'profile', component: ProfileComponent, canActivate: [authGuard] }, - // Organizer only - // { path: 'organizer/events', component: OrgEventListComponent, canActivate: [authGuard, rolesGuard(['ORG'])] }, - // { path: 'organizer/events/create', component: CreateEventComponent, canActivate: [authGuard, rolesGuard(['ORG'])] }, - - // Admin only - // { path: 'admin/users', component: AdminUsersComponent, canActivate: [authGuard, rolesGuard(['ADMIN'])] }, - // { path: 'admin/logs', component: AdminLogsComponent, canActivate: [authGuard, rolesGuard(['ADMIN'])] }, - + // Default Redirects { path: '', redirectTo: 'login', pathMatch: 'full' }, { path: '**', redirectTo: 'login' }, ]; -// Re-export guards so other modules can import from one place -export { authGuard, rolesGuard }; +export { authGuard, rolesGuard }; \ No newline at end of file diff --git a/frontend/src/app/auth/login/login.component.ts b/frontend/src/app/auth/login/login.component.ts index 7081de9..190f4e7 100644 --- a/frontend/src/app/auth/login/login.component.ts +++ b/frontend/src/app/auth/login/login.component.ts @@ -34,9 +34,18 @@ export class LoginComponent { next: () => { this.message = 'Login successful! Redirecting...'; this.error = ''; - // this.cdr.detectChanges(); + + // The AuthService.login method uses tap() to save the token. + // We now extract the role from that saved token. + const role = this.auth.getRole(); + setTimeout(() => { - this.router.navigate(['/user-dashboard']); + // Updated to check for 'ORG' instead of 'organization' + if (role === 'ORG') { + this.router.navigate(['/organizer-dashboard']); + } else { + this.router.navigate(['/user-dashboard']); + } }, 1000); }, error: (err) => { @@ -45,8 +54,6 @@ export class LoginComponent { this.cdr.detectChanges(); }, }); - } else { - this.error = 'Please enter valid email and password.'; } } -} +} \ No newline at end of file diff --git a/frontend/src/app/core/guards/auth.guard.ts b/frontend/src/app/core/guards/auth.guard.ts index 7f8cffa..75bd695 100644 --- a/frontend/src/app/core/guards/auth.guard.ts +++ b/frontend/src/app/core/guards/auth.guard.ts @@ -2,10 +2,6 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { AuthService } from '../../auth/auth.service'; -/** - * Protects routes that require the user to be logged in. - * Redirects to /login if no valid token is found. - */ export const authGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); diff --git a/frontend/src/app/core/guards/roles.guard.ts b/frontend/src/app/core/guards/roles.guard.ts index 5e3b02c..7f79d7d 100644 --- a/frontend/src/app/core/guards/roles.guard.ts +++ b/frontend/src/app/core/guards/roles.guard.ts @@ -1,26 +1,17 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { AuthService } from '../../auth/auth.service'; +export const rolesGuard = (allowedRoles: string[]): CanActivateFn => { + return () => { + const auth = inject(AuthService); + const router = inject(Router); -/** - * Protects routes that require a specific role. - * Usage in routes: canActivate: [authGuard, rolesGuard(['ORG'])] - * or: canActivate: [authGuard, rolesGuard(['ADMIN'])] - * or: canActivate: [authGuard, rolesGuard(['ORG', 'ADMIN'])] - * - * Always combine with authGuard — rolesGuard assumes the user is already - * authenticated and focuses only on role validation. - */ -export const rolesGuard = (allowedRoles: string[]): CanActivateFn => () => { - const auth = inject(AuthService); - const router = inject(Router); - - const role = auth.getRole(); - - if (role && allowedRoles.includes(role)) { - return true; - } - - // Authenticated but wrong role — send back to home - return router.createUrlTree(['/']); -}; + const role = auth.getRole(); + console.log(`[RolesGuard] User Role: ${role} | Required: ${allowedRoles}`); + if (role && allowedRoles.includes(role)) { + return true; + } + console.warn(`[RolesGuard] Access Denied. Redirecting to login.`); + return router.createUrlTree(['/login']); + }; +}; \ No newline at end of file diff --git a/frontend/src/app/events/create-event/create-event.html b/frontend/src/app/events/create-event/create-event.html new file mode 100644 index 0000000..6d13065 --- /dev/null +++ b/frontend/src/app/events/create-event/create-event.html @@ -0,0 +1,47 @@ +
+
+
+

Launch New Event

+

Fill in the details to publish your event to the community.

+
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ {{ errorMessage }} +
+ +
+ + +
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/events/create-event/create-event.scss b/frontend/src/app/events/create-event/create-event.scss new file mode 100644 index 0000000..daad78b --- /dev/null +++ b/frontend/src/app/events/create-event/create-event.scss @@ -0,0 +1,71 @@ +.create-event-container { + padding: 40px 20px; + display: flex; + justify-content: center; + background: #f4f7f6; + min-height: 100vh; + + .form-card { + background: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0,0,0,0.05); + width: 100%; + max-width: 600px; + + header { + margin-bottom: 25px; + h2 { color: #2d3436; margin: 0; } + p { color: #636e72; font-size: 0.9rem; } + } + } + + .form-group { + margin-bottom: 20px; + label { display: block; margin-bottom: 8px; font-weight: 600; color: #2d3436; } + input, textarea { + width: 100%; + padding: 12px; + border: 1px solid #dfe6e9; + border-radius: 6px; + font-family: inherit; + &:focus { outline: none; border-color: #0984e3; } + } + } + + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + } + + .actions { + display: flex; + justify-content: flex-end; + gap: 15px; + margin-top: 30px; + + button { + padding: 12px 24px; + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 600; + } + + .btn-cancel { background: #dfe6e9; color: #2d3436; } + .btn-submit { + background: #0984e3; color: white; + &:disabled { background: #74b9ff; cursor: not-allowed; } + } + } + + .error-banner { + color: #d63031; + background: #ff767522; + padding: 10px; + border-radius: 4px; + margin-bottom: 15px; + font-size: 0.85rem; + } +} \ No newline at end of file diff --git a/frontend/src/app/events/create-event/create-event.spec.ts b/frontend/src/app/events/create-event/create-event.spec.ts new file mode 100644 index 0000000..5aa3948 --- /dev/null +++ b/frontend/src/app/events/create-event/create-event.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CreateEvent } from './create-event'; + +describe('CreateEvent', () => { + let component: CreateEvent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreateEvent], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateEvent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/events/create-event/create-event.ts b/frontend/src/app/events/create-event/create-event.ts new file mode 100644 index 0000000..da80f0e --- /dev/null +++ b/frontend/src/app/events/create-event/create-event.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { EventService } from '../event.service'; + +@Component({ + selector: 'app-create-event', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterModule], + templateUrl: './create-event.html', + styleUrls: ['./create-event.scss'] +}) +export class CreateEventComponent { + eventForm: FormGroup; + submitting = false; + errorMessage = ''; + + constructor( + private fb: FormBuilder, + private eventService: EventService, + private router: Router + ) { + this.eventForm = this.fb.group({ + title: ['', [Validators.required, Validators.minLength(3)]], + description: ['', [Validators.required]], + location: ['', [Validators.required]], + event_date: ['', [Validators.required]], + capacity: [10, [Validators.required, Validators.min(1)]] + }); + } + + onSubmit() { + if (this.eventForm.valid) { + this.submitting = true; + this.eventService.createEvent(this.eventForm.value).subscribe({ + next: () => { + alert('Event created successfully!'); + this.router.navigate(['/organizer-dashboard']); + }, + error: (err) => { + this.errorMessage = 'Failed to create event. Please try again.'; + this.submitting = false; + console.error(err); + } + }); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/events/event-details/event-details.html b/frontend/src/app/events/event-details/event-details.html index cf5551b..ab1cbf8 100644 --- a/frontend/src/app/events/event-details/event-details.html +++ b/frontend/src/app/events/event-details/event-details.html @@ -1,10 +1,13 @@
- +
- Upcoming Event -

{{ event.title }}

+ + {{ event.is_cancelled ? 'Cancelled' : 'Upcoming Event' }} + +

{{ event.title }}

+
📍 {{ event.location }} 📅 {{ event.event_date | date: 'fullDate' }} @@ -15,36 +18,69 @@

{{ event.title }}

About this event

-

{{ event.description }}

+
+

{{ event.description }}

+ +
+

📁 Resources

+ +
+
+ +
+ + + + + + + + + + + +
-
- -
Loading event details...
+
+

Loading event details...

+
-
Event not found. It may have been removed.
-
+
+

Event not found. It may have been removed.

+ +
+ \ No newline at end of file diff --git a/frontend/src/app/events/event-details/event-details.scss b/frontend/src/app/events/event-details/event-details.scss index 7ad74cb..7d15e21 100644 --- a/frontend/src/app/events/event-details/event-details.scss +++ b/frontend/src/app/events/event-details/event-details.scss @@ -1,6 +1,7 @@ $primary: #6366f1; $primary-dark: #4f46e5; $success: #10b981; +$danger: #ef4444; // Added for Cancel $text-main: #1e293b; $text-muted: #64748b; $bg-light: #f8fafc; @@ -46,6 +47,11 @@ $shadow: font-size: 0.85rem; font-weight: 700; text-transform: uppercase; + + &.cancelled { + background: #fee2e2; + color: $danger; + } } h1 { @@ -55,6 +61,19 @@ $shadow: line-height: 1.2; } + .edit-title-input { + width: 100%; + font-size: 2.5rem; + font-weight: 800; + color: $text-main; + border: 2px dashed $primary; + border-radius: 12px; + padding: 0.5rem 1rem; + margin: 1rem 0; + outline: none; + background: $bg-light; + } + .meta-strip { display: flex; flex-wrap: wrap; @@ -96,13 +115,51 @@ $shadow: .description { line-height: 1.8; color: #475569; - white-space: pre-wrap; // Respects line breaks from backend + white-space: pre-wrap; font-size: 1.1rem; } + + /* --- Edit Form Styles --- */ + .edit-fields { + display: flex; + flex-direction: column; + gap: 1.5rem; + + .field-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + + label { + font-weight: 600; + color: $text-main; + font-size: 0.9rem; + } + + .form-control { + padding: 0.8rem 1rem; + border: 1.5px solid #e2e8f0; + border-radius: 10px; + font-size: 1rem; + transition: all 0.2s; + + &:focus { + outline: none; + border-color: $primary; + box-shadow: 0 0 0 3px rgba($primary, 0.1); + } + } + + textarea.form-control { + min-height: 150px; + resize: vertical; + } + } + } } .registration-card { - background: $text-main; // Dark blue/black look + background: $text-main; color: $white; padding: 2rem; border-radius: 20px; @@ -127,6 +184,12 @@ $shadow: } } + .action-buttons { + display: flex; + flex-direction: column; + gap: 1rem; + } + button { width: 100%; padding: 1rem; @@ -137,7 +200,7 @@ $shadow: cursor: pointer; transition: all 0.2s; - &.btn-register { + &.btn-register, &.btn-edit, &.btn-save { background: $primary; color: white; @@ -145,12 +208,19 @@ $shadow: background: $primary-dark; transform: translateY(-2px); } + } - &:disabled { - background: #4b5563; - cursor: not-allowed; - opacity: 0.7; - } + &.btn-save { + background: $success; + &:hover { background: darken($success, 10%); } + } + + &.btn-cancel { + background: rgba($white, 0.1); + color: $white; + border: 1px solid rgba($white, 0.2); + + &:hover { background: rgba($white, 0.2); } } &.btn-registered { @@ -159,6 +229,11 @@ $shadow: border: 1px solid $success; cursor: default; } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } } } } @@ -169,4 +244,4 @@ $shadow: font-size: 1.2rem; color: $text-muted; } -} +} \ No newline at end of file diff --git a/frontend/src/app/events/event-details/event-details.ts b/frontend/src/app/events/event-details/event-details.ts index 045da33..dec9133 100644 --- a/frontend/src/app/events/event-details/event-details.ts +++ b/frontend/src/app/events/event-details/event-details.ts @@ -1,14 +1,15 @@ -// event-details.component.ts import { Component, OnInit, ChangeDetectorRef } from '@angular/core'; -import { ActivatedRoute, RouterModule } from '@angular/router'; +import { ActivatedRoute, RouterModule, Router } from '@angular/router'; import { EventService } from '../../events/event.service'; import { AuthService } from '../../auth/auth.service'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; // Added @Component({ selector: 'app-event-details', standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, FormsModule], templateUrl: './event-details.html', styleUrls: ['./event-details.scss'], }) @@ -17,55 +18,98 @@ export class EventDetailsComponent implements OnInit { myEvents: any[] = []; loading = true; isProcessing = false; + userRole: string | null = null; + selectedFile: File | null = null; // Added + + isEditing = false; + editForm: any = {}; constructor( private route: ActivatedRoute, + private router: Router, private eventService: EventService, private auth: AuthService, private cdr: ChangeDetectorRef, + private http: HttpClient // Injected ) {} ngOnInit() { + this.userRole = this.auth.getRole(); const id = this.route.snapshot.paramMap.get('id'); - if (id) { - this.loadData(id); - } + if (id) this.loadData(id); } loadData(eventId: string) { - // We use "forkJoin" or simply subscribe to both this.eventService.getEventById(eventId).subscribe((data) => { this.event = data; this.loading = false; this.cdr.detectChanges(); }); + if (this.userRole === 'USER') { + this.auth.getUserEvents().subscribe((events: any) => { + this.myEvents = events; + this.cdr.detectChanges(); + }); + } + } - // Fetch user's events to check registration status - this.auth.getUserEvents().subscribe((events: any) => { - this.myEvents = events; - this.cdr.detectChanges(); + onFileSelected(event: any) { + this.selectedFile = event.target.files[0]; + } + + startEdit() { + this.isEditing = true; + this.editForm = { ...this.event }; + if (this.editForm.event_date) { + this.editForm.event_date = new Date(this.editForm.event_date).toISOString().split('T')[0]; + } + } + + cancelEdit() { + this.isEditing = false; + this.selectedFile = null; + } + + saveEdit() { + this.isProcessing = true; + this.eventService.updateEvent(this.event.event_id, this.editForm).subscribe({ + next: (updated) => { + if (this.selectedFile) { + const formData = new FormData(); + formData.append('file', this.selectedFile); + this.http.post(`http://localhost:3000/api/events/${this.event.event_id}/upload`, formData) + .subscribe({ + next: () => this.finishSave(), + error: () => { alert('Details saved, but file upload failed.'); this.finishSave(); } + }); + } else { + this.finishSave(); + } + }, + error: () => { alert('Update failed'); this.isProcessing = false; } }); } - // Check if the current event ID is in the user's list + finishSave() { + this.isProcessing = false; + this.isEditing = false; + this.router.navigate(['/organizer-dashboard']); + } + isAlreadyRegistered(): boolean { - if (!this.event || !this.myEvents) return false; - return this.myEvents.some((e) => e.event_id === this.event.event_id); + return this.myEvents.some((e) => e.event_id === this.event?.event_id); } register() { this.isProcessing = true; - this.eventService.registerForEvent(this.event.event_id).subscribe({ - next: () => { - alert('Registration Successful!'); - this.loadData(this.event.event_id.toString()); // Refresh status - this.isProcessing = false; - this.cdr.detectChanges(); - }, - error: () => { - this.isProcessing = false; - this.cdr.detectChanges(); - }, + this.eventService.registerForEvent(this.event.event_id).subscribe(() => { + alert('Registered!'); + this.loadData(this.event.event_id.toString()); + this.isProcessing = false; }); } -} + + goBack() { + this.router.navigate([this.userRole === 'ORG' ? '/organizer-dashboard' : '/user-dashboard']); + } +} \ No newline at end of file diff --git a/frontend/src/app/events/event.service.ts b/frontend/src/app/events/event.service.ts index 0d887bf..037fa96 100644 --- a/frontend/src/app/events/event.service.ts +++ b/frontend/src/app/events/event.service.ts @@ -1,17 +1,15 @@ -// event.service.ts import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class EventService { - private apiUrl = 'http://localhost:3000/api/events'; // Match your NestJS route + private apiUrl = 'http://localhost:3000/api/events'; constructor(private http: HttpClient) {} getPublishedEvents(filters: any): Observable { let params = new HttpParams(); - if (filters.search) params = params.append('search', filters.search); if (filters.location) params = params.append('location', filters.location); if (filters.dateFrom) params = params.append('date_from', filters.dateFrom); @@ -20,6 +18,23 @@ export class EventService { return this.http.get(this.apiUrl, { params }); } + getOrgEvents(): Observable { + return this.http.get(`${this.apiUrl}/my/events`); + } + + createEvent(eventData: any): Observable { + return this.http.post(this.apiUrl, eventData); + } + + //Method to update existing events + updateEvent(eventId: number, eventData: any): Observable { + return this.http.patch(`${this.apiUrl}/${eventId}`, eventData); + } + + deleteEvent(eventId: number): Observable { + return this.http.delete(`${this.apiUrl}/${eventId}`); + } + getEventById(id: string): Observable { return this.http.get(`${this.apiUrl}/${id}`); } @@ -27,4 +42,8 @@ export class EventService { registerForEvent(eventId: number): Observable { return this.http.post(`${this.apiUrl}/${eventId}/register`, { event_id: eventId }); } -} + + getParticipants(eventId: number) { + return this.http.get(`http://localhost:3000/api/events/${eventId}/participants`); + } +} \ No newline at end of file diff --git a/frontend/src/app/events/events.html b/frontend/src/app/events/events.html index 243f7dd..5ed21c5 100644 --- a/frontend/src/app/events/events.html +++ b/frontend/src/app/events/events.html @@ -1,33 +1,14 @@
-
- - - - - -
- +

{{ event.title }}

📍 {{ event.location }}

📅 {{ event.event_date | date: 'fullDate' }}

- -
@@ -36,4 +17,4 @@

{{ event.title }}

No published events found.

-
+
\ No newline at end of file diff --git a/frontend/src/app/events/events.ts b/frontend/src/app/events/events.ts index 8c3fe67..4ad23f7 100644 --- a/frontend/src/app/events/events.ts +++ b/frontend/src/app/events/events.ts @@ -1,8 +1,10 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { EventService } from './event.service'; -import { RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; +import { AuthService } from '../auth/auth.service'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; + @Component({ selector: 'app-browse-events', standalone: true, @@ -12,16 +14,12 @@ import { FormsModule } from '@angular/forms'; }) export class BrowseEventsComponent implements OnInit { events: any[] = []; - - searchFilters = { - search: '', - location: '', - dateFrom: '', - dateTo: '', - }; + searchFilters = { search: '', location: '', dateFrom: '', dateTo: '' }; constructor( private eventService: EventService, + private auth: AuthService, + private router: Router, private cdr: ChangeDetectorRef, ) {} @@ -29,12 +27,20 @@ export class BrowseEventsComponent implements OnInit { this.fetchEvents(); } + backToDashboard() { + const role = this.auth.getRole(); + if (role === 'ORG') { + this.router.navigate(['/organizer-dashboard']); + } else { + this.router.navigate(['/user-dashboard']); + } + } + fetchEvents() { this.eventService.getPublishedEvents(this.searchFilters).subscribe({ next: (data) => { this.events = data; this.cdr.detectChanges(); - //console.log('Events loaded:', data); }, error: (err) => { console.error('Error fetching events:', err); @@ -43,9 +49,8 @@ export class BrowseEventsComponent implements OnInit { }); } - // Called on every keystroke or when 'Search' button is clicked onFilterChange() { this.fetchEvents(); this.cdr.detectChanges(); } -} +} \ No newline at end of file diff --git a/frontend/src/app/events/participant-list/participant-list.html b/frontend/src/app/events/participant-list/participant-list.html new file mode 100644 index 0000000..22c266d --- /dev/null +++ b/frontend/src/app/events/participant-list/participant-list.html @@ -0,0 +1,31 @@ +
+

Participant List

+ + +
Loading participants...
+ +
+ + + + + + + + + + + + + + + + + +
#UsernameEmailRegistration Date
{{ i + 1 }}{{ reg.user.username }}{{ reg.user.email }}{{ reg.registered_at | date:'short' }}
+
+ +
+ No participants have registered for this event yet. +
+
\ No newline at end of file diff --git a/frontend/src/app/events/participant-list/participant-list.scss b/frontend/src/app/events/participant-list/participant-list.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/events/participant-list/participant-list.spec.ts b/frontend/src/app/events/participant-list/participant-list.spec.ts new file mode 100644 index 0000000..9bdb3fa --- /dev/null +++ b/frontend/src/app/events/participant-list/participant-list.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ParticipantList } from './participant-list'; + +describe('ParticipantList', () => { + let component: ParticipantList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ParticipantList], + }).compileComponents(); + + fixture = TestBed.createComponent(ParticipantList); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/events/participant-list/participant-list.ts b/frontend/src/app/events/participant-list/participant-list.ts new file mode 100644 index 0000000..5b2e5ee --- /dev/null +++ b/frontend/src/app/events/participant-list/participant-list.ts @@ -0,0 +1,43 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { EventService } from '../event.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-participant-list', + standalone: true, + imports: [CommonModule, RouterModule], + templateUrl: './participant-list.html', + styleUrls: ['./participant-list.scss'] +}) +export class ParticipantListComponent implements OnInit { + participants: any[] = []; + eventId: number = 0; + loading = true; + + constructor( + private route: ActivatedRoute, + private eventService: EventService + ) {} + + ngOnInit() { + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.eventId = +id; + this.loadParticipants(); + } + } + + loadParticipants() { + this.eventService.getParticipants(this.eventId).subscribe({ + next: (data) => { + this.participants = data; + this.loading = false; + }, + error: (err) => { + console.error('Failed to load participants', err); + this.loading = false; + } + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/organizer-dashboard/organizer-dashboard.html b/frontend/src/app/organizer-dashboard/organizer-dashboard.html new file mode 100644 index 0000000..5076379 --- /dev/null +++ b/frontend/src/app/organizer-dashboard/organizer-dashboard.html @@ -0,0 +1,72 @@ +
+
+
+

Organization Portal

+

Welcome back, {{ user?.username }}

+
+ 📧 {{ user?.email }} + ORG ACCOUNT +
+
+ +
+ +
+
+

Managed Events

+ +
+ +
+
+ Total Events + {{ myHostedEvents.length }} +
+
+ +
+
+
+ {{ event.title }} + 📍 {{ event.location }} + 📅 {{ event.event_date | date:'mediumDate' }} +
+ +
+
+ Registrations + {{ event._count?.registrations || 0 }} +
+ +
+ + + +
+
+
+
+ + +
+

You haven't created any events yet.

+ +
+
+
+
+ + +
+
+

Initializing Dashboard...

+
+
\ No newline at end of file diff --git a/frontend/src/app/organizer-dashboard/organizer-dashboard.scss b/frontend/src/app/organizer-dashboard/organizer-dashboard.scss new file mode 100644 index 0000000..ee47aa2 --- /dev/null +++ b/frontend/src/app/organizer-dashboard/organizer-dashboard.scss @@ -0,0 +1,110 @@ +@use '../user-dashboard/user-dashboard.scss'; + +.org-theme { + background: linear-gradient(135deg, #1e293b 0%, #334155 100%) !important; +} + +.role-badge { + background: #f59e0b; + color: #fff; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: bold; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.btn-create { + background: #10b981; + color: white; + border: none; + padding: 0.8rem 1.2rem; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: background 0.2s ease; + + &:hover { background: #059669; } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; + + .stat-card { + background: #f1f5f9; + padding: 1.5rem; + border-radius: 12px; + display: flex; + flex-direction: column; + + .label { color: #64748b; font-size: 0.9rem; } + .value { font-size: 1.8rem; font-weight: 800; color: #1e293b; } + } +} + +.event-actions { + display: flex; + align-items: center; + gap: 20px; + + .attendance { + text-align: center; + margin-right: 10px; + strong { display: block; font-size: 1.2rem; color: #6366f1; } + small { color: #64748b; text-transform: uppercase; font-size: 0.7rem; letter-spacing: 0.5px; } + } + + .button-group { + display: flex; + gap: 10px; + } + + button { + padding: 0.6rem 1rem; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s ease; + font-size: 0.85rem; + } + + .btn-edit { + background: #6366f1; + color: white; + &:hover { background: #4f46e5; transform: translateY(-1px); } + } + + .btn-delete { + background: #ef4444; + color: white; + &:hover { background: #dc2626; transform: translateY(-1px); } + } +} +.event-card { + background: white; + border: 1px solid #e2e8f0; + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + transition: box-shadow 0.2s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + } +} \ No newline at end of file diff --git a/frontend/src/app/organizer-dashboard/organizer-dashboard.spec.ts b/frontend/src/app/organizer-dashboard/organizer-dashboard.spec.ts new file mode 100644 index 0000000..764f5bb --- /dev/null +++ b/frontend/src/app/organizer-dashboard/organizer-dashboard.spec.ts @@ -0,0 +1,22 @@ +// import { ComponentFixture, TestBed } from '@angular/core/testing'; + +// import { UserDashboardComponent } from './user-dashboard'; + +// describe('UserDashboard', () => { +// let component: UserDashboardComponent; +// let fixture: ComponentFixture; + +// beforeEach(async () => { +// await TestBed.configureTestingModule({ +// imports: [UserDashboardComponent], +// }).compileComponents(); + +// fixture = TestBed.createComponent(UserDashboardComponent); +// component = fixture.componentInstance; +// await fixture.whenStable(); +// }); + +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/frontend/src/app/organizer-dashboard/organizer-dashboard.ts b/frontend/src/app/organizer-dashboard/organizer-dashboard.ts new file mode 100644 index 0000000..4e6a78e --- /dev/null +++ b/frontend/src/app/organizer-dashboard/organizer-dashboard.ts @@ -0,0 +1,71 @@ +import { Component, OnInit, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, RouterModule } from '@angular/router'; +import { AuthService } from '../auth/auth.service'; +import { EventService } from '../events/event.service'; + +@Component({ + selector: 'app-organizer-dashboard', + standalone: true, + imports: [CommonModule, RouterModule], + templateUrl: './organizer-dashboard.html', + styleUrls: ['./organizer-dashboard.scss'], +}) +export class OrganizerDashboardComponent implements OnInit { + user: any = null; + myHostedEvents: any[] = []; + loading = true; + + constructor( + private auth: AuthService, + private eventService: EventService, + private router: Router, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit() { + this.auth.getProfile().subscribe({ + next: (data: any) => { + this.user = data; + if (this.user.role !== 'ORG') { + this.router.navigate(['/user-dashboard']); + return; + } + this.loadHostedEvents(); + }, + error: () => this.router.navigate(['/login']) + }); + } + + loadHostedEvents() { + this.eventService.getOrgEvents().subscribe({ + next: (data: any[]) => { + this.myHostedEvents = data; + this.loading = false; + this.cdr.detectChanges(); + }, + error: (err: any) => { + console.error('Error loading events:', err); + this.loading = false; + this.cdr.detectChanges(); + } + }); + } + + deleteEvent(eventId: number) { + if (confirm('Are you sure you want to delete this event?')) { + this.eventService.deleteEvent(eventId).subscribe({ + next: () => { + this.myHostedEvents = this.myHostedEvents.filter(e => e.event_id !== eventId); + this.cdr.detectChanges(); + }, + error: (err: any) => console.error('Delete failed:', err) + }); + } + } + + logout() { + this.auth.logout(); + this.router.navigate(['/login']); + } +} \ No newline at end of file