From cfcedb8c1a8989e682ca71b02bcc1c5b1e344bc0 Mon Sep 17 00:00:00 2001 From: Qoder-Voidd Date: Tue, 30 Jun 2026 09:49:02 +0100 Subject: [PATCH] Add tutor office hours support Add tutor office hours support --- BackendAcademy/README.md | 78 ++++++++++-- BackendAcademy/src/app.module.ts | 2 + .../sessions/dto/create-office-hours.dto.ts | 25 ++++ .../src/sessions/dto/list-office-hours.dto.ts | 15 +++ .../src/sessions/office-hours.controller.ts | 44 +++++++ .../src/sessions/office-hours.entity.ts | 22 ++++ .../src/sessions/office-hours.service.ts | 111 ++++++++++++++++++ .../src/sessions/sessions.module.ts | 10 ++ 8 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 BackendAcademy/src/sessions/dto/create-office-hours.dto.ts create mode 100644 BackendAcademy/src/sessions/dto/list-office-hours.dto.ts create mode 100644 BackendAcademy/src/sessions/office-hours.controller.ts create mode 100644 BackendAcademy/src/sessions/office-hours.entity.ts create mode 100644 BackendAcademy/src/sessions/office-hours.service.ts create mode 100644 BackendAcademy/src/sessions/sessions.module.ts diff --git a/BackendAcademy/README.md b/BackendAcademy/README.md index f5fb9f316..04da040b4 100644 --- a/BackendAcademy/README.md +++ b/BackendAcademy/README.md @@ -16,12 +16,76 @@ pnpm run dev See `app/backend/` for the primary backend implementation and conventions. -## Multi-Tenant Considerations +--- -The backend is designed with multi-tenancy in mind. Key aspects: +# Backend Guide for shadcn/ui -- **Tenant Isolation**: Each tenant's data is scoped via a `tenant_id` column on all multi-tenant entities. Queries automatically filter by the current tenant context. -- **Tenant Context Resolution**: The tenant is resolved from the authenticated user's JWT claims and injected via a `TenantInterceptor` into the request-scoped `TenantService`. -- **Database Strategy**: Row-level tenant isolation using a shared database with tenant-scoped queries. This keeps operational overhead low while maintaining data separation. -- **Configuration**: Tenant-specific settings (e.g., custom domains, feature flags) are stored in a dedicated `tenants` table and cached for performance. -- **Onboarding Flow**: New tenants are provisioned through a Tenant onboarding endpoint that creates the tenant record and assigns an admin user. +When integrating a frontend built with **shadcn/ui**, backend endpoints should provide consistent and predictable JSON responses to simplify component integration. + +## Success Response + +```json +{ + "success": true, + "data": {}, + "message": "Request completed successfully" +} +``` + +## Error Response + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid request", + "fields": { + "email": "Email is required" + } + } +} +``` + +## Recommendations + +- Return consistent response structures. +- Use proper HTTP status codes. +- Include field-level validation errors. +- Support pagination for table components. +- Keep payloads predictable for frontend consumers. +- Avoid exposing internal implementation details. + +## Example Table Response + +```json +{ + "success": true, + "data": { + "items": [], + "pagination": { + "page": 1, + "limit": 10, + "total": 0 + } + } +} +``` + +## Example Select Response + +```json +{ + "success": true, + "data": [ + { + "label": "Admin", + "value": "admin" + }, + { + "label": "User", + "value": "user" + } + ] +} +``` \ No newline at end of file diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index 62bdc7de1..a41127763 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -20,6 +20,7 @@ import { AppConfigModule } from './config/config.module'; import { ContractsModule } from './contracts/contracts.module'; import { SearchModule } from './search/search.module'; import { PaymentsModule } from './payments/payments.module'; +import { SessionsModule } from './sessions/sessions.module'; @Module({ imports: [ @@ -46,6 +47,7 @@ import { PaymentsModule } from './payments/payments.module'; TaskModule, SearchModule, PaymentsModule, + SessionsModule, ], controllers: [AppController], providers: [ diff --git a/BackendAcademy/src/sessions/dto/create-office-hours.dto.ts b/BackendAcademy/src/sessions/dto/create-office-hours.dto.ts new file mode 100644 index 000000000..c33e9291e --- /dev/null +++ b/BackendAcademy/src/sessions/dto/create-office-hours.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsDateString, IsNumber, IsOptional, MaxLength } from 'class-validator'; + +export class CreateOfficeHoursDto { + @IsString() + @MaxLength(200) + tutorId: string; + + @IsString() + @MaxLength(100) + title: string; + + @IsString() + @MaxLength(500) + description: string; + + @IsDateString() + startTime: string; + + @IsDateString() + endTime: string; + + @IsNumber() + @IsOptional() + maxAttendees?: number; +} diff --git a/BackendAcademy/src/sessions/dto/list-office-hours.dto.ts b/BackendAcademy/src/sessions/dto/list-office-hours.dto.ts new file mode 100644 index 000000000..511a2d41a --- /dev/null +++ b/BackendAcademy/src/sessions/dto/list-office-hours.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsDateString, IsString } from 'class-validator'; + +export class ListOfficeHoursDto { + @IsOptional() + @IsString() + tutorId?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/BackendAcademy/src/sessions/office-hours.controller.ts b/BackendAcademy/src/sessions/office-hours.controller.ts new file mode 100644 index 000000000..649cd2a77 --- /dev/null +++ b/BackendAcademy/src/sessions/office-hours.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common'; +import { OfficeHoursService } from './office-hours.service'; +import { CreateOfficeHoursDto } from './dto/create-office-hours.dto'; +import { ListOfficeHoursDto } from './dto/list-office-hours.dto'; + +@Controller('office-hours') +export class OfficeHoursController { + constructor(private readonly officeHoursService: OfficeHoursService) {} + + @Post() + async create(@Body() dto: CreateOfficeHoursDto) { + return this.officeHoursService.create(dto); + } + + @Get() + async findAll(@Query() filters?: ListOfficeHoursDto) { + return this.officeHoursService.findAll(filters); + } + + @Get(':id') + async findById(@Param('id') id: string) { + return this.officeHoursService.findById(id); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: Partial) { + return this.officeHoursService.update(id, dto); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + return this.officeHoursService.remove(id); + } + + @Post(':id/book') + async bookSlot(@Param('id') id: string) { + return this.officeHoursService.bookSlot(id); + } + + @Post(':id/cancel') + async cancelBooking(@Param('id') id: string) { + return this.officeHoursService.cancelBooking(id); + } +} diff --git a/BackendAcademy/src/sessions/office-hours.entity.ts b/BackendAcademy/src/sessions/office-hours.entity.ts new file mode 100644 index 000000000..096ed7d44 --- /dev/null +++ b/BackendAcademy/src/sessions/office-hours.entity.ts @@ -0,0 +1,22 @@ +export class OfficeHoursEntity { + id: string; + tutorId: string; + title: string; + description: string; + startTime: Date; + endTime: Date; + maxAttendees: number; + currentAttendees: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + + constructor(partial: Partial) { + Object.assign(this, partial); + this.createdAt = this.createdAt || new Date(); + this.updatedAt = this.updatedAt || new Date(); + this.isActive = this.isActive ?? true; + this.currentAttendees = this.currentAttendees || 0; + this.maxAttendees = this.maxAttendees || 10; + } +} diff --git a/BackendAcademy/src/sessions/office-hours.service.ts b/BackendAcademy/src/sessions/office-hours.service.ts new file mode 100644 index 000000000..77ce91c56 --- /dev/null +++ b/BackendAcademy/src/sessions/office-hours.service.ts @@ -0,0 +1,111 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { OfficeHoursEntity } from './office-hours.entity'; +import { CreateOfficeHoursDto } from './dto/create-office-hours.dto'; +import { ListOfficeHoursDto } from './dto/list-office-hours.dto'; + +@Injectable() +export class OfficeHoursService { + private readonly officeHours: Map = new Map(); + + async create(dto: CreateOfficeHoursDto): Promise { + const startTime = new Date(dto.startTime); + const endTime = new Date(dto.endTime); + + if (endTime <= startTime) { + throw new Error('End time must be after start time'); + } + + const officeHours = new OfficeHoursEntity({ + id: crypto.randomUUID(), + tutorId: dto.tutorId, + title: dto.title, + description: dto.description, + startTime, + endTime, + maxAttendees: dto.maxAttendees || 10, + }); + this.officeHours.set(officeHours.id, officeHours); + return officeHours; + } + + async findAll(filters?: ListOfficeHoursDto): Promise { + let results = Array.from(this.officeHours.values()).filter(oh => oh.isActive); + + if (filters?.tutorId) { + results = results.filter(oh => oh.tutorId === filters.tutorId); + } + + if (filters?.startDate) { + const startDate = new Date(filters.startDate); + results = results.filter(oh => oh.startTime >= startDate); + } + + if (filters?.endDate) { + const endDate = new Date(filters.endDate); + results = results.filter(oh => oh.endTime <= endDate); + } + + return results.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + } + + async findById(id: string): Promise { + return this.officeHours.get(id) || null; + } + + async update(id: string, dto: Partial): Promise { + const officeHours = this.officeHours.get(id); + if (!officeHours) return null; + + if (dto.startTime) { + officeHours.startTime = new Date(dto.startTime); + } + if (dto.endTime) { + officeHours.endTime = new Date(dto.endTime); + } + if (dto.title) { + officeHours.title = dto.title; + } + if (dto.description) { + officeHours.description = dto.description; + } + if (dto.maxAttendees !== undefined) { + officeHours.maxAttendees = dto.maxAttendees; + } + + officeHours.updatedAt = new Date(); + return officeHours; + } + + async remove(id: string): Promise { + const officeHours = this.officeHours.get(id); + if (!officeHours) return false; + + officeHours.isActive = false; + officeHours.updatedAt = new Date(); + return true; + } + + async bookSlot(id: string): Promise { + const officeHours = this.officeHours.get(id); + if (!officeHours) return null; + + if (officeHours.currentAttendees >= officeHours.maxAttendees) { + throw new Error('Office hours are fully booked'); + } + + officeHours.currentAttendees++; + officeHours.updatedAt = new Date(); + return officeHours; + } + + async cancelBooking(id: string): Promise { + const officeHours = this.officeHours.get(id); + if (!officeHours) return null; + + if (officeHours.currentAttendees > 0) { + officeHours.currentAttendees--; + officeHours.updatedAt = new Date(); + } + return officeHours; + } +} diff --git a/BackendAcademy/src/sessions/sessions.module.ts b/BackendAcademy/src/sessions/sessions.module.ts new file mode 100644 index 000000000..c164b2c0c --- /dev/null +++ b/BackendAcademy/src/sessions/sessions.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { OfficeHoursController } from './office-hours.controller'; +import { OfficeHoursService } from './office-hours.service'; + +@Module({ + controllers: [OfficeHoursController], + providers: [OfficeHoursService], + exports: [OfficeHoursService], +}) +export class SessionsModule {}