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
28 changes: 28 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"pg": "^8.21.0",
"raw-body": "^3.0.2",
"reflect-metadata": "^0.2.2",
"resend": "^6.16.0",
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"stripe": "^22.2.0",
Expand Down
30 changes: 30 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ model User {
stripeAccountId String? @unique
stripeAccountLinked Boolean @default(false)
university String?
isEduVerified Boolean @default(false)
conversationMembers ConversationMember[]
interactions Interaction[]
listings Listing[]
Expand All @@ -43,6 +44,10 @@ model User {
notificationsReceived Notification[] @relation("NotificationsReceived")
notificationsSent Notification[] @relation("NotificationsSent")

reviewsGiven Review[] @relation("ReviewsGiven")
reviewsReceived Review[] @relation("ReviewsReceived")
verificationCodes VerificationCode[]

// Notification Preferences
emailNotifications Boolean @default(true)
emailFrequency String @default("daily")
Expand Down Expand Up @@ -252,6 +257,31 @@ enum InteractionType {
SKIP
}



model Review {
id String @id @default(uuid())
reviewerId String
revieweeId String
rating Int
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

reviewer User @relation("ReviewsGiven", fields: [reviewerId], references: [id])
reviewee User @relation("ReviewsReceived", fields: [revieweeId], references: [id])
}

model VerificationCode {
id String @id @default(uuid())
userId String
code String
expiresAt DateTime
createdAt DateTime @default(now())

user User @relation(fields: [userId], references: [id])
}

enum PaymentMethod {
DIRECT
STRIPE
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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';
import { ReviewsModule } from './modules/reviews/reviews.module';
@Module({
imports: [
ServeStaticModule.forRoot({
Expand All @@ -43,6 +44,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
JobsModule,
PaymentsModule,
NotificationsModule,
ReviewsModule,
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
Expand Down
9 changes: 8 additions & 1 deletion backend/src/modules/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ export class ChatService {
members: {
include: {
user: {
select: { id: true, name: true, avatarUrl: true, clerkUserId: true }
select: {
id: true,
name: true,
avatarUrl: true,
clerkUserId: true,
isEduVerified: true,
reviewsReceived: { select: { rating: true } }
}
}
}
},
Expand Down
11 changes: 10 additions & 1 deletion backend/src/modules/listings/listings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,16 @@ export class ListingsService {
orderBy: { createdAt: 'desc' },
include: {
images: true,
seller: { select: { id: true, email: true, university: true } },
seller: {
select: {
id: true,
email: true,
university: true,
isEduVerified: true,
username: true,
reviewsReceived: { select: { rating: true } }
}
},
},
});
}
Expand Down
15 changes: 15 additions & 0 deletions backend/src/modules/reviews/dto/create-review.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsString, IsInt, Min, Max, IsOptional } from 'class-validator';

export class CreateReviewDto {
@IsString()
revieweeId: string;

@IsInt()
@Min(1)
@Max(5)
rating: number;

@IsOptional()
@IsString()
comment?: string;
}
21 changes: 21 additions & 0 deletions backend/src/modules/reviews/reviews.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Controller, Post, Get, Body, Param, Req, UseGuards } from '@nestjs/common';
import { ReviewsService } from './reviews.service';
import { CreateReviewDto } from './dto/create-review.dto';
import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard';

@Controller('reviews')
export class ReviewsController {
constructor(private readonly reviewsService: ReviewsService) {}

@Post()
@UseGuards(ClerkAuthGuard)
createReview(@Req() req, @Body() createReviewDto: CreateReviewDto) {
const clerkUserId = req.user.clerkUserId;
return this.reviewsService.createReview(clerkUserId, createReviewDto);
}

@Get('user/:id')
getUserReviews(@Param('id') userId: string) {
return this.reviewsService.getUserReviews(userId);
}
}
12 changes: 12 additions & 0 deletions backend/src/modules/reviews/reviews.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ReviewsService } from './reviews.service';
import { ReviewsController } from './reviews.controller';
import { PrismaModule } from '../../database/prisma.module';

@Module({
imports: [PrismaModule],
controllers: [ReviewsController],
providers: [ReviewsService],
exports: [ReviewsService],
})
export class ReviewsModule {}
74 changes: 74 additions & 0 deletions backend/src/modules/reviews/reviews.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { CreateReviewDto } from './dto/create-review.dto';

@Injectable()
export class ReviewsService {
constructor(private prisma: PrismaService) {}

async createReview(clerkUserId: string, createReviewDto: CreateReviewDto) {
const reviewer = await this.prisma.user.findUnique({ where: { clerkUserId } });
if (!reviewer) throw new NotFoundException('Reviewer not found');

if (reviewer.id === createReviewDto.revieweeId) {
throw new BadRequestException('You cannot review yourself');
}

// Ensure they have chatted (they share a conversation)
const hasChatted = await this.prisma.conversation.findFirst({
where: {
AND: [
{ members: { some: { userId: reviewer.id } } },
{ members: { some: { userId: createReviewDto.revieweeId } } }
]
}
});

if (!hasChatted) {
throw new BadRequestException('You can only leave a review for someone you have chatted with.');
}

// Ensure no existing review for this user pair
const existingReview = await this.prisma.review.findFirst({
where: {
reviewerId: reviewer.id,
revieweeId: createReviewDto.revieweeId
}
});

if (existingReview) {
throw new BadRequestException('You have already left a review for this user.');
}

return this.prisma.review.create({
data: {
reviewerId: reviewer.id,
revieweeId: createReviewDto.revieweeId,
rating: createReviewDto.rating,
comment: createReviewDto.comment,
},
include: {
reviewer: {
select: { id: true, name: true, username: true, avatarUrl: true }
}
}
});
}

async getUserReviews(userId: string) {
const reviews = await this.prisma.review.findMany({
where: { revieweeId: userId },
include: {
reviewer: {
select: { id: true, name: true, username: true, avatarUrl: true }
}
},
orderBy: { createdAt: 'desc' }
});

const totalRating = reviews.reduce((acc, curr) => acc + curr.rating, 0);
const averageRating = reviews.length > 0 ? (totalRating / reviews.length) : 0;

return { reviews, averageRating, totalCount: reviews.length };
}
}
38 changes: 33 additions & 5 deletions backend/src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, UseInterceptors, Query, Post } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
import { Controller, Get, Patch, UseGuards, Param, Body, NotFoundException, ConflictException, Query, Post, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard';
import { CurrentUser} from '../../common/decorators/current-user.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AuthUser } from '../../common/types/auth-user.type';

@Controller('users')
Expand All @@ -25,7 +24,6 @@ export class UsersController {
try {
return await this.usersService.updateUser(clerkUser.clerkUserId, updateData);
} catch (error: any) {
// Prisma error code for Unique Constraint Violation
if (error.code === 'P2002') {
throw new ConflictException('Username is already taken.');
}
Expand All @@ -44,12 +42,24 @@ export class UsersController {
@UseGuards(ClerkAuthGuard)
async getUser(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) {
const user = await this.usersService.getUserById(id, clerkUser?.clerkUserId);
if (!user){
if (!user) {
throw new NotFoundException("User not found");
}
return user;
}

@Get(':id/followers')
@UseGuards(ClerkAuthGuard)
async getFollowers(@Param('id') id: string) {
return this.usersService.getFollowers(id);
}

@Get(':id/following')
@UseGuards(ClerkAuthGuard)
async getFollowing(@Param('id') id: string) {
return this.usersService.getFollowing(id);
}

@Post(':id/follow')
@UseGuards(ClerkAuthGuard)
async toggleFollow(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) {
Expand All @@ -58,5 +68,23 @@ export class UsersController {
return result;
}

@Delete(':id/follower')
@UseGuards(ClerkAuthGuard)
async removeFollower(@Param('id') id: string, @CurrentUser() clerkUser: AuthUser) {
const result = await this.usersService.removeFollower(id, clerkUser.clerkUserId);
if (!result) throw new NotFoundException("Follower not found");
return result;
}

@Post('verify-edu/send')
@UseGuards(ClerkAuthGuard)
async sendEduVerification(@CurrentUser() clerkUser: AuthUser) {
return this.usersService.sendEduVerification(clerkUser.clerkUserId);
}

@Post('verify-edu/verify')
@UseGuards(ClerkAuthGuard)
async verifyEduCode(@CurrentUser() clerkUser: AuthUser, @Body('code') code: string) {
return this.usersService.verifyEduCode(clerkUser.clerkUserId, code);
}
}
Loading
Loading