diff --git a/backend/.env.example b/backend/.env.example index fcf08da..d7dc850 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -60,8 +60,23 @@ REDIS_PORT=6379 # SENDGRID_API_KEY=your_sendgrid_api_key +# Email Provider Webhook Secrets (Optional) +SENDGRID_WEBHOOK_SECRET=your_sendgrid_webhook_secret +MAILGUN_WEBHOOK_SECRET=your_mailgun_webhook_secret +SES_SNS_TOPIC_ARN=your_ses_sns_topic_arn + +# Access Control +DEVICE_WEBHOOK_API_KEY=your_device_webhook_api_key_here + +# ─── Database Migrations ──────────────────────────────────────────────────── +# synchronize is set to FALSE in app.module.ts — schema changes are managed +# via TypeORM migrations only. After every deploy, run: +# +# npm run migration:run # -# ==> OTHER +# To generate a migration after changing entities: +# npm run migration:generate -- src/migrations/DescriptiveMigrationName # -COMPANY_NAME=ManageHub -SUPPORT_EMAIL=support@yourdomain.com \ No newline at end of file +# To revert the most recent migration: +# npm run migration:revert +DATABASE_SSL=false diff --git a/backend/package.json b/backend/package.json index 873a4b9..79a508a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,10 +19,11 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "typeorm": "typeorm-ts-node-commonjs", - "typeorm:run-migrations": "npm run typeorm migration:run -- -d ./src/config/typeorm.config.ts", - "typeorm:generate-migration": "npm run typeorm -- migration:generate ./src/migrations/$npm_config_name -d ./src/config/typeorm.config.ts", - "typeorm:create-migration": "npm run typeorm -- migration:create ./src/migrations/$npm_config_name", - "typeorm:revert-migration": "npm run typeorm -- migration:revert -d ./src/config/typeorm.config.ts", + "migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts", + "migration:run": "npm run typeorm -- migration:run -d src/data-source.ts", + "migration:revert": "npm run typeorm -- migration:revert -d src/data-source.ts", + "migration:create": "npm run typeorm -- migration:create", + "migration:show": "npm run typeorm -- migration:show -d src/data-source.ts", "demo:seed": "node scripts/demo-data.js seed", "demo:clear": "node scripts/demo-data.js clear", "demo:info": "node scripts/demo-data.js info" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 00daa1a..e9b435a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -51,6 +51,7 @@ import { ResourcesModule } from './resources/resources.module'; import { NpsModule } from './nps/nps.module'; import { SearchModule } from './search/search.module'; import { DoorAccessModule } from './integrations/access-control/door-access.module'; +import { CommunityModule } from './community/community.module'; import { validationSchema } from './config/env.validation'; @@ -94,7 +95,8 @@ import { LoggerModule } from 'nestjs-pino'; }, { name: 'newsletter', ttl: 60_000, limit: 10 }, { name: 'contact', ttl: 60_000, limit: 5 }, - { name: 'feedback', ttl: 60_000, limit: 10 }, + // otp: strict limit for OTP/2FA verification endpoints (5 per 10 minutes) + { name: 'otp', ttl: 600_000, limit: 5 }, ]), BullModule.forRootAsync({ imports: [ConfigModule], @@ -130,7 +132,7 @@ import { LoggerModule } from 'nestjs-pino'; port: +configService.get('DATABASE_PORT'), host, autoLoadEntities: true, - synchronize: true, + synchronize: false, // Never use synchronize in production — run migrations instead ssl: sslRequired ? { rejectUnauthorized: false } : false, }; }, @@ -181,6 +183,7 @@ import { LoggerModule } from 'nestjs-pino'; ResourcesModule, NpsModule, DoorAccessModule, + CommunityModule, SearchModule, ], controllers: [AppController], diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index e82c8a3..fa0b179 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; +import { Throttle, seconds } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { CreateUserDto } from './dto/create-user.dto'; import { LoginUserDto } from './dto/login-user.dto'; @@ -34,6 +35,7 @@ export class AuthController { @Public() @Post('register') @HttpCode(HttpStatus.CREATED) + @Throttle({ short: { ttl: seconds(1), limit: 3 } }) create(@Body() createUserDto: CreateUserDto) { return this.authService.createUser(createUserDto); } @@ -41,12 +43,15 @@ export class AuthController { @Public() @Post('verify-otp') @HttpCode(HttpStatus.OK) + @Throttle({ otp: { ttl: seconds(600), limit: 5 } }) verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) { return this.authService.verifyOtp(verifyOtpDto); } + @Public() @Post('resend-verification-otp') @HttpCode(HttpStatus.OK) + @Throttle({ otp: { ttl: seconds(600), limit: 5 } }) resendVerificationOtp(@Body() resendOtpDto: ResendOtpDto) { return this.authService.resendVerificationOtp(resendOtpDto.email); } @@ -61,6 +66,7 @@ export class AuthController { @Public() @Post('login') @HttpCode(HttpStatus.OK) + @Throttle({ short: { ttl: seconds(1), limit: 3 } }) login(@Body() loginUserDto: LoginUserDto) { return this.authService.login(loginUserDto); } @@ -81,6 +87,7 @@ export class AuthController { @Public() @Post('forgot-password') @HttpCode(HttpStatus.OK) + @Throttle({ short: { ttl: seconds(1), limit: 3 } }) forgotPassword(@Body() sendPasswordResetOtpDto: SendPasswordResetOtpDto) { return this.authService.requestResetPasswordOtp(sendPasswordResetOtpDto); } @@ -88,14 +95,17 @@ export class AuthController { @Public() @Post('send-reset-password-otp') @HttpCode(HttpStatus.OK) + @Throttle({ short: { ttl: seconds(1), limit: 3 } }) requestResetPasswordOtp( @Body() sendPasswordResetOtpDto: SendPasswordResetOtpDto, ) { return this.authService.requestResetPasswordOtp(sendPasswordResetOtpDto); } + @Public() @Post('resend-reset-password-otp') @HttpCode(HttpStatus.OK) + @Throttle({ otp: { ttl: seconds(600), limit: 5 } }) resendResetPasswordVerificationOtp(@Body() resendOtpDto: ResendOtpDto) { return this.authService.resendResetPasswordVerificationOtp(resendOtpDto); } @@ -103,6 +113,7 @@ export class AuthController { @Public() @Post('verify-reset-password-otp') @HttpCode(HttpStatus.OK) + @Throttle({ otp: { ttl: seconds(600), limit: 5 } }) verifyResetPasswordOtp(@Body() verifyOtpDto: VerifyOtpDto) { return this.authService.verifyResetPasswordOtp(verifyOtpDto); } @@ -131,6 +142,7 @@ export class AuthController { @Public() @Post('2fa/verify') @HttpCode(HttpStatus.OK) + @Throttle({ otp: { ttl: seconds(600), limit: 5 } }) verifyTotpLogin(@Body() dto: VerifyTotpDto) { return this.authService.verifyTotpLogin(dto); } @@ -138,6 +150,7 @@ export class AuthController { @Public() @Post('2fa/backup-code') @HttpCode(HttpStatus.OK) + @Throttle({ otp: { ttl: seconds(600), limit: 5 } }) verifyBackupCode(@Body() dto: UseBackupCodeDto) { return this.authService.verifyBackupCode(dto); } diff --git a/backend/src/bookings/bookings.controller.ts b/backend/src/bookings/bookings.controller.ts index 60083ec..1d6690f 100644 --- a/backend/src/bookings/bookings.controller.ts +++ b/backend/src/bookings/bookings.controller.ts @@ -14,6 +14,7 @@ import { Header, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { Throttle, seconds } from '@nestjs/throttler'; import { BookingsService } from './bookings.service'; import { CreateBookingDto } from './dto/create-booking.dto'; import { CreateRecurringBookingDto } from './dto/create-recurring-booking.dto'; @@ -43,6 +44,7 @@ export class BookingsController { @Post() @ApiOperation({ summary: 'Create a booking' }) + @Throttle({ long: { ttl: seconds(60), limit: 30 } }) async create(@Body() dto: CreateBookingDto, @GetCurrentUser('id') userId: string) { const booking = await this.bookingsService.create(dto, userId); return { message: 'Booking created successfully', data: booking }; diff --git a/backend/src/community/community.controller.ts b/backend/src/community/community.controller.ts new file mode 100644 index 0000000..b8f963f --- /dev/null +++ b/backend/src/community/community.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Get, + Post, + Delete, + Patch, + Body, + Param, + Query, + HttpCode, + HttpStatus, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { CommunityService } from './community.service'; +import { CreatePostDto } from './dto/create-post.dto'; +import { PostQueryDto } from './dto/post-query.dto'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@ApiTags('Community Feed') +@ApiBearerAuth() +@UseGuards(RolesGuard) +@Controller('community/posts') +export class CommunityController { + constructor(private readonly communityService: CommunityService) {} + + // POST /community/posts — any active authenticated member + @Post() + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Create a community post' }) + async createPost( + @Body() dto: CreatePostDto, + @GetCurrentUser('id') userId: string, + ) { + const post = await this.communityService.createPost(dto, userId); + return { message: 'Post created successfully', data: post }; + } + + // GET /community/posts — paginated feed; pinned first, then newest + @Get() + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Get paginated community feed (pinned first, then newest)' }) + async getFeed(@Query() query: PostQueryDto) { + return this.communityService.getFeed(query); + } + + // DELETE /community/posts/:id — soft-delete own post (member) or any post (admin) + @Delete(':id') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Soft-delete a post (own post for members, any post for admins)' }) + async deletePost( + @Param('id', ParseUUIDPipe) id: string, + @GetCurrentUser('id') userId: string, + @GetCurrentUser('role') userRole: UserRole, + ) { + await this.communityService.deletePost(id, userId, userRole); + return { message: 'Post deleted successfully' }; + } + + // POST /community/posts/:id/like — toggle like/unlike + @Post(':id/like') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Toggle like/unlike on a post' }) + async toggleLike( + @Param('id', ParseUUIDPipe) id: string, + @GetCurrentUser('id') userId: string, + ) { + const result = await this.communityService.toggleLike(id, userId); + return { + message: result.liked ? 'Post liked' : 'Post unliked', + data: result, + }; + } + + // PATCH /community/posts/:id/pin — toggle pin/unpin (admin only) + @Patch(':id/pin') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Toggle pin/unpin on a post (admin only)' }) + async togglePin(@Param('id', ParseUUIDPipe) id: string) { + const result = await this.communityService.togglePin(id); + return { + message: result.isPinned ? 'Post pinned' : 'Post unpinned', + data: result, + }; + } +} diff --git a/backend/src/community/community.module.ts b/backend/src/community/community.module.ts new file mode 100644 index 0000000..c76973c --- /dev/null +++ b/backend/src/community/community.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CommunityPost } from './entities/community-post.entity'; +import { CommunityPostLike } from './entities/community-post-like.entity'; +import { CommunityService } from './community.service'; +import { CommunityController } from './community.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([CommunityPost, CommunityPostLike])], + controllers: [CommunityController], + providers: [CommunityService], + exports: [CommunityService], +}) +export class CommunityModule {} diff --git a/backend/src/community/community.service.ts b/backend/src/community/community.service.ts new file mode 100644 index 0000000..a1a4c9c --- /dev/null +++ b/backend/src/community/community.service.ts @@ -0,0 +1,165 @@ +import { + Injectable, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { CommunityPost } from './entities/community-post.entity'; +import { CommunityPostLike } from './entities/community-post-like.entity'; +import { CreatePostDto } from './dto/create-post.dto'; +import { PostQueryDto } from './dto/post-query.dto'; +import { UserRole } from '../users/enums/userRoles.enum'; +import { ErrorCatch } from '../utils/error'; + +@Injectable() +export class CommunityService { + constructor( + @InjectRepository(CommunityPost) + private readonly postRepo: Repository, + + @InjectRepository(CommunityPostLike) + private readonly likeRepo: Repository, + + private readonly dataSource: DataSource, + ) {} + + // ─── Create Post ────────────────────────────────────────────────────────── + + async createPost(dto: CreatePostDto, authorUserId: string): Promise { + try { + const post = this.postRepo.create({ body: dto.body, authorUserId }); + return await this.postRepo.save(post); + } catch (error) { + ErrorCatch(error, 'Error creating community post'); + } + } + + // ─── Paginated Feed ─────────────────────────────────────────────────────── + + async getFeed(query: PostQueryDto) { + try { + const { page = 1, limit = 20 } = query; + + const [items, total] = await this.postRepo + .createQueryBuilder('post') + .leftJoin('post.author', 'author') + .addSelect([ + 'author.id', + 'author.username', + 'author.firstname', + 'author.lastname', + 'author.profilePicture', + ]) + .where('post.isDeleted = :isDeleted', { isDeleted: false }) + // pinned first, then newest + .orderBy('post.isPinned', 'DESC') + .addOrderBy('post.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } catch (error) { + ErrorCatch(error, 'Error fetching community feed'); + } + } + + // ─── Soft-Delete Post ───────────────────────────────────────────────────── + + async deletePost( + postId: string, + userId: string, + userRole: UserRole, + ): Promise { + try { + const post = await this.findPostOrFail(postId); + + const isAdmin = + userRole === UserRole.ADMIN || userRole === UserRole.SUPER_ADMIN; + + if (!isAdmin && post.authorUserId !== userId) { + throw new ForbiddenException('You can only delete your own posts'); + } + + post.isDeleted = true; + await this.postRepo.save(post); + } catch (error) { + ErrorCatch(error, 'Error deleting community post'); + } + } + + // ─── Toggle Like ────────────────────────────────────────────────────────── + + async toggleLike( + postId: string, + userId: string, + ): Promise<{ liked: boolean; likeCount: number }> { + try { + const post = await this.findPostOrFail(postId); + + const existingLike = await this.likeRepo.findOne({ + where: { postId, userId }, + }); + + // Use a transaction to update like row + likeCount atomically + await this.dataSource.transaction(async (manager) => { + if (existingLike) { + await manager.remove(CommunityPostLike, existingLike); + await manager + .createQueryBuilder() + .update(CommunityPost) + .set({ likeCount: () => '"likeCount" - 1' }) + .where('id = :id AND "likeCount" > 0', { id: postId }) + .execute(); + } else { + const like = manager.create(CommunityPostLike, { postId, userId }); + await manager.save(CommunityPostLike, like); + await manager + .createQueryBuilder() + .update(CommunityPost) + .set({ likeCount: () => '"likeCount" + 1' }) + .where('id = :id', { id: postId }) + .execute(); + } + }); + + // Reload updated count + const updated = await this.postRepo.findOne({ where: { id: postId } }); + return { + liked: !existingLike, + likeCount: updated?.likeCount ?? post.likeCount, + }; + } catch (error) { + ErrorCatch(error, 'Error toggling post like'); + } + } + + // ─── Toggle Pin ─────────────────────────────────────────────────────────── + + async togglePin(postId: string): Promise<{ isPinned: boolean }> { + try { + const post = await this.findPostOrFail(postId); + post.isPinned = !post.isPinned; + await this.postRepo.save(post); + return { isPinned: post.isPinned }; + } catch (error) { + ErrorCatch(error, 'Error toggling post pin'); + } + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private async findPostOrFail(postId: string): Promise { + const post = await this.postRepo.findOne({ where: { id: postId } }); + if (!post) throw new NotFoundException(`Post ${postId} not found`); + if (post.isDeleted) throw new NotFoundException(`Post ${postId} not found`); + return post; + } +} diff --git a/backend/src/community/dto/create-post.dto.ts b/backend/src/community/dto/create-post.dto.ts new file mode 100644 index 0000000..87eec3b --- /dev/null +++ b/backend/src/community/dto/create-post.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreatePostDto { + @ApiProperty({ description: 'Post body text', maxLength: 1000, minLength: 1 }) + @IsString() + @MinLength(1) + @MaxLength(1000) + body: string; +} diff --git a/backend/src/community/dto/post-query.dto.ts b/backend/src/community/dto/post-query.dto.ts new file mode 100644 index 0000000..8ac8b5a --- /dev/null +++ b/backend/src/community/dto/post-query.dto.ts @@ -0,0 +1,20 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PostQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/src/community/entities/community-post-like.entity.ts b/backend/src/community/entities/community-post-like.entity.ts new file mode 100644 index 0000000..aa7d5c1 --- /dev/null +++ b/backend/src/community/entities/community-post-like.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + Unique, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { CommunityPost } from './community-post.entity'; + +@Entity('community_post_likes') +@Unique(['postId', 'userId']) +@Index(['postId']) +@Index(['userId']) +export class CommunityPostLike { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + postId: string; + + @ManyToOne(() => CommunityPost, (post) => post.likes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'postId' }) + post: CommunityPost; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/community/entities/community-post.entity.ts b/backend/src/community/entities/community-post.entity.ts new file mode 100644 index 0000000..afa6b7a --- /dev/null +++ b/backend/src/community/entities/community-post.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { CommunityPostLike } from './community-post-like.entity'; + +@Entity('community_posts') +@Index(['isDeleted', 'isPinned', 'createdAt']) +export class CommunityPost { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + authorUserId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'authorUserId' }) + author: User; + + @Column({ type: 'text', length: 1000 }) + body: string; + + @Column({ type: 'boolean', default: false }) + isPinned: boolean; + + @Column({ type: 'boolean', default: false }) + isDeleted: boolean; + + @Column({ type: 'int', default: 0 }) + likeCount: number; + + @OneToMany(() => CommunityPostLike, (like) => like.post) + likes: CommunityPostLike[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/credits/credits.controller.ts b/backend/src/credits/credits.controller.ts index 2d9de75..7727bb3 100644 --- a/backend/src/credits/credits.controller.ts +++ b/backend/src/credits/credits.controller.ts @@ -12,6 +12,7 @@ import { ApiQuery, ApiTags, } from '@nestjs/swagger'; +import { Throttle, seconds } from '@nestjs/throttler'; import { CreditsService } from './credits.service'; import { PurchaseCreditsDto } from './dto/purchase-credits.dto'; import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; @@ -31,6 +32,7 @@ export class CreditsController { @Post('purchase') @ApiOperation({ summary: 'Purchase a credit pack' }) + @Throttle({ medium: { ttl: seconds(60), limit: 10 } }) async purchase( @Body() dto: PurchaseCreditsDto, @GetCurrentUser('id') userId: string, diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts new file mode 100644 index 0000000..e2fcf71 --- /dev/null +++ b/backend/src/data-source.ts @@ -0,0 +1,40 @@ +/** + * TypeORM CLI DataSource + * + * Used exclusively by the TypeORM CLI for migration commands: + * npm run migration:generate -- src/migrations/MigrationName + * npm run migration:run + * npm run migration:revert + * + * This file is NOT imported by the NestJS application — the app uses + * TypeOrmModule.forRootAsync() in app.module.ts with autoLoadEntities. + */ + +import 'dotenv/config'; +import { DataSource } from 'typeorm'; + +const host = process.env.DATABASE_HOST ?? 'localhost'; +const sslRequired = + process.env.NODE_ENV === 'production' || + process.env.PGSSLMODE === 'require' || + process.env.DATABASE_SSL === 'true' || + host.includes('neon.tech'); + +export default new DataSource({ + type: 'postgres', + host, + port: parseInt(process.env.DATABASE_PORT ?? '5432', 10), + username: process.env.DATABASE_USERNAME, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + + // Entity discovery via glob — picks up every *.entity.ts under src/ + entities: ['src/**/*.entity.ts'], + + // Migration files live in src/migrations/ + migrations: ['src/migrations/**/*.ts'], + migrationsTableName: 'typeorm_migrations', + + synchronize: false, + ssl: sslRequired ? { rejectUnauthorized: false } : false, +}); diff --git a/backend/src/migrations/.gitkeep b/backend/src/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/payments/payments.controller.ts b/backend/src/payments/payments.controller.ts index dc6c7c2..c1ea41a 100644 --- a/backend/src/payments/payments.controller.ts +++ b/backend/src/payments/payments.controller.ts @@ -17,6 +17,7 @@ import { ApiQuery, ApiTags, } from '@nestjs/swagger'; +import { Throttle, seconds } from '@nestjs/throttler'; import { RawBodyRequest } from '@nestjs/common'; import { Request } from 'express'; import { PaymentsService } from './payments.service'; @@ -36,6 +37,7 @@ export class PaymentsController { @Post('initialize') @ApiOperation({ summary: 'Initialize a Paystack payment for a booking' }) + @Throttle({ medium: { ttl: seconds(60), limit: 10 } }) async initialize( @Body() dto: InitializePaymentDto, @GetCurrentUser('id') userId: string, diff --git a/frontend/app/community/page.tsx b/frontend/app/community/page.tsx new file mode 100644 index 0000000..58ed3e1 --- /dev/null +++ b/frontend/app/community/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState, useEffect } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import CommunityFeed from "@/components/community/CommunityFeed"; +import { apiClient } from "@/lib/apiClient"; +import { Search, User, X, Linkedin, Twitter, Users, Rss } from "lucide-react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Member = { + id: string; + firstname: string; + lastname: string; + username?: string; + profilePicture?: string; + memberSince?: string; +}; + +type Tab = "feed" | "members"; + +// ─── Members tab (ported from /members with community API) ──────────────────── + +function MembersTab() { + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [selectedMember, setSelectedMember] = useState(null); + + useEffect(() => { + fetchMembers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search]); + + const fetchMembers = async () => { + try { + setLoading(true); + const res = await apiClient.get<{ + data: Member[]; + total: number; + }>(`/community/members?search=${encodeURIComponent(search)}&limit=50`); + setMembers(res.data ?? []); + } catch { + setMembers([]); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 text-sm text-gray-900 dark:text-gray-100" + /> +
+ + {loading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : members.length === 0 ? ( +
+ +

No members found.

+
+ ) : ( +
+ {members.map((member) => ( + + ))} +
+ )} + + {/* Member profile modal */} + {selectedMember && ( +
+
+ +
+ {selectedMember.profilePicture ? ( + {`${selectedMember.firstname} + ) : ( +
+ +
+ )} +

+ {selectedMember.firstname} {selectedMember.lastname} +

+ {selectedMember.username && ( +

+ @{selectedMember.username} +

+ )} + {selectedMember.memberSince && ( +

+ Member since{" "} + {new Date(selectedMember.memberSince).toLocaleDateString(undefined, { + month: "long", + year: "numeric", + })} +

+ )} +
+
+
+ )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CommunityPage() { + const [activeTab, setActiveTab] = useState("feed"); + + const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [ + { id: "feed", label: "Feed", icon: Rss }, + { id: "members", label: "Members", icon: Users }, + ]; + + return ( + + {/* Header */} +
+

Community

+

+ Connect with fellow members, share updates, and celebrate milestones. +

+
+ + {/* Tabs */} +
+ {tabs.map(({ id, label, icon: Icon }) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === "feed" ? : } +
+ ); +} diff --git a/frontend/components/community/CommunityFeed.tsx b/frontend/components/community/CommunityFeed.tsx new file mode 100644 index 0000000..74dc52d --- /dev/null +++ b/frontend/components/community/CommunityFeed.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useState } from "react"; +import { Heart, Pin, Trash2, PenLine, X } from "lucide-react"; +import { useAuthState } from "@/lib/store/authStore"; +import { useGetCommunityFeed } from "@/lib/react-query/hooks/community/useGetCommunityFeed"; +import { useCreateCommunityPost } from "@/lib/react-query/hooks/community/useCreateCommunityPost"; +import { useTogglePostLike } from "@/lib/react-query/hooks/community/useTogglePostLike"; +import { useDeleteCommunityPost } from "@/lib/react-query/hooks/community/useDeleteCommunityPost"; +import { useTogglePostPin } from "@/lib/react-query/hooks/community/useTogglePostPin"; +import { CommunityPost } from "@/lib/types/community"; + +const MAX_BODY = 1000; + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +} + +function Avatar({ author }: { author: CommunityPost["author"] }) { + const initials = `${author.firstname?.[0] ?? ""}${author.lastname?.[0] ?? ""}`.toUpperCase(); + if (author.profilePicture) { + return ( + {`${author.firstname} + ); + } + return ( +
+ + {initials || "?"} + +
+ ); +} + +// ─── Write Post Modal ───────────────────────────────────────────────────────── + +function WritePostModal({ onClose }: { onClose: () => void }) { + const [body, setBody] = useState(""); + const createPost = useCreateCommunityPost(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!body.trim()) return; + await createPost.mutateAsync({ body: body.trim() }); + onClose(); + }; + + return ( +
+
+
+

Share with the community

+ +
+
+
+