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
56 changes: 45 additions & 11 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -215,7 +222,8 @@ model ListingView {
}

enum ListingCategory {
HOUSING
DORM
SUBLEASE
CLOTHES
SCHOOL
LEISURE
Expand Down Expand Up @@ -277,3 +285,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])
}
5 changes: 4 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -40,6 +40,9 @@ import { PaymentsModule } from './modules/payments/payments.module';
PostsModule,
TransactionsModule,
ReportsModule,
JobsModule,
PaymentsModule,
NotificationsModule,
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
Expand Down
3 changes: 0 additions & 3 deletions backend/src/modules/listings/listings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export class ListingsController {
}

@Get('all')
@UseGuards(ClerkAuthGuard)
@UseInterceptors(CacheInterceptor)
async getAllListings(
@Query('category') category?: ListingCategory,
Expand All @@ -39,7 +38,6 @@ export class ListingsController {
}

@Get('recommendations')
@UseGuards(ClerkAuthGuard)
@UseInterceptors(CacheInterceptor)
async getRecommendations(
@Query('q') q: string,
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions backend/src/modules/notifications/notifications.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions backend/src/modules/notifications/notifications.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
import { PrismaModule } from '../../database/prisma.module';
import { ChatModule } from '../chat/chat.module';

@Module({
imports: [PrismaModule, ChatModule],
providers: [NotificationsService],
controllers: [NotificationsController],
exports: [NotificationsService],
})
export class NotificationsModule {}
101 changes: 101 additions & 0 deletions backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
@@ -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.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 };
}
}
12 changes: 7 additions & 5 deletions backend/src/modules/payments/payments.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
27 changes: 24 additions & 3 deletions backend/src/modules/payments/payments.service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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',
});
Expand Down Expand Up @@ -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: [
Expand All @@ -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: {
Expand Down
5 changes: 5 additions & 0 deletions backend/src/modules/posts/dto/create-post.dto.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,4 +12,8 @@ export class CreatePostDto {
@IsEnum(PostType)
@IsNotEmpty()
postType: PostType;

@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
isAnonymous?: boolean;
}
14 changes: 11 additions & 3 deletions backend/src/modules/posts/posts.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}
Loading
Loading