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:
delta-lynx-89e8
2026-02-22 07:00:44 -08:00
commit b37b734c82
95 changed files with 10921 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
log: process.env['NODE_ENV'] === 'development' ? ['query', 'error', 'warn'] : ['error'],
});

10
server/src/config/env.ts Normal file
View File

@@ -0,0 +1,10 @@
export const env = {
PORT: parseInt(process.env['PORT'] || '3000', 10),
DATABASE_URL: process.env['DATABASE_URL'] || 'postgresql://marketplace:marketplace_dev@localhost:5432/marketplace',
JWT_SECRET: process.env['JWT_SECRET'] || 'dev-secret-change-in-production',
JWT_REFRESH_SECRET: process.env['JWT_REFRESH_SECRET'] || 'dev-refresh-secret-change-in-production',
STRIPE_SECRET_KEY: process.env['STRIPE_SECRET_KEY'] || '',
STRIPE_WEBHOOK_SECRET: process.env['STRIPE_WEBHOOK_SECRET'] || '',
CLIENT_URL: process.env['CLIENT_URL'] || 'http://localhost:5173',
UPLOAD_DIR: process.env['UPLOAD_DIR'] || './uploads',
};

65
server/src/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import express from 'express';
import { createServer } from 'http';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import path from 'path';
import { fileURLToPath } from 'url';
import { env } from './config/env.js';
import { errorHandler } from './middleware/errorHandler.js';
import { setupSocket } from './socket/index.js';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/user.js';
import listingRoutes from './routes/listing.js';
import offerRoutes from './routes/offer.js';
import chatRoutes from './routes/chat.js';
import notificationRoutes from './routes/notification.js';
import paymentRoutes from './routes/payment.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const httpServer = createServer(app);
// Socket.io
const io = setupSocket(httpServer);
app.set('io', io);
// Middleware
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: env.CLIENT_URL, credentials: true }));
app.use(cookieParser());
// Stripe webhook needs raw body
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
// Rate limiting
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
// Static files
app.use('/uploads', express.static(path.join(__dirname, '..', env.UPLOAD_DIR)));
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/listings', listingRoutes);
app.use('/api/offers', offerRoutes);
app.use('/api/chat', chatRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/payments', paymentRoutes);
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handler
app.use(errorHandler);
httpServer.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});

View File

@@ -0,0 +1,41 @@
import type { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt.js';
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ message: 'Authentication required' });
return;
}
try {
const token = authHeader.slice(7);
const payload = verifyAccessToken(token);
req.userId = payload.userId;
next();
} catch {
res.status(401).json({ message: 'Invalid or expired token' });
}
}
export function optionalAuth(req: Request, _res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
try {
const token = authHeader.slice(7);
const payload = verifyAccessToken(token);
req.userId = payload.userId;
} catch {
// Token invalid, continue without auth
}
}
next();
}

View File

@@ -0,0 +1,22 @@
import type { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
console.error('Error:', err);
if (err instanceof AppError) {
res.status(err.statusCode).json({ message: err.message });
return;
}
res.status(500).json({ message: 'Internal server error' });
}

View File

@@ -0,0 +1,34 @@
import multer from 'multer';
import path from 'path';
import { env } from '../config/env.js';
import fs from 'fs';
const uploadDir = env.UPLOAD_DIR;
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, uploadDir);
},
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, uniqueSuffix + path.extname(file.originalname));
},
});
const fileFilter = (_req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files (JPEG, PNG, WebP, GIF) are allowed'));
}
};
export const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024, files: 6 },
});

View File

@@ -0,0 +1,17 @@
import type { Request, Response, NextFunction } from 'express';
import type { ZodSchema } from 'zod';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
message: 'Validation error',
errors: result.error.flatten().fieldErrors,
});
return;
}
req.body = result.data;
next();
};
}

148
server/src/routes/auth.ts Normal file
View 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
View 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;

View 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;

View 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
View 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;

View 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
View 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;

View File

