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
24 changes: 24 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ model User {
followers User[] @relation("UserFollows")
following User[] @relation("UserFollows")

offersMade Offer[] @relation("OffersMade")

notificationsReceived Notification[] @relation("NotificationsReceived")
notificationsSent Notification[] @relation("NotificationsSent")

Expand Down Expand Up @@ -91,6 +93,7 @@ model Listing {
messages Message[]
reports Report[]
transactions Transaction[]
offers Offer[]
}

model ListingImage {
Expand Down Expand Up @@ -324,6 +327,13 @@ enum MeetupStatus {
CANCELLED
}

enum OfferStatus {
PENDING
ACCEPTED
REJECTED
CANCELLED
}

enum NotificationType {
FOLLOW
LIKE
Expand Down Expand Up @@ -354,4 +364,18 @@ model Notification {
enum Role {
USER
ADMIN
MODERATOR
}

model Offer {
id String @id @default(uuid())
price Float
status OfferStatus @default(PENDING)
buyerId String
listingId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

buyer User @relation("OffersMade", fields: [buyerId], references: [id])
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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';
import { OffersModule } from './modules/offers/offers.module';
import { AdminModule } from './modules/admin/admin.module';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { S3PresignInterceptor } from './common/interceptors/s3-presign.interceptor';
Expand Down Expand Up @@ -49,6 +50,7 @@ import { S3PresignInterceptor } from './common/interceptors/s3-presign.intercept
NotificationsModule,
ReviewsModule,
AdminModule,
OffersModule,
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
Expand Down
2 changes: 1 addition & 1 deletion backend/src/modules/chat/chat.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ import { ChatGateway } from './chat.gateway';
],
providers: [ChatService, ChatGateway],
controllers: [ChatController],
exports: [ChatGateway],
exports: [ChatGateway, ChatService],
})
export class ChatModule {}
11 changes: 11 additions & 0 deletions backend/src/modules/offers/dto/create-offer.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';

export class CreateOfferDto {
@IsString()
@IsNotEmpty()
listingId: string;

@IsNumber()
@Min(1)
price: number;
}
53 changes: 53 additions & 0 deletions backend/src/modules/offers/offers.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { OffersService } from './offers.service';
import { CreateOfferDto } from './dto/create-offer.dto';
import { ClerkAuthGuard } from '../../common/guards/clerk-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';

@Controller('offers')
@UseGuards(ClerkAuthGuard)
export class OffersController {
constructor(private readonly offersService: OffersService) { }

@Post()
createOffer(
@CurrentUser() user: any,
@Body() createOfferDto: CreateOfferDto
) {
return this.offersService.createOffer(user.clerkUserId, createOfferDto);
}

@Get('me/sent')
getOffersSent(@CurrentUser() user: any) {
return this.offersService.getOffersSent(user.clerkUserId);
}

@Get('me/received')
getOffersReceived(@CurrentUser() user: any) {
return this.offersService.getOffersReceived(user.clerkUserId);
}

@Patch(':id/accept')
acceptOffer(
@CurrentUser() user: any,
@Param('id') id: string
) {
return this.offersService.acceptOffer(user.clerkUserId, id);
}

@Patch(':id/reject')
rejectOffer(
@CurrentUser() user: any,
@Param('id') id: string
) {
return this.offersService.rejectOffer(user.clerkUserId, id);
}

@Delete(':id')
cancelOffer(
@CurrentUser() user: any,
@Param('id') id: string
) {
return this.offersService.cancelOffer(user.clerkUserId, id);
}
}
12 changes: 12 additions & 0 deletions backend/src/modules/offers/offers.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { OffersService } from './offers.service';
import { OffersController } from './offers.controller';
import { ChatModule } from '../chat/chat.module';

@Module({
imports: [ChatModule],
providers: [OffersService],
controllers: [OffersController],
exports: [OffersService],
})
export class OffersModule {}
244 changes: 244 additions & 0 deletions backend/src/modules/offers/offers.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { CreateOfferDto } from './dto/create-offer.dto';
import { OfferStatus, ListingStatus } from '@prisma/client';
import { ChatService } from '../chat/chat.service';

@Injectable()
export class OffersService {
constructor(
private prisma: PrismaService,
private chatService: ChatService,
) { }

async createOffer(clerkUserId: string, createOfferDto: CreateOfferDto) {
// Find user
const dbUser = await this.prisma.user.findUnique({
where: { clerkUserId }
});
if (!dbUser) throw new NotFoundException('User not found');

// Find listing
const listing = await this.prisma.listing.findUnique({
where: { id: createOfferDto.listingId }
});
if (!listing) throw new NotFoundException('Listing not found');

if (listing.status !== ListingStatus.ACTIVE) {
throw new BadRequestException('Listing is no longer active');
}

if (listing.sellerId === dbUser.id) {
throw new BadRequestException('Cannot make an offer on your own listing');
}

if (createOfferDto.price > listing.price) {
throw new BadRequestException('Offer cannot be higher than listing price');
}

// Check if there is already a pending offer from this user
const existingOffer = await this.prisma.offer.findFirst({
where: {
buyerId: dbUser.id,
listingId: listing.id,
status: OfferStatus.PENDING,
}
});

if (existingOffer) {
throw new BadRequestException('You already have a pending offer for this listing');
}

// Create the offer
const offer = await this.prisma.offer.create({
data: {
price: createOfferDto.price,
listingId: listing.id,
buyerId: dbUser.id,
status: OfferStatus.PENDING,
},
include: {
listing: { select: { title: true, sellerId: true } }
}
});

// Chat integration: Find or create conversation and send system message
try {
// Find existing conversation between buyer and seller for this listing
let conversation = await this.prisma.conversation.findFirst({
where: {
listingId: listing.id,
members: {
every: {
userId: { in: [dbUser.id, listing.sellerId] }
}
}
}
});

if (!conversation) {
conversation = await this.prisma.conversation.create({
data: {
listingId: listing.id,
members: {
create: [
{ userId: dbUser.id },
{ userId: listing.sellerId }
]
}
}
});
}
Comment on lines +68 to +91

// Send a system message in the chat
await this.chatService.createMessage(
clerkUserId,
conversation.id,
`I have made an offer of $${offer.price} for this item.`
);
} catch (error) {
console.error('Failed to send offer chat message:', error);
}

return offer;
}

async getOffersSent(clerkUserId: string) {
const dbUser = await this.prisma.user.findUnique({
where: { clerkUserId }
});
if (!dbUser) throw new NotFoundException('User not found');

return this.prisma.offer.findMany({
where: { buyerId: dbUser.id },
include: {
listing: {
include: { images: true }
}
},
orderBy: { createdAt: 'desc' }
});
}

async getOffersReceived(clerkUserId: string) {
const dbUser = await this.prisma.user.findUnique({
where: { clerkUserId }
});
if (!dbUser) throw new NotFoundException('User not found');

return this.prisma.offer.findMany({
where: {
listing: { sellerId: dbUser.id }
},
include: {
listing: {
include: { images: true }
},
buyer: {
select: { id: true, name: true, username: true, avatarUrl: true }
}
},
orderBy: { createdAt: 'desc' }
});
}

async acceptOffer(clerkUserId: string, offerId: string) {
const dbUser = await this.prisma.user.findUnique({
where: { clerkUserId }
});
if (!dbUser) throw new NotFoundException('User not found');

const offer = await this.prisma.offer.findUnique({
where: { id: offerId },
include: { listing: true }
});

if (!offer) throw new NotFoundException('Offer not found');

if (offer.listing.sellerId !== dbUser.id) {
throw new ForbiddenException('You do not have permission to accept this offer');
}

if (offer.status !== OfferStatus.PENDING) {
throw new BadRequestException('Offer is no longer pending');
}

// Use transaction to accept this offer and reject all other pending offers for this listing
await this.prisma.$transaction(async (prisma) => {
// Accept the target offer
await prisma.offer.update({
where: { id: offerId },
data: { status: OfferStatus.ACCEPTED }
});

// Reject all other pending offers for the same listing
await prisma.offer.updateMany({
where: {
listingId: offer.listingId,
status: OfferStatus.PENDING,
id: { not: offerId }
},
data: { status: OfferStatus.REJECTED }
});
});

return { success: true, message: 'Offer accepted successfully' };
}

async rejectOffer(clerkUserId: string, offerId: string) {
const dbUser = await this.prisma.user.findUnique({
where: { clerkUserId }
});
if (!dbUser) throw new NotFoundException('User not found');

const offer = await this.prisma.offer.findUnique({
where: { id: offerId },
include: { listing: true }
});

if (!offer) throw new NotFoundException('Offer not found');

if (offer.listing.sellerId !== dbUser.id) {
throw new ForbiddenException('You do not have permission to reject this offer');
}

if (offer.status !== OfferStatus.PENDING) {
throw new BadRequestException('Offer is no longer pending');
}

const updatedOffer = await this.prisma.offer.update({
where: { id: offerId },
data: { status: OfferStatus.REJECTED }
});

return updatedOffer;
}

async cancelOffer(clerkUserId: string, offerId: string) {
const dbUser = await this.prisma.user.findUnique({
where: { clerkUserId }
});
if (!dbUser) throw new NotFoundException('User not found');

const offer = await this.prisma.offer.findUnique({
where: { id: offerId }
});

if (!offer) throw new NotFoundException('Offer not found');

if (offer.buyerId !== dbUser.id) {
throw new ForbiddenException('You do not have permission to cancel this offer');
}

if (offer.status !== OfferStatus.PENDING) {
throw new BadRequestException('Only pending offers can be cancelled');
}

const updatedOffer = await this.prisma.offer.update({
where: { id: offerId },
data: { status: OfferStatus.CANCELLED }
});

return updatedOffer;
}
}
Loading
Loading