From c2bc95ca72961ac60a8a4106ce22077f61668363 Mon Sep 17 00:00:00 2001 From: kiet08hogit Date: Wed, 24 Jun 2026 23:13:36 -0500 Subject: [PATCH 1/3] noti feature, mini chat --- backend/prisma/schema.prisma | 53 +- backend/src/app.module.ts | 5 +- .../modules/listings/listings.controller.ts | 3 - .../notifications/notifications.controller.ts | 33 + .../notifications/notifications.module.ts | 13 + .../notifications/notifications.service.ts | 101 + .../src/modules/payments/payments.module.ts | 12 +- .../src/modules/payments/payments.service.ts | 27 +- .../src/modules/posts/dto/create-post.dto.ts | 5 + backend/src/modules/posts/posts.controller.ts | 14 +- backend/src/modules/posts/posts.module.ts | 3 +- backend/src/modules/posts/posts.service.ts | 79 +- .../transactions/transactions.module.ts | 3 +- .../transactions/transactions.service.ts | 12 + backend/src/modules/users/users.controller.ts | 23 +- backend/src/modules/users/users.module.ts | 2 + backend/src/modules/users/users.service.ts | 79 +- frontend/app/ClientNav.tsx | 64 +- frontend/app/about/page.tsx | 53 +- frontend/app/add-product/page.tsx | 1022 ++++--- frontend/app/chat/page.tsx | 2623 ++++++++++------- frontend/app/checkout/[id]/page.tsx | 333 ++- frontend/app/community/CommunityClient.tsx | 1413 +++++---- frontend/app/community/page.tsx | 34 +- frontend/app/faqs/page.tsx | 59 +- frontend/app/home/page.tsx | 296 +- frontend/app/layout.tsx | 86 +- frontend/app/listings/[id]/page.tsx | 77 +- frontend/app/listings/page.tsx | 231 +- frontend/app/offers/page.tsx | 120 +- frontend/app/onboarding/page.tsx | 141 +- frontend/app/page.tsx | 111 +- frontend/app/profile/[id]/page.tsx | 1216 +++++--- frontend/app/purchase-history/page.tsx | 90 +- frontend/app/settings/page.tsx | 269 +- frontend/app/swipe/page.tsx | 715 +++-- frontend/app/wishlist/page.tsx | 250 +- frontend/components/GlobalNav.tsx | 26 +- frontend/components/MiniChatWidget.tsx | 284 ++ frontend/components/NotificationsMenu.tsx | 266 ++ 40 files changed, 6536 insertions(+), 3710 deletions(-) create mode 100644 backend/src/modules/notifications/notifications.controller.ts create mode 100644 backend/src/modules/notifications/notifications.module.ts create mode 100644 backend/src/modules/notifications/notifications.service.ts create mode 100644 frontend/components/MiniChatWidget.tsx create mode 100644 frontend/components/NotificationsMenu.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f693bd9..8add912 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -37,6 +37,12 @@ model User { buyerTransactions Transaction[] @relation("BuyerTransactions") sellerTransactions Transaction[] @relation("SellerTransactions") + followers User[] @relation("UserFollows") + following User[] @relation("UserFollows") + + notificationsReceived Notification[] @relation("NotificationsReceived") + notificationsSent Notification[] @relation("NotificationsSent") + // Notification Preferences emailNotifications Boolean @default(true) emailFrequency String @default("daily") @@ -87,16 +93,17 @@ model ListingImage { } model Post { - id String @id @default(uuid()) - content String - postType PostType - imageUrls String[] - authorId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - author User @relation(fields: [authorId], references: [id]) - comments PostComment[] - likes PostLike[] + id String @id @default(uuid()) + content String + postType PostType + imageUrls String[] + authorId String + isAnonymous Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + author User @relation(fields: [authorId], references: [id]) + comments PostComment[] + likes PostLike[] } model PostLike { @@ -277,3 +284,29 @@ enum MeetupStatus { ACCEPTED CANCELLED } + +enum NotificationType { + FOLLOW + LIKE + COMMENT + PURCHASE + OFFER +} + +model Notification { + id String @id @default(uuid()) + userId String + type NotificationType + title String + content String? + isRead Boolean @default(false) + linkUrl String? + createdAt DateTime @default(now()) + + actorId String? + postId String? + listingId String? + + user User @relation("NotificationsReceived", fields: [userId], references: [id]) + actor User? @relation("NotificationsSent", fields: [actorId], references: [id]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e449d54..7f6c93b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -20,7 +20,7 @@ import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; import { APP_GUARD } from '@nestjs/core'; import { ReportsModule } from './modules/reports/reports.module'; import { PaymentsModule } from './modules/payments/payments.module'; - +import { NotificationsModule } from './modules/notifications/notifications.module'; @Module({ imports: [ ServeStaticModule.forRoot({ @@ -40,6 +40,9 @@ import { PaymentsModule } from './modules/payments/payments.module'; PostsModule, TransactionsModule, ReportsModule, + JobsModule, + PaymentsModule, + NotificationsModule, CacheModule.registerAsync({ isGlobal: true, imports: [ConfigModule], diff --git a/backend/src/modules/listings/listings.controller.ts b/backend/src/modules/listings/listings.controller.ts index d8d9219..722be89 100644 --- a/backend/src/modules/listings/listings.controller.ts +++ b/backend/src/modules/listings/listings.controller.ts @@ -29,7 +29,6 @@ export class ListingsController { } @Get('all') - @UseGuards(ClerkAuthGuard) @UseInterceptors(CacheInterceptor) async getAllListings( @Query('category') category?: ListingCategory, @@ -39,7 +38,6 @@ export class ListingsController { } @Get('recommendations') - @UseGuards(ClerkAuthGuard) @UseInterceptors(CacheInterceptor) async getRecommendations( @Query('q') q: string, @@ -104,7 +102,6 @@ export class ListingsController { } @Get(':id') - @UseGuards(ClerkAuthGuard) @UseInterceptors(CacheInterceptor) async getListing(@Param('id') id: string) { const listing = await this.listingsService.findById(id); diff --git a/backend/src/modules/notifications/notifications.controller.ts b/backend/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..1677961 --- /dev/null +++ b/backend/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Patch, Param, Query, UseGuards, Req } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard'; + +@Controller('notifications') +@UseGuards(ClerkAuthGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + async getNotifications(@Req() req, @Query('filter') filter?: string) { + const clerkUserId = req.user.clerkUserId; + return this.notificationsService.getUserNotifications(clerkUserId, filter); + } + + @Get('unread-count') + async getUnreadCount(@Req() req) { + const clerkUserId = req.user.clerkUserId; + return this.notificationsService.getUnreadCount(clerkUserId); + } + + @Patch('read-all') + async markAllAsRead(@Req() req) { + const clerkUserId = req.user.clerkUserId; + return this.notificationsService.markAllAsRead(clerkUserId); + } + + @Patch(':id/read') + async markAsRead(@Req() req, @Param('id') id: string) { + const clerkUserId = req.user.clerkUserId; + return this.notificationsService.markAsRead(clerkUserId, id); + } +} diff --git a/backend/src/modules/notifications/notifications.module.ts b/backend/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..365c863 --- /dev/null +++ b/backend/src/modules/notifications/notifications.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; +import { DatabaseModule } from '../../database/database.module'; +import { ChatModule } from '../chat/chat.module'; + +@Module({ + imports: [DatabaseModule, ChatModule], + providers: [NotificationsService], + controllers: [NotificationsController], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..9f5d5fc --- /dev/null +++ b/backend/src/modules/notifications/notifications.service.ts @@ -0,0 +1,101 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../database/prisma.service'; +import { ChatGateway } from '../chat/chat.gateway'; +import { NotificationType } from '@prisma/client'; + +@Injectable() +export class NotificationsService { + constructor( + private prisma: PrismaService, + private chatGateway: ChatGateway + ) {} + + async createNotification(data: { + userId: string; + type: NotificationType; + title: string; + content?: string; + linkUrl?: string; + actorId?: string; + postId?: string; + listingId?: string; + }) { + // Don't notify yourself + if (data.userId === data.actorId) return null; + + const notification = await this.prisma.notification.create({ + data, + include: { + actor: { + select: { name: true, username: true, avatarUrl: true } + } + } + }); + + // We can emit this notification directly through the chat gateway + // Assuming user joined a room with their userId + const user = await this.prisma.user.findUnique({ where: { id: data.userId } }); + if (user) { + this.chatGateway.server.to(`user_${user.clerkUserId}`).emit('new_notification', notification); + } + + return notification; + } + + async getUserNotifications(clerkUserId: string, filter?: string) { + const user = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) throw new NotFoundException('User not found'); + + const whereClause: any = { userId: user.id }; + + if (filter && filter !== 'ALL') { + whereClause.type = filter as NotificationType; + } + + return this.prisma.notification.findMany({ + where: whereClause, + orderBy: { createdAt: 'desc' }, + take: 50, + include: { + actor: { select: { name: true, username: true, avatarUrl: true } } + } + }); + } + + async getUnreadCount(clerkUserId: string) { + const user = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) return { unreadCount: 0 }; + + const count = await this.prisma.notification.count({ + where: { userId: user.id, isRead: false } + }); + return { unreadCount: count }; + } + + async markAsRead(clerkUserId: string, notificationId: string) { + const user = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) throw new NotFoundException('User not found'); + + const notification = await this.prisma.notification.findUnique({ where: { id: notificationId } }); + if (!notification || notification.userId !== user.id) { + throw new NotFoundException('Notification not found'); + } + + return this.prisma.notification.update({ + where: { id: notificationId }, + data: { isRead: true } + }); + } + + async markAllAsRead(clerkUserId: string) { + const user = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) throw new NotFoundException('User not found'); + + await this.prisma.notification.updateMany({ + where: { userId: user.id, isRead: false }, + data: { isRead: true } + }); + + return { success: true }; + } +} diff --git a/backend/src/modules/payments/payments.module.ts b/backend/src/modules/payments/payments.module.ts index a8e7dad..2a0d0e7 100644 --- a/backend/src/modules/payments/payments.module.ts +++ b/backend/src/modules/payments/payments.module.ts @@ -1,12 +1,14 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PaymentsService } from './payments.service'; import { PaymentsController } from './payments.controller'; import { PrismaModule } from '../../database/prisma.module'; +import { TransactionsModule } from '../transactions/transactions.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [PrismaModule], - providers: [PaymentsService], - controllers: [PaymentsController], - exports: [PaymentsService] + imports: [PrismaModule, forwardRef(() => TransactionsModule), NotificationsModule], + providers: [PaymentsService], + controllers: [PaymentsController], + exports: [PaymentsService] }) export class PaymentsModule {} diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index d827469..c9295c2 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -1,5 +1,7 @@ -import { Injectable, NotFoundException, BadRequestException, InternalServerErrorException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Inject, forwardRef } from '@nestjs/common'; import { PrismaService } from '../../database/prisma.service'; +import { TransactionsService } from '../transactions/transactions.service'; +import { NotificationsService } from '../notifications/notifications.service'; // Stripe v22 CJS types aren't easily importable — use require + any for the instance // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -9,7 +11,12 @@ const Stripe = require('stripe'); export class PaymentsService { private stripe: any; - constructor(private prisma: PrismaService) { + constructor( + private prisma: PrismaService, + @Inject(forwardRef(() => TransactionsService)) + private transactionsService: TransactionsService, + private notificationsService: NotificationsService + ) { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { apiVersion: '2026-05-27.dahlia', }); @@ -225,7 +232,6 @@ export class PaymentsService { }) ]); - // Also ensure a chat message exists so the Chat UI can attach the listing context let conversation = await this.prisma.conversation.findFirst({ where: { AND: [ @@ -235,6 +241,21 @@ export class PaymentsService { } }); + const buyer = await this.prisma.user.findUnique({ where: { id: transaction.buyerId } }); + const listing = await this.prisma.listing.findUnique({ where: { id: transaction.listingId } }); + + if (buyer && listing) { + await this.notificationsService.createNotification({ + userId: transaction.sellerId, + type: 'PURCHASE', + title: 'New Reservation', + content: `${buyer.name || buyer.username || 'A student'} paid and reserved your item: ${listing.title}`, + actorId: buyer.id, + listingId: listing.id, + linkUrl: `/purchase-history` + }); + } + if (!conversation) { conversation = await this.prisma.conversation.create({ data: { diff --git a/backend/src/modules/posts/dto/create-post.dto.ts b/backend/src/modules/posts/dto/create-post.dto.ts index 1bc4744..72ec231 100644 --- a/backend/src/modules/posts/dto/create-post.dto.ts +++ b/backend/src/modules/posts/dto/create-post.dto.ts @@ -1,4 +1,5 @@ import { IsString, IsNotEmpty, IsEnum, IsOptional, MaxLength } from 'class-validator'; +import { Transform } from 'class-transformer'; import { PostType } from '@prisma/client'; export class CreatePostDto { @@ -11,4 +12,8 @@ export class CreatePostDto { @IsEnum(PostType) @IsNotEmpty() postType: PostType; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + isAnonymous?: boolean; } diff --git a/backend/src/modules/posts/posts.controller.ts b/backend/src/modules/posts/posts.controller.ts index 33b2130..4f53757 100644 --- a/backend/src/modules/posts/posts.controller.ts +++ b/backend/src/modules/posts/posts.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, Post, Body, UseGuards, Req, UseInterceptors, UploadedFiles, Param } from '@nestjs/common'; +import { Controller, Get, Post, Delete, Body, UseGuards, Req, UseInterceptors, UploadedFiles, Param, Query } from '@nestjs/common'; +import { PostType } from '@prisma/client'; import { CacheInterceptor } from '@nestjs/cache-manager'; import { FilesInterceptor } from '@nestjs/platform-express'; import { PostsService } from './posts.service'; @@ -11,9 +12,9 @@ export class PostsController { @Get() @UseGuards(ClerkAuthGuard) - async getAllPosts(@Req() req) { + async getAllPosts(@Req() req, @Query('type') type?: PostType) { const clerkUserId = req.user.clerkUserId; - const posts = await this.postsService.getAllPosts(clerkUserId); + const posts = await this.postsService.getAllPosts(clerkUserId, type); const ids = posts.map(p => p.id); const dups = ids.filter((item, index) => ids.indexOf(item) !== index); if (dups.length > 0) { @@ -53,4 +54,11 @@ export class PostsController { getComments(@Param('id') id: string) { return this.postsService.getComments(id); } + + @Delete(':id') + @UseGuards(ClerkAuthGuard) + deletePost(@Req() req, @Param('id') id: string) { + const clerkUserId = req.user.clerkUserId; + return this.postsService.deletePost(clerkUserId, id); + } } diff --git a/backend/src/modules/posts/posts.module.ts b/backend/src/modules/posts/posts.module.ts index 812e232..7859161 100644 --- a/backend/src/modules/posts/posts.module.ts +++ b/backend/src/modules/posts/posts.module.ts @@ -4,9 +4,10 @@ import { PostsController } from './posts.controller'; import { PrismaModule } from '../../database/prisma.module'; import { StorageModule } from '../storage/storage.module'; import { ChatModule } from '../chat/chat.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [PrismaModule, StorageModule, ChatModule], + imports: [PrismaModule, StorageModule, ChatModule, NotificationsModule], controllers: [PostsController], providers: [PostsService], }) diff --git a/backend/src/modules/posts/posts.service.ts b/backend/src/modules/posts/posts.service.ts index 79c14c3..0ead7f9 100644 --- a/backend/src/modules/posts/posts.service.ts +++ b/backend/src/modules/posts/posts.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../database/prisma.service'; import { StorageService } from '../storage/storage.service'; import { CreatePostDto } from './dto/create-post.dto'; import { ChatGateway } from '../chat/chat.gateway'; +import { NotificationsService } from '../notifications/notifications.service'; @Injectable() @@ -10,7 +11,8 @@ export class PostsService { constructor( private prisma: PrismaService, private storageService: StorageService, - private chatGateway: ChatGateway + private chatGateway: ChatGateway, + private notificationsService: NotificationsService ) {} async createPost(clerkUserId: string, createPostDto: CreatePostDto, files: any[]) { @@ -31,10 +33,11 @@ export class PostsService { } } - return await this.prisma.post.create({ + const post = await this.prisma.post.create({ data: { content: createPostDto.content, postType: createPostDto.postType, + isAnonymous: createPostDto.isAnonymous ?? false, imageUrls: imageUrls, author: { connect: { id: user.id } @@ -42,27 +45,36 @@ export class PostsService { }, include: { author: { - select: { name: true, username: true, avatarUrl: true }, + select: { name: true, username: true, avatarUrl: true, clerkUserId: true }, }, _count: { select: { likes: true, comments: true }, }, }, }); + + if (post.isAnonymous) { + return { + ...post, + author: { name: "Anonymous Student", username: "anonymous", avatarUrl: null } + }; + } + return post; } catch (error: any) { console.error("DATABASE ERROR:", error); throw new BadRequestException(`Database Error: ${error.message}`); } } - async getAllPosts(clerkUserId?: string) { + async getAllPosts(clerkUserId?: string, type?: string) { const user = clerkUserId ? await this.prisma.user.findUnique({ where: { clerkUserId } }) : null; - return this.prisma.post.findMany({ + const posts = await this.prisma.post.findMany({ + where: type ? { postType: type as any } : undefined, orderBy: { createdAt: 'desc' }, include: { author: { - select: { name: true, username: true, avatarUrl: true }, + select: { name: true, username: true, avatarUrl: true, clerkUserId: true }, }, _count: { select: { likes: true, comments: true }, @@ -73,6 +85,16 @@ export class PostsService { } : false, }, }); + + return posts.map(post => { + if (post.isAnonymous) { + return { + ...post, + author: { name: "Anonymous Student", username: "anonymous", avatarUrl: null } + }; + } + return post; + }); } async toggleLike(clerkUserId: string, postId: string) { @@ -108,6 +130,17 @@ export class PostsService { }, }); isLiked = true; + + if (post.authorId !== user.id) { + await this.notificationsService.createNotification({ + userId: post.authorId, + type: 'LIKE', + title: 'New Like', + content: `${user.name || user.username || 'Someone'} liked your post.`, + actorId: user.id, + postId: post.id, + }); + } } const likeCount = await this.prisma.postLike.count({ where: { postId } }); @@ -142,6 +175,17 @@ export class PostsService { const commentCount = await this.prisma.postComment.count({ where: { postId } }); + if (post.authorId !== user.id) { + await this.notificationsService.createNotification({ + userId: post.authorId, + type: 'COMMENT', + title: 'New Comment', + content: `${user.name || user.username || 'Someone'} commented: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`, + actorId: user.id, + postId: post.id, + }); + } + // Broadcast the new comment this.chatGateway.server.emit('post_comment_added', { postId, comment: newComment, commentCount }); @@ -149,17 +193,28 @@ export class PostsService { } async getComments(postId: string) { - const post = await this.prisma.post.findUnique({ where: { id: postId } }); - if (!post) throw new NotFoundException('Post not found'); - return this.prisma.postComment.findMany({ where: { postId }, orderBy: { createdAt: 'asc' }, include: { - author: { - select: { name: true, username: true, avatarUrl: true }, - }, + author: { select: { name: true, username: true, avatarUrl: true, clerkUserId: true } }, }, }); } + + async deletePost(clerkUserId: string, postId: string) { + const user = await this.prisma.user.findUnique({ where: { clerkUserId } }); + if (!user) throw new NotFoundException('User not found'); + + const post = await this.prisma.post.findUnique({ where: { id: postId } }); + if (!post) throw new NotFoundException('Post not found'); + + if (post.authorId !== user.id) { + throw new BadRequestException('You can only delete your own posts'); + } + + return this.prisma.post.delete({ + where: { id: postId } + }); + } } diff --git a/backend/src/modules/transactions/transactions.module.ts b/backend/src/modules/transactions/transactions.module.ts index 97c26d0..85fddc5 100644 --- a/backend/src/modules/transactions/transactions.module.ts +++ b/backend/src/modules/transactions/transactions.module.ts @@ -4,9 +4,10 @@ import { TransactionsService } from './transactions.service'; import { PrismaModule } from '../../database/prisma.module'; import { ChatModule } from '../chat/chat.module'; import { PaymentsModule } from '../payments/payments.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [PrismaModule, ChatModule, forwardRef(() => PaymentsModule)], + imports: [PrismaModule, ChatModule, forwardRef(() => PaymentsModule), NotificationsModule], controllers: [TransactionsController], providers: [TransactionsService], exports: [TransactionsService] diff --git a/backend/src/modules/transactions/transactions.service.ts b/backend/src/modules/transactions/transactions.service.ts index 0dd18b2..f41098d 100644 --- a/backend/src/modules/transactions/transactions.service.ts +++ b/backend/src/modules/transactions/transactions.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, ForbiddenException, import { PrismaService } from '../../database/prisma.service'; import { ChatGateway } from '../chat/chat.gateway'; import { PaymentsService } from '../payments/payments.service'; +import { NotificationsService } from '../notifications/notifications.service'; import * as crypto from 'crypto'; @Injectable() @@ -11,6 +12,7 @@ export class TransactionsService { private chatGateway: ChatGateway, @Inject(forwardRef(() => PaymentsService)) private paymentsService: PaymentsService, + private notificationsService: NotificationsService ) {} // Generate a secure 6-digit code @@ -45,6 +47,16 @@ export class TransactionsService { } }); + await this.notificationsService.createNotification({ + userId: listing.sellerId, + type: 'PURCHASE', + title: 'New Reservation', + content: `${buyer.name || buyer.username || 'A student'} reserved your item: ${listing.title}`, + actorId: buyer.id, + listingId: listing.id, + linkUrl: `/purchase-history` + }); + return transaction; } diff --git a/backend/src/modules/users/users.controller.ts b/backend/src/modules/users/users.controller.ts index 3c27d23..7f85d54 100644 --- a/backend/src/modules/users/users.controller.ts +++ b/backend/src/modules/users/users.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, UseInterceptors } from '@nestjs/common'; +import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, UseInterceptors, Query, Post } from '@nestjs/common'; import { CacheInterceptor } from '@nestjs/cache-manager'; import { UsersService } from './users.service'; import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard'; @@ -33,17 +33,30 @@ export class UsersController { } } - @Get(':id') + @Get('search') @UseGuards(ClerkAuthGuard) - @UseInterceptors(CacheInterceptor) - async getUser(@Param('id') id: string) { + async searchUsers(@Query('q') query: string) { + if (!query) return []; + return this.usersService.searchUsers(query); + } - const user = await this.usersService.getUserById(id); + @Get(':id') + @UseGuards(ClerkAuthGuard) + async getUser(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) { + const user = await this.usersService.getUserById(id, clerkUser?.clerkUserId); if (!user){ throw new NotFoundException("User not found"); } return user; } + @Post(':id/follow') + @UseGuards(ClerkAuthGuard) + async toggleFollow(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) { + const result = await this.usersService.toggleFollow(id, clerkUser.clerkUserId); + if (!result) throw new NotFoundException("User not found or cannot follow yourself"); + return result; + } + } diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index 513776d..f9b463b 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ + imports: [NotificationsModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index a4ef41d..8c44b10 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -1,9 +1,13 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../database/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; @Injectable() export class UsersService { - constructor(private prisma: PrismaService) { } + constructor( + private prisma: PrismaService, + private notificationsService: NotificationsService + ) { } // This function finds a user, or creates one if they don't exist yet! async syncUser(clerkUserId: string, email: string) { @@ -45,8 +49,8 @@ export class UsersService { return user; } - async getUserById(id: string) { - return this.prisma.user.findFirst({ + async getUserById(id: string, currentUserId?: string) { + const user = await this.prisma.user.findFirst({ where: { OR: [ { id: id }, @@ -59,10 +63,77 @@ export class UsersService { include: { images: true } }, _count: { - select: { listings: true } + select: { followers: true, following: true } } } }); + + if (!user) return null; + + let isFollowing = false; + if (currentUserId) { + const follower = await this.prisma.user.findFirst({ + where: { OR: [{ id: currentUserId }, { clerkUserId: currentUserId }] }, + select: { id: true } + }); + if (follower) { + const link = await this.prisma.user.findFirst({ + where: { id: user.id, followers: { some: { id: follower.id } } } + }); + isFollowing = !!link; + } + } + + return { ...user, isFollowing }; + } + + async toggleFollow(targetId: string, currentUserId: string) { + const targetUser = await this.prisma.user.findFirst({ where: { OR: [{ id: targetId }, { clerkUserId: targetId }] } }); + const currentUser = await this.prisma.user.findFirst({ where: { OR: [{ id: currentUserId }, { clerkUserId: currentUserId }] } }); + + if (!targetUser || !currentUser || targetUser.id === currentUser.id) return null; + + const isFollowing = await this.prisma.user.findFirst({ + where: { id: targetUser.id, followers: { some: { id: currentUser.id } } } + }); + + if (isFollowing) { + await this.prisma.user.update({ + where: { id: currentUser.id }, + data: { following: { disconnect: { id: targetUser.id } } } + }); + return { following: false }; + } else { + await this.prisma.user.update({ + where: { id: currentUser.id }, + data: { following: { connect: { id: targetUser.id } } } + }); + + // Trigger notification + await this.notificationsService.createNotification({ + userId: targetUser.id, + type: 'FOLLOW', + title: 'New Follower', + content: `${currentUser.name || currentUser.username || 'Someone'} started following you!`, + linkUrl: `/profile/${currentUser.clerkUserId}`, + actorId: currentUser.id, + }); + + return { following: true }; + } + } + + async searchUsers(query: string) { + return this.prisma.user.findMany({ + where: { + OR: [ + { name: { contains: query, mode: 'insensitive' } }, + { username: { contains: query, mode: 'insensitive' } } + ] + }, + take: 20, + select: { id: true, clerkUserId: true, name: true, username: true, avatarUrl: true, major: true, _count: { select: { followers: true } } } + }); } async updateUser(clerkUserId: string, updateData: any) { diff --git a/frontend/app/ClientNav.tsx b/frontend/app/ClientNav.tsx index 1ecb3bc..345439e 100644 --- a/frontend/app/ClientNav.tsx +++ b/frontend/app/ClientNav.tsx @@ -4,14 +4,14 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; const CATEGORIES = [ - { id: 'HOUSING', label: 'Dorm/Sublease' }, - { id: 'CLOTHES', label: 'Clothing' }, - { id: 'SCHOOL', label: 'School' }, - { id: 'LEISURE', label: 'Leisure' }, - { id: 'ACCESSORIES', label: 'Accessories' }, - { id: 'OTHER', label: 'Other' }, - { id: 'SERVICES', label: 'Services' }, - { id: 'ALL', label: 'Home' }, + { id: "HOUSING", label: "Dorm/Sublease" }, + { id: "CLOTHES", label: "Clothing" }, + { id: "SCHOOL", label: "School" }, + { id: "LEISURE", label: "Leisure" }, + { id: "ACCESSORIES", label: "Accessories" }, + { id: "OTHER", label: "Other" }, + { id: "SERVICES", label: "Services" }, + { id: "ALL", label: "Home" }, ]; export function ClientNav() { @@ -19,19 +19,19 @@ export function ClientNav() { const searchParams = useSearchParams(); // Hide the nav bar on individual listing detail pages and chat - if (pathname.match(/^\/listings\/.+$/) || pathname.startsWith('/chat')) { + if (pathname.match(/^\/listings\/.+$/) || pathname.startsWith("/chat")) { return null; } - - let currentCategory = ''; - if (pathname === '/listings') { - currentCategory = searchParams.get('category') || ''; - } else if (pathname === '/home') { - currentCategory = 'ALL'; - } else if (pathname === '/community') { - currentCategory = 'COMMUNITY'; - } else if (pathname === '/swipe') { - currentCategory = 'SWIPE'; + + let currentCategory = ""; + if (pathname === "/listings") { + currentCategory = searchParams.get("category") || ""; + } else if (pathname === "/home") { + currentCategory = "ALL"; + } else if (pathname === "/community") { + currentCategory = "COMMUNITY"; + } else if (pathname === "/swipe") { + currentCategory = "SWIPE"; } return ( @@ -40,28 +40,28 @@ export function ClientNav() { {CATEGORIES.map((cat) => { const isActive = currentCategory === cat.id; return ( - {cat.label} ); })} - +
- - Match your needs @@ -70,5 +70,3 @@ export function ClientNav() { ); } - - diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index 384c99e..cc5deac 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -8,7 +8,10 @@ export default function AboutPage() {
{/* Main Content */}
- + Back @@ -18,35 +21,63 @@ export default function AboutPage() {
-

The Problem

+

+ The Problem +

- Buying and selling on campus shouldn't be sketchy or complicated. Traditional marketplaces are full of scammers, irrelevant listings, and people who live miles away. Students lack a secure, localized space to trade dorm essentials, find subleases, or offer their skills without worrying about who they are actually meeting up with or paying hefty middleman fees. + Buying and selling on campus shouldn't be sketchy or complicated. + Traditional marketplaces are full of scammers, irrelevant listings, + and people who live miles away. Students lack a secure, localized + space to trade dorm essentials, find subleases, or offer their + skills without worrying about who they are actually meeting up with + or paying hefty middleman fees.

-

Our Solution

+

+ Our Solution +

- We built Orbit as an exclusive marketplace specifically for university students. By requiring a verified .edu email address, we ensure that every single transaction is safe, local, and student-to-student. We integrated secure payments through Stripe with a unique Meetup Code system, completely eliminating the anxiety of cash handoffs. It keeps the money in the campus community, helps students save, and makes finding what you need effortless. + We built Orbit as an exclusive marketplace specifically for + university students. By requiring a verified .edu email address, we + ensure that every single transaction is safe, local, and + student-to-student. We integrated secure payments through Stripe + with a unique Meetup Code system, completely eliminating the anxiety + of cash handoffs. It keeps the money in the campus community, helps + students save, and makes finding what you need effortless.

-

The Ecosystem

+

+ The Ecosystem +

- Orbit isn't just a marketplace for buying textbooks or selling your dorm fridge. It's the central hub for your entire university ecosystem. Need CS help, a resume review, or someone to help you move out? We've got you covered. + Orbit isn't just a marketplace for buying textbooks or selling your + dorm fridge. It's the central hub for your entire university + ecosystem. Need CS help, a resume review, or someone to help you + move out? We've got you covered.

-

The Challenge

+

+ The Challenge +

- Building Orbit wasn't just about creating a marketplace; it was about engineering trust. The biggest challenge was designing a system that guaranteed safety without adding friction. We had to implement a strict yet seamless .edu verification process and build a custom escrow-like payment flow using Stripe and Meetup Codes. We needed to ensure that sellers felt confident they would get paid, and buyers felt confident they wouldn't get scammed—all while keeping the platform lightning fast and visually engaging for Gen Z students. + Building Orbit wasn't just about creating a marketplace; it was + about engineering trust. The biggest challenge was designing a + system that guaranteed safety without adding friction. We had to + implement a strict yet seamless .edu verification process and build + a custom escrow-like payment flow using Stripe and Meetup Codes. We + needed to ensure that sellers felt confident they would get paid, + and buyers felt confident they wouldn't get scammed—all while + keeping the platform lightning fast and visually engaging for Gen Z + students.

-
-
); } diff --git a/frontend/app/add-product/page.tsx b/frontend/app/add-product/page.tsx index 329b78a..bb46be7 100644 --- a/frontend/app/add-product/page.tsx +++ b/frontend/app/add-product/page.tsx @@ -1,450 +1,588 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import { useAuth } from '@clerk/nextjs'; -import { useRouter } from 'next/navigation'; -import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft, Tag, AlignLeft, DollarSign, ListPlus, Loader2, ImagePlus, AlertCircle, X, GripVertical, ShieldCheck, Banknote, UploadCloud } from 'lucide-react'; -import Link from 'next/link'; -import axios from 'axios'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useAuth } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Tag, + AlignLeft, + DollarSign, + ListPlus, + Loader2, + ImagePlus, + AlertCircle, + X, + GripVertical, + ShieldCheck, + Banknote, + UploadCloud, +} from "lucide-react"; +import Link from "next/link"; +import axios from "axios"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; const MAX_IMAGES = 6; interface ImagePreview { - id: string; - file: File; - url: string; + id: string; + file: File; + url: string; } export default function AddProductPage() { - const { getToken, userId } = useAuth(); - const router = useRouter(); - const fileInputRef = useRef(null); - - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - const [images, setImages] = useState([]); - - const [formData, setFormData] = useState({ - title: '', - description: '', - price: '', - category: 'SCHOOL', - acceptsDirectPayment: true, - acceptsProtectedPayment: false, - location: '', - brand: '', - colors: '', - size: '', - material: '', - weatherFound: '', - }); - - const COLORS = ['Black', 'White', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Pink', 'Purple', 'Orange', 'Brown', 'Multi', 'Other']; - const SIZES = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'One Size', 'Other']; - - const [isStripeLinked, setIsStripeLinked] = useState(false); - const [isLoadingStripeStatus, setIsLoadingStripeStatus] = useState(true); - - // Fetch Stripe status on mount - useEffect(() => { - const fetchStripeStatus = async () => { - try { - const token = await getToken(); - if (token) { - const res = await axios.get(`http://127.0.0.1:3000/payments/connect/status`, { - headers: { Authorization: `Bearer ${token}` } - }); - setIsStripeLinked(res.data.linked); - } - } catch (err) { - console.error("Failed to fetch Stripe status", err); - } finally { - setIsLoadingStripeStatus(false); - } - }; - fetchStripeStatus(); - }, [getToken]); - - const categories = ['SCHOOL', 'CLOTHES', 'HOUSING', 'LEISURE', 'ACCESSORIES', 'OTHER']; - - const handleImageAdd = (e: React.ChangeEvent) => { - if (!e.target.files) return; - - const newFiles = Array.from(e.target.files); - const remaining = MAX_IMAGES - images.length; - const filesToAdd = newFiles.slice(0, remaining); - - const newPreviews: ImagePreview[] = filesToAdd.map((file) => ({ - id: crypto.randomUUID(), - file, - url: URL.createObjectURL(file), - })); - - setImages((prev) => [...prev, ...newPreviews]); - - // Reset input so the same file can be re-selected - if (fileInputRef.current) fileInputRef.current.value = ''; - }; - - const removeImage = (id: string) => { - setImages((prev) => { - const toRemove = prev.find((img) => img.id === id); - if (toRemove) URL.revokeObjectURL(toRemove.url); - return prev.filter((img) => img.id !== id); - }); - }; - - const handleChange = (e: React.ChangeEvent) => { - setFormData({ ...formData, [e.target.name]: e.target.value }); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(''); - - try { - const token = await getToken(); - - const submitData = new FormData(); - submitData.append('title', formData.title); - submitData.append('description', formData.description); - submitData.append('price', formData.price.toString()); - submitData.append('category', formData.category); - submitData.append('acceptsDirectPayment', formData.acceptsDirectPayment.toString()); - submitData.append('acceptsProtectedPayment', formData.acceptsProtectedPayment.toString()); - if (formData.location) submitData.append('location', formData.location); - if (formData.brand) submitData.append('brand', formData.brand); - if (formData.colors) submitData.append('colors', formData.colors); - if (formData.size) submitData.append('size', formData.size); - if (formData.material) submitData.append('material', formData.material); - - images.forEach((img) => { - submitData.append('images', img.file); - }); - - await axios.post('http://127.0.0.1:3000/listings', submitData, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - router.push('/listings'); - } catch (err: any) { - const errData = err.response?.data; - const backendMessage = errData?.message - ? Array.isArray(errData.message) - ? errData.message.join(', ') - : errData.message - : err.message || 'An error occurred while creating the listing.'; - - setError(backendMessage); - } finally { - setIsLoading(false); - } - }; - - return ( -
- - {/* Back Button */} - - - Back to Marketplace - - - {/* Header */} -
-

- Create a Listing -

-
- - {/* Form Card */} -
- {error && ( - - - Error - {error} - - )} - -
- {/* ── Image Upload ── */} -
- -
- - -

Drag and drop your image here, or click to select a file

-

(Only *.jpeg, *.jpg, *.png images will be accepted)

-
- - {/* Previews */} - {images.length > 0 && ( -
- - {images.map((img, idx) => ( - - {`Upload - {idx === 0 && ( -
- Cover -
- )} - -
- ))} -
-
- )} -

- ({images.length}/{MAX_IMAGES}) uploaded. First image will be the cover. -

-
- - {/* ── Category & Location ── */} -
-
- - -
-
- - -
-
- - {/* ── Brand & Material ── */} -
-
- - -
-
- - -
-
- - {/* ── Colors, Size ── */} -
-
- - -
-
- - -
-
- - {/* ── Title ── */} -
- - -
- - {/* ── Price ── */} -
- -
- $ - -
-
- - {/* ── Description ── */} -
- -