@@ -0,0 +1,88 @@
import type { Server as HTTPServer } from 'http';
import { Server } from 'socket.io';
import { verifyAccessToken } from '../utils/jwt.js';
import { prisma } from '../config/database.js';
export function setupSocket(httpServer: HTTPServer) {
const io = new Server(httpServer, {
cors: {
origin: process.env['CLIENT_URL'] || 'http://localhost:5173',
credentials: true,
},
});
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Authentication required'));
try {
const payload = verifyAccessToken(token);
socket.data.userId = payload.userId;
next();
} catch {
next(new Error('Invalid token'));
}
});
io.on('connection', (socket) => {
const userId = socket.data.userId;
socket.join(`user:${userId}`);
socket.on('join_conversation', (conversationId: string) => {
socket.join(`conversation:${conversationId}`);
});
socket.on('leave_conversation', (conversationId: string) => {
socket.leave(`conversation:${conversationId}`);
});
socket.on('send_message', async (data: { conversationId: string; content: string; offerAmount?: number }) => {
try {
const message = await prisma.message.create({
data: {
content: data.content,
senderId: userId,
conversationId: data.conversationId,
offerAmount: data.offerAmount,
},
include: { sender: { select: { id: true, fullName: true, avatar: true } } },
});
await prisma.conversation.update({
where: { id: data.conversationId },
data: { updatedAt: new Date() },
});
io.to(`conversation:${data.conversationId}`).emit('new_message', message);
const conversation = await prisma.conversation.findUnique({ where: { id: data.conversationId } });
if (conversation) {
const recipientId = conversation.user1Id === userId ? conversation.user2Id : conversation.user1Id;
io.to(`user:${recipientId}`).emit('message_notification', { conversationId: data.conversationId, message });
}
} catch (error) {
socket.emit('error', { message: 'Failed to send message' });
}
});
socket.on('typing', (conversationId: string) => {
socket.to(`conversation:${conversationId}`).emit('user_typing', { userId, conversationId });
});
socket.on('stop_typing', (conversationId: string) => {
socket.to(`conversation:${conversationId}`).emit('user_stop_typing', { userId, conversationId });
});
socket.on('mark_read', async (conversationId: string) => {
await prisma.message.updateMany({
where: { conversationId, senderId: { not: userId }, isRead: false },
data: { isRead: true },
});
});
socket.on('disconnect', () => {
// Cleanup handled by Socket.io
});
});
return io;
}

18
server/src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,18 @@
import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
export function generateAccessToken(userId: string): string {
return jwt.sign({ userId }, env.JWT_SECRET, { expiresIn: '15m' });
}
export function generateRefreshToken(userId: string): string {
return jwt.sign({ userId }, env.JWT_REFRESH_SECRET, { expiresIn: '7d' });
}
export function verifyAccessToken(token: string): { userId: string } {
return jwt.verify(token, env.JWT_SECRET) as { userId: string };
}
export function verifyRefreshToken(token: string): { userId: string } {
return jwt.verify(token, env.JWT_REFRESH_SECRET) as { userId: string };
}

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const registerSchema = z.object({
fullName: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
});

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const createListingSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(100),
description: z.string().min(10, 'Description must be at least 10 characters').max(2000),
price: z.number().positive('Price must be positive'),
obo: z.boolean().optional().default(false),
category: z.enum(['ELECTRONICS', 'FURNITURE', 'CLOTHING', 'HOME_GARDEN', 'SPORTS', 'BOOKS', 'GAMES', 'VEHICLES', 'OTHER']),
condition: z.enum(['NEW', 'LIKE_NEW', 'GENTLY_USED', 'USED', 'FAIR']),
location: z.string().min(2, 'Location is required'),
});
export const updateListingSchema = createListingSchema.partial();

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const createOfferSchema = z.object({
amount: z.number().positive('Offer amount must be positive'),
message: z.string().max(500).optional(),
listingId: z.string().min(1, 'Listing ID is required'),
});
export const respondOfferSchema = z.object({
status: z.enum(['ACCEPTED', 'DECLINED', 'COUNTERED']),
counterAmount: z.number().positive().optional(),
});