Initial marketplace implementation
Full-stack marketplace for buying/selling second-hand items. React 19 + TypeScript + Tailwind CSS v4 frontend with 17 screens, Express + Prisma + Socket.io backend, Stripe payments, JWT auth. Deployed at https://marketplace.173.212.212.157.sslip.io/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
148
server/src/routes/auth.ts
Normal file
148
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { hashPassword, comparePassword } from '../utils/password.js';
|
||||
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../utils/jwt.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { registerSchema, loginSchema } from '../validators/auth.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', validate(registerSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { fullName, email, password } = req.body;
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) throw new AppError(409, 'Email already registered');
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await prisma.user.create({
|
||||
data: { fullName, email, passwordHash },
|
||||
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
|
||||
});
|
||||
|
||||
const accessToken = generateAccessToken(user.id);
|
||||
const refreshToken = generateRefreshToken(user.id);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken,
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
ipAddress: req.ip || null,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env['NODE_ENV'] === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
res.status(201).json({ user, accessToken });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', validate(loginSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) throw new AppError(401, 'Invalid email or password');
|
||||
if (!user.isActive) throw new AppError(403, 'Account is disabled');
|
||||
|
||||
const valid = await comparePassword(password, user.passwordHash);
|
||||
if (!valid) throw new AppError(401, 'Invalid email or password');
|
||||
|
||||
const accessToken = generateAccessToken(user.id);
|
||||
const refreshToken = generateRefreshToken(user.id);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken,
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
ipAddress: req.ip || null,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env['NODE_ENV'] === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { passwordHash: _, ...userData } = user;
|
||||
res.json({ user: userData, accessToken });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const token = req.cookies?.refreshToken;
|
||||
if (!token) throw new AppError(401, 'No refresh token');
|
||||
|
||||
const payload = verifyRefreshToken(token);
|
||||
const session = await prisma.session.findUnique({ where: { refreshToken: token } });
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
if (session) await prisma.session.delete({ where: { id: session.id } });
|
||||
throw new AppError(401, 'Invalid refresh token');
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(payload.userId);
|
||||
const newRefreshToken = generateRefreshToken(payload.userId);
|
||||
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { refreshToken: newRefreshToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
res.cookie('refreshToken', newRefreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env['NODE_ENV'] === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
res.json({ accessToken });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
|
||||
});
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const token = req.cookies?.refreshToken;
|
||||
if (token) {
|
||||
await prisma.session.deleteMany({ where: { refreshToken: token } });
|
||||
}
|
||||
res.clearCookie('refreshToken');
|
||||
res.json({ message: 'Logged out' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
90
server/src/routes/chat.ts
Normal file
90
server/src/routes/chat.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/conversations', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const conversations = await prisma.conversation.findMany({
|
||||
where: { OR: [{ user1Id: req.userId }, { user2Id: req.userId }] },
|
||||
include: {
|
||||
user1: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
user2: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
listing: { select: { id: true, title: true, price: true, images: { take: 1 } } },
|
||||
messages: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const result = await Promise.all(conversations.map(async (conv) => {
|
||||
const unreadCount = await prisma.message.count({
|
||||
where: { conversationId: conv.id, senderId: { not: req.userId }, isRead: false },
|
||||
});
|
||||
return {
|
||||
...conv,
|
||||
lastMessage: conv.messages[0] || null,
|
||||
unreadCount,
|
||||
};
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/conversations/:id/messages', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const conv = await prisma.conversation.findUnique({ where: { id: req.params.id } });
|
||||
if (!conv) throw new AppError(404, 'Conversation not found');
|
||||
if (conv.user1Id !== req.userId && conv.user2Id !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where: { conversationId: req.params.id },
|
||||
include: { sender: { select: { id: true, fullName: true, avatar: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
await prisma.message.updateMany({
|
||||
where: { conversationId: req.params.id, senderId: { not: req.userId }, isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
|
||||
res.json(messages);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/conversations', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { recipientId, listingId, message } = req.body;
|
||||
if (recipientId === req.userId) throw new AppError(400, 'Cannot message yourself');
|
||||
|
||||
const [id1, id2] = [req.userId!, recipientId].sort();
|
||||
let conversation = await prisma.conversation.findFirst({
|
||||
where: { user1Id: id1, user2Id: id2, listingId: listingId || null },
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
conversation = await prisma.conversation.create({
|
||||
data: { user1Id: id1, user2Id: id2, listingId: listingId || null },
|
||||
});
|
||||
}
|
||||
|
||||
if (message) {
|
||||
await prisma.message.create({
|
||||
data: { content: message, senderId: req.userId!, conversationId: conversation.id },
|
||||
});
|
||||
await prisma.conversation.update({ where: { id: conversation.id }, data: { updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
res.json(conversation);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
186
server/src/routes/listing.ts
Normal file
186
server/src/routes/listing.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate, optionalAuth } from '../middleware/auth.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { upload } from '../middleware/upload.js';
|
||||
import { createListingSchema, updateListingSchema } from '../validators/listing.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const listingSelect = {
|
||||
id: true, title: true, description: true, price: true, obo: true,
|
||||
category: true, condition: true, status: true, location: true, viewCount: true,
|
||||
createdAt: true, updatedAt: true, sellerId: true,
|
||||
seller: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, showEmail: true, showPhone: true, showLocation: true } },
|
||||
images: { orderBy: { order: 'asc' as const } },
|
||||
_count: { select: { favorites: true } },
|
||||
};
|
||||
|
||||
router.get('/', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { page = '1', pageSize = '20', category, search, sort = 'newest', condition } = req.query;
|
||||
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
|
||||
const take = parseInt(pageSize as string);
|
||||
|
||||
const where: Record<string, unknown> = { status: 'ACTIVE' };
|
||||
if (category) where.category = category;
|
||||
if (condition) where.condition = condition;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||
{ description: { contains: search as string, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const orderBy = sort === 'price_asc' ? { price: 'asc' as const }
|
||||
: sort === 'price_desc' ? { price: 'desc' as const }
|
||||
: sort === 'popular' ? { viewCount: 'desc' as const }
|
||||
: { createdAt: 'desc' as const };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.listing.findMany({ where, select: listingSelect, skip, take, orderBy }),
|
||||
prisma.listing.count({ where }),
|
||||
]);
|
||||
|
||||
let favorites: Set<string> = new Set();
|
||||
if (req.userId) {
|
||||
const favs = await prisma.favorite.findMany({
|
||||
where: { userId: req.userId, listingId: { in: data.map(l => l.id) } },
|
||||
select: { listingId: true },
|
||||
});
|
||||
favorites = new Set(favs.map(f => f.listingId));
|
||||
}
|
||||
|
||||
const listings = data.map(l => ({ ...l, isFavorited: favorites.has(l.id) }));
|
||||
|
||||
res.json({
|
||||
data: listings,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
pageSize: take,
|
||||
totalPages: Math.ceil(total / take),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const listing = await prisma.listing.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { ...listingSelect, seller: { select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true } } },
|
||||
});
|
||||
if (!listing || listing.status === 'DELETED') throw new AppError(404, 'Listing not found');
|
||||
|
||||
await prisma.listing.update({ where: { id: req.params.id }, data: { viewCount: { increment: 1 } } });
|
||||
|
||||
let isFavorited = false;
|
||||
if (req.userId) {
|
||||
const fav = await prisma.favorite.findUnique({
|
||||
where: { userId_listingId: { userId: req.userId, listingId: listing.id } },
|
||||
});
|
||||
isFavorited = !!fav;
|
||||
}
|
||||
|
||||
res.json({ ...listing, isFavorited });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authenticate, validate(createListingSchema), async (req, res, next) => {
|
||||
try {
|
||||
const listing = await prisma.listing.create({
|
||||
data: { ...req.body, sellerId: req.userId!, status: 'DRAFT' },
|
||||
select: listingSelect,
|
||||
});
|
||||
res.status(201).json(listing);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, validate(updateListingSchema), async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, 'Listing not found');
|
||||
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
const listing = await prisma.listing.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
select: listingSelect,
|
||||
});
|
||||
res.json(listing);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, 'Listing not found');
|
||||
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
await prisma.listing.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
res.json({ message: 'Listing deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/images', authenticate, upload.array('images', 6), async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, 'Listing not found');
|
||||
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) throw new AppError(400, 'No files uploaded');
|
||||
|
||||
const existingImages = await prisma.listingImage.count({ where: { listingId: req.params.id } });
|
||||
|
||||
const images = await Promise.all(
|
||||
files.map((file, i) =>
|
||||
prisma.listingImage.create({
|
||||
data: {
|
||||
url: `/uploads/${file.filename}`,
|
||||
order: existingImages + i,
|
||||
listingId: req.params.id!,
|
||||
uploadedBy: req.userId!,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
res.status(201).json(images);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/favorite', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.favorite.findUnique({
|
||||
where: { userId_listingId: { userId: req.userId!, listingId: req.params.id! } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.favorite.delete({ where: { id: existing.id } });
|
||||
res.json({ isFavorited: false });
|
||||
} else {
|
||||
await prisma.favorite.create({ data: { userId: req.userId!, listingId: req.params.id! } });
|
||||
res.json({ isFavorited: true });
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
44
server/src/routes/notification.ts
Normal file
44
server/src/routes/notification.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { userId: req.userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
res.json(notifications);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/read-all', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
await prisma.notification.updateMany({
|
||||
where: { userId: req.userId, isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
res.json({ message: 'All notifications marked as read' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/read', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
await prisma.notification.update({
|
||||
where: { id: req.params.id },
|
||||
data: { isRead: true },
|
||||
});
|
||||
res.json({ message: 'Notification marked as read' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
110
server/src/routes/offer.ts
Normal file
110
server/src/routes/offer.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { createOfferSchema, respondOfferSchema } from '../validators/offer.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { type = 'received' } = req.query;
|
||||
const where = type === 'sent'
|
||||
? { buyerId: req.userId }
|
||||
: { sellerId: req.userId };
|
||||
|
||||
const offers = await prisma.offer.findMany({
|
||||
where,
|
||||
include: {
|
||||
listing: { include: { images: { take: 1, orderBy: { order: 'asc' } } } },
|
||||
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(offers);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authenticate, validate(createOfferSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { amount, message, listingId } = req.body;
|
||||
|
||||
const listing = await prisma.listing.findUnique({ where: { id: listingId } });
|
||||
if (!listing) throw new AppError(404, 'Listing not found');
|
||||
if (listing.status !== 'ACTIVE') throw new AppError(400, 'Listing is not active');
|
||||
if (listing.sellerId === req.userId) throw new AppError(400, 'Cannot make offer on your own listing');
|
||||
|
||||
const offer = await prisma.offer.create({
|
||||
data: { amount, message, listingId, buyerId: req.userId!, sellerId: listing.sellerId },
|
||||
include: {
|
||||
listing: { include: { images: { take: 1 } } },
|
||||
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: listing.sellerId,
|
||||
type: 'NEW_OFFER',
|
||||
title: 'New Offer',
|
||||
body: `${offer.buyer.fullName} made an offer of $${amount} for ${listing.title}`,
|
||||
data: { listingId, offerId: offer.id, amount },
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(offer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', authenticate, validate(respondOfferSchema), async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.offer.findUnique({ where: { id: req.params.id }, include: { listing: true } });
|
||||
if (!existing) throw new AppError(404, 'Offer not found');
|
||||
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
if (existing.status !== 'PENDING') throw new AppError(400, 'Offer already responded to');
|
||||
|
||||
const { status, counterAmount } = req.body;
|
||||
|
||||
const offer = await prisma.offer.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status, counterAmount },
|
||||
include: {
|
||||
listing: { include: { images: { take: 1 } } },
|
||||
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (status === 'ACCEPTED') {
|
||||
await prisma.listing.update({ where: { id: existing.listingId }, data: { status: 'SOLD' } });
|
||||
await prisma.offer.updateMany({
|
||||
where: { listingId: existing.listingId, id: { not: existing.id }, status: 'PENDING' },
|
||||
data: { status: 'DECLINED' },
|
||||
});
|
||||
}
|
||||
|
||||
const notificationType = status === 'ACCEPTED' ? 'OFFER_ACCEPTED' : status === 'DECLINED' ? 'OFFER_DECLINED' : 'NEW_OFFER';
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: existing.buyerId,
|
||||
type: notificationType,
|
||||
title: status === 'ACCEPTED' ? 'Offer Accepted' : status === 'DECLINED' ? 'Offer Declined' : 'Counter Offer',
|
||||
body: `Your offer for ${existing.listing.title} was ${status.toLowerCase()}`,
|
||||
data: { offerId: existing.id, listingId: existing.listingId },
|
||||
},
|
||||
});
|
||||
|
||||
res.json(offer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
78
server/src/routes/payment.ts
Normal file
78
server/src/routes/payment.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Router } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
|
||||
|
||||
router.post('/create-intent', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { listingId } = req.body;
|
||||
if (!stripe) throw new AppError(500, 'Stripe not configured');
|
||||
|
||||
const listing = await prisma.listing.findUnique({ where: { id: listingId } });
|
||||
if (!listing) throw new AppError(404, 'Listing not found');
|
||||
if (listing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
const existingPayment = await prisma.payment.findFirst({
|
||||
where: { listingId, status: 'COMPLETED' },
|
||||
});
|
||||
if (existingPayment) throw new AppError(400, 'Listing already paid for');
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: 500,
|
||||
currency: 'usd',
|
||||
metadata: { listingId, userId: req.userId! },
|
||||
});
|
||||
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
userId: req.userId!,
|
||||
listingId,
|
||||
stripePaymentId: paymentIntent.id,
|
||||
amount: 5,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ clientSecret: paymentIntent.client_secret });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/webhook', async (req, res, next) => {
|
||||
try {
|
||||
if (!stripe) throw new AppError(500, 'Stripe not configured');
|
||||
|
||||
const sig = req.headers['stripe-signature'] as string;
|
||||
const event = stripe.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET);
|
||||
|
||||
if (event.type === 'payment_intent.succeeded') {
|
||||
const paymentIntent = event.data.object;
|
||||
const { listingId } = paymentIntent.metadata;
|
||||
|
||||
await prisma.payment.updateMany({
|
||||
where: { stripePaymentId: paymentIntent.id },
|
||||
data: { status: 'COMPLETED' },
|
||||
});
|
||||
|
||||
if (listingId) {
|
||||
await prisma.listing.update({
|
||||
where: { id: listingId },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
68
server/src/routes/user.ts
Normal file
68
server/src/routes/user.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { hashPassword, comparePassword } from '../utils/password.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const userSelect = {
|
||||
id: true, email: true, fullName: true, nickname: true, avatar: true,
|
||||
phone: true, location: true, bio: true, rating: true,
|
||||
showEmail: true, showPhone: true, showLocation: true,
|
||||
createdAt: true,
|
||||
};
|
||||
|
||||
router.get('/profile', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.userId }, select: userSelect });
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/profile', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { fullName, nickname, phone, location, bio, showEmail, showPhone, showLocation } = req.body;
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { fullName, nickname, phone, location, bio, showEmail, showPhone, showLocation },
|
||||
select: userSelect,
|
||||
});
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/password', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const user = await prisma.user.findUnique({ where: { id: req.userId } });
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
|
||||
const valid = await comparePassword(currentPassword, user.passwordHash);
|
||||
if (!valid) throw new AppError(400, 'Current password is incorrect');
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
await prisma.user.update({ where: { id: req.userId }, data: { passwordHash } });
|
||||
|
||||
res.json({ message: 'Password updated' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id }, select: userSelect });
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user