Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 71 additions & 7 deletions BackendAcademy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
```
2 changes: 2 additions & 0 deletions BackendAcademy/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -46,6 +47,7 @@ import { PaymentsModule } from './payments/payments.module';
TaskModule,
SearchModule,
PaymentsModule,
SessionsModule,
],
controllers: [AppController],
providers: [
Expand Down
25 changes: 25 additions & 0 deletions BackendAcademy/src/sessions/dto/create-office-hours.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions BackendAcademy/src/sessions/dto/list-office-hours.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions BackendAcademy/src/sessions/office-hours.controller.ts
Original file line number Diff line number Diff line change
@@ -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<CreateOfficeHoursDto>) {
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);
}
}
22 changes: 22 additions & 0 deletions BackendAcademy/src/sessions/office-hours.entity.ts
Original file line number Diff line number Diff line change
@@ -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<OfficeHoursEntity>) {
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;
}
}
111 changes: 111 additions & 0 deletions BackendAcademy/src/sessions/office-hours.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, OfficeHoursEntity> = new Map();

async create(dto: CreateOfficeHoursDto): Promise<OfficeHoursEntity> {
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<OfficeHoursEntity[]> {
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<OfficeHoursEntity | null> {
return this.officeHours.get(id) || null;
}

async update(id: string, dto: Partial<CreateOfficeHoursDto>): Promise<OfficeHoursEntity | null> {
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<boolean> {
const officeHours = this.officeHours.get(id);
if (!officeHours) return false;

officeHours.isActive = false;
officeHours.updatedAt = new Date();
return true;
}

async bookSlot(id: string): Promise<OfficeHoursEntity | null> {
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<OfficeHoursEntity | null> {
const officeHours = this.officeHours.get(id);
if (!officeHours) return null;

if (officeHours.currentAttendees > 0) {
officeHours.currentAttendees--;
officeHours.updatedAt = new Date();
}
return officeHours;
}
}
10 changes: 10 additions & 0 deletions BackendAcademy/src/sessions/sessions.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading