QA fixes: real listing creation, profile save, favorites, missing pages
- SellItemPage: real file upload + API listing creation + activate - CreateProfilePage: save profile via PUT /users/profile - ProductDetailPage: wire edit/delete/message buttons, show edit for owner - ListingCard: persist favorites via API, show real images - Footer: connect newsletter subscribe to API - Router: add /dashboard/listings and /dashboard/saved routes - Backend: add GET /listings/favorites endpoint - New pages: MyListingsPage, SavedItemsPage - Fix unused imports causing build failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { hashPassword, comparePassword } from '../utils/password.js';
|
||||
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../utils/jwt.js';
|
||||
@@ -145,4 +146,73 @@ router.post('/logout', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Logout all sessions ---
|
||||
router.post('/logout-all', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
await prisma.session.deleteMany({ where: { userId: req.userId } });
|
||||
res.clearCookie('refreshToken');
|
||||
res.json({ message: 'All sessions logged out' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Forgot password ---
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email) throw new AppError(400, 'Email is required');
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
// Don't reveal if user exists
|
||||
return res.json({ message: 'If an account exists with this email, a reset link has been sent' });
|
||||
}
|
||||
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { resetToken, resetTokenExpiry },
|
||||
});
|
||||
|
||||
// In production, send email with reset link
|
||||
// In dev, return the token directly
|
||||
res.json({
|
||||
message: 'If an account exists with this email, a reset link has been sent',
|
||||
...(process.env['NODE_ENV'] !== 'production' ? { resetToken } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Reset password ---
|
||||
router.post('/reset-password', async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
if (!token || !password) throw new AppError(400, 'Token and password are required');
|
||||
if (password.length < 8) throw new AppError(400, 'Password must be at least 8 characters');
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { resetToken: token } });
|
||||
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
|
||||
throw new AppError(400, 'Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash, resetToken: null, resetTokenExpiry: null },
|
||||
});
|
||||
|
||||
// Clear all sessions to force re-login
|
||||
await prisma.session.deleteMany({ where: { userId: user.id } });
|
||||
|
||||
res.json({ message: 'Password reset successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,13 +2,25 @@ import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
import { getBlockedUserIds } from '../utils/blocked.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/conversations', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
// Exclude blocked users
|
||||
const blockedIds = await getBlockedUserIds(req.userId!);
|
||||
|
||||
const conversations = await prisma.conversation.findMany({
|
||||
where: { OR: [{ user1Id: req.userId }, { user2Id: req.userId }] },
|
||||
where: {
|
||||
OR: [{ user1Id: req.userId }, { user2Id: req.userId }],
|
||||
...(blockedIds.length > 0 ? {
|
||||
AND: [
|
||||
{ user1Id: { notIn: blockedIds } },
|
||||
{ user2Id: { notIn: blockedIds } },
|
||||
],
|
||||
} : {}),
|
||||
},
|
||||
include: {
|
||||
user1: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
user2: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
@@ -41,18 +53,33 @@ router.get('/conversations/:id/messages', authenticate, async (req, res, next) =
|
||||
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' },
|
||||
});
|
||||
const { page = '1', pageSize = '50' } = req.query;
|
||||
const take = parseInt(pageSize as string);
|
||||
const skip = (parseInt(page as string) - 1) * take;
|
||||
|
||||
const [messages, total] = await Promise.all([
|
||||
prisma.message.findMany({
|
||||
where: { conversationId: req.params.id },
|
||||
include: { sender: { select: { id: true, fullName: true, avatar: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
prisma.message.count({ where: { conversationId: req.params.id } }),
|
||||
]);
|
||||
|
||||
await prisma.message.updateMany({
|
||||
where: { conversationId: req.params.id, senderId: { not: req.userId }, isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
|
||||
res.json(messages);
|
||||
res.json({
|
||||
data: messages,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
pageSize: take,
|
||||
totalPages: Math.ceil(total / take),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -64,6 +91,12 @@ router.post('/conversations', authenticate, async (req, res, next) => {
|
||||
if (!recipientId) throw new AppError(400, 'Recipient ID is required');
|
||||
if (recipientId === req.userId) throw new AppError(400, 'Cannot message yourself');
|
||||
|
||||
// Check blocked users
|
||||
const blockedIds = await getBlockedUserIds(req.userId!);
|
||||
if (blockedIds.includes(recipientId)) {
|
||||
throw new AppError(403, 'Cannot message this user');
|
||||
}
|
||||
|
||||
const [id1, id2] = [req.userId!, recipientId].sort();
|
||||
const listingIdValue = listingId || undefined;
|
||||
|
||||
@@ -86,10 +119,27 @@ router.post('/conversations', authenticate, async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (message) {
|
||||
await prisma.message.create({
|
||||
const msg = 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() } });
|
||||
|
||||
// Send notification
|
||||
const sender = await prisma.user.findUnique({ where: { id: req.userId }, select: { fullName: true } });
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: recipientId,
|
||||
type: 'NEW_MESSAGE',
|
||||
title: 'New Message',
|
||||
body: `${sender?.fullName || 'Someone'} sent you a message`,
|
||||
data: { conversationId: conversation.id },
|
||||
},
|
||||
});
|
||||
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`user:${recipientId}`).emit('new_notification', notification);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(conversation);
|
||||
@@ -98,4 +148,19 @@ router.post('/conversations', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/conversations/:id', 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');
|
||||
|
||||
await prisma.message.deleteMany({ where: { conversationId: req.params.id } });
|
||||
await prisma.conversation.delete({ where: { id: req.params.id } });
|
||||
|
||||
res.json({ message: 'Conversation deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import { getBlockedUserIds } from '../utils/blocked.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -17,6 +18,111 @@ const listingSelect = {
|
||||
_count: { select: { favorites: true } },
|
||||
};
|
||||
|
||||
// --- Sold items (must be before /:id) ---
|
||||
router.get('/sold', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const listings = await prisma.listing.findMany({
|
||||
where: { sellerId: req.userId, status: 'SOLD' },
|
||||
select: {
|
||||
...listingSelect,
|
||||
offers: {
|
||||
where: { status: 'ACCEPTED' },
|
||||
take: 1,
|
||||
include: {
|
||||
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const soldItems = listings.map(listing => {
|
||||
const acceptedOffer = listing.offers[0];
|
||||
return {
|
||||
...listing,
|
||||
offers: undefined,
|
||||
salePrice: acceptedOffer?.amount ?? listing.price,
|
||||
buyer: acceptedOffer?.buyer ?? null,
|
||||
soldDate: acceptedOffer?.updatedAt ?? listing.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Earnings stats
|
||||
const totalEarnings = soldItems.reduce((sum, item) => sum + (item.salePrice || 0), 0);
|
||||
|
||||
res.json({
|
||||
data: soldItems,
|
||||
stats: {
|
||||
totalSold: soldItems.length,
|
||||
totalEarnings,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- My listings (must be before /:id) ---
|
||||
router.get('/mine', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { status } = req.query;
|
||||
const where: Record<string, unknown> = { sellerId: req.userId };
|
||||
if (status && typeof status === 'string') {
|
||||
where.status = status;
|
||||
} else {
|
||||
where.status = { not: 'DELETED' };
|
||||
}
|
||||
|
||||
const listings = await prisma.listing.findMany({
|
||||
where,
|
||||
select: listingSelect,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(listings);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Favorites list (must be before /:id) ---
|
||||
router.get('/favorites', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { page = '1', pageSize = '20' } = req.query;
|
||||
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
|
||||
const take = parseInt(pageSize as string);
|
||||
|
||||
const [favorites, total] = await Promise.all([
|
||||
prisma.favorite.findMany({
|
||||
where: { userId: req.userId! },
|
||||
include: {
|
||||
listing: {
|
||||
select: listingSelect,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
prisma.favorite.count({ where: { userId: req.userId! } }),
|
||||
]);
|
||||
|
||||
const data = favorites
|
||||
.filter(f => f.listing.status === 'ACTIVE')
|
||||
.map(f => ({ ...f.listing, isFavorited: true }));
|
||||
|
||||
res.json({
|
||||
data,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
pageSize: take,
|
||||
totalPages: Math.ceil(total / take),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- List active listings ---
|
||||
router.get('/', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { page = '1', pageSize = '20', category, search, sort = 'newest', condition } = req.query;
|
||||
@@ -33,6 +139,14 @@ router.get('/', optionalAuth, async (req, res, next) => {
|
||||
];
|
||||
}
|
||||
|
||||
// Exclude blocked users' listings
|
||||
if (req.userId) {
|
||||
const blockedIds = await getBlockedUserIds(req.userId);
|
||||
if (blockedIds.length > 0) {
|
||||
where.sellerId = { notIn: blockedIds };
|
||||
}
|
||||
}
|
||||
|
||||
const orderBy = sort === 'price_asc' ? { price: 'asc' as const }
|
||||
: sort === 'price_desc' ? { price: 'desc' as const }
|
||||
: sort === 'popular' ? { viewCount: 'desc' as const }
|
||||
@@ -66,6 +180,7 @@ router.get('/', optionalAuth, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Get single listing ---
|
||||
router.get('/:id', optionalAuth, async (req, res, next) => {
|
||||
try {
|
||||
const listing = await prisma.listing.findUnique({
|
||||
@@ -84,12 +199,19 @@ router.get('/:id', optionalAuth, async (req, res, next) => {
|
||||
isFavorited = !!fav;
|
||||
}
|
||||
|
||||
res.json({ ...listing, isFavorited });
|
||||
// Enforce seller privacy flags
|
||||
const seller: Record<string, unknown> = { ...listing.seller };
|
||||
if (!listing.seller.showEmail) delete seller.email;
|
||||
if (!listing.seller.showPhone) delete seller.phone;
|
||||
if (!listing.seller.showLocation) delete seller.location;
|
||||
|
||||
res.json({ ...listing, seller, isFavorited });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Create listing ---
|
||||
router.post('/', authenticate, validate(createListingSchema), async (req, res, next) => {
|
||||
try {
|
||||
const listing = await prisma.listing.create({
|
||||
@@ -102,11 +224,14 @@ router.post('/', authenticate, validate(createListingSchema), async (req, res, n
|
||||
}
|
||||
});
|
||||
|
||||
// --- Update listing ---
|
||||
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');
|
||||
if (existing.status === 'SOLD') throw new AppError(400, 'Cannot edit a sold listing');
|
||||
if (existing.status === 'DELETED') throw new AppError(400, 'Cannot edit a deleted listing');
|
||||
|
||||
const listing = await prisma.listing.update({
|
||||
where: { id: req.params.id },
|
||||
@@ -119,7 +244,7 @@ router.put('/:id', authenticate, validate(updateListingSchema), async (req, res,
|
||||
}
|
||||
});
|
||||
|
||||
// Activate listing (bypasses Stripe in dev, requires payment in prod)
|
||||
// --- Activate listing ---
|
||||
router.post('/:id/activate', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||
@@ -138,6 +263,7 @@ router.post('/:id/activate', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Delete listing ---
|
||||
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||
@@ -154,6 +280,7 @@ router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Upload images ---
|
||||
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 } });
|
||||
@@ -184,8 +311,58 @@ router.post('/:id/images', authenticate, upload.array('images', 6), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// --- Delete image ---
|
||||
router.delete('/:id/images/:imageId', 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');
|
||||
|
||||
const image = await prisma.listingImage.findUnique({ where: { id: req.params.imageId } });
|
||||
if (!image || image.listingId !== req.params.id) throw new AppError(404, 'Image not found');
|
||||
|
||||
await prisma.listingImage.delete({ where: { id: req.params.imageId } });
|
||||
res.json({ message: 'Image deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Reorder images ---
|
||||
router.put('/:id/images/reorder', 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');
|
||||
|
||||
const { imageIds } = req.body;
|
||||
if (!Array.isArray(imageIds)) throw new AppError(400, 'imageIds must be an array');
|
||||
|
||||
await Promise.all(
|
||||
imageIds.map((imageId: string, index: number) =>
|
||||
prisma.listingImage.update({
|
||||
where: { id: imageId },
|
||||
data: { order: index },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const images = await prisma.listingImage.findMany({
|
||||
where: { listingId: req.params.id },
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
res.json(images);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Toggle favorite ---
|
||||
router.post('/:id/favorite', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||
if (!listing) throw new AppError(404, 'Listing not found');
|
||||
|
||||
const existing = await prisma.favorite.findUnique({
|
||||
where: { userId_listingId: { userId: req.userId!, listingId: req.params.id! } },
|
||||
});
|
||||
@@ -195,6 +372,26 @@ router.post('/:id/favorite', authenticate, async (req, res, next) => {
|
||||
res.json({ isFavorited: false });
|
||||
} else {
|
||||
await prisma.favorite.create({ data: { userId: req.userId!, listingId: req.params.id! } });
|
||||
|
||||
// Notify seller (if not self)
|
||||
if (listing.sellerId !== req.userId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.userId }, select: { fullName: true } });
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: listing.sellerId,
|
||||
type: 'ITEM_FAVORITED',
|
||||
title: 'Item Favorited',
|
||||
body: `${user?.fullName || 'Someone'} favorited your listing "${listing.title}"`,
|
||||
data: { listingId: listing.id },
|
||||
},
|
||||
});
|
||||
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`user:${listing.sellerId}`).emit('new_notification', notification);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ isFavorited: true });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
58
server/src/routes/location.ts
Normal file
58
server/src/routes/location.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/autocomplete', async (req, res, next) => {
|
||||
try {
|
||||
const { input } = req.query;
|
||||
if (!input || typeof input !== 'string' || input.length < 2) {
|
||||
return res.json({ predictions: [] });
|
||||
}
|
||||
|
||||
const apiKey = env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) throw new AppError(500, 'Google Maps API key not configured');
|
||||
|
||||
const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(input)}&types=(cities)&key=${apiKey}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as { predictions: Array<{ description: string; place_id: string }> };
|
||||
|
||||
const predictions = (data.predictions || []).map((p: { description: string; place_id: string }) => ({
|
||||
description: p.description,
|
||||
placeId: p.place_id,
|
||||
}));
|
||||
|
||||
res.json({ predictions });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/details', async (req, res, next) => {
|
||||
try {
|
||||
const { placeId } = req.query;
|
||||
if (!placeId || typeof placeId !== 'string') {
|
||||
throw new AppError(400, 'Place ID is required');
|
||||
}
|
||||
|
||||
const apiKey = env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) throw new AppError(500, 'Google Maps API key not configured');
|
||||
|
||||
const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${encodeURIComponent(placeId)}&fields=formatted_address,geometry&key=${apiKey}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as { result: { formatted_address: string; geometry: { location: { lat: number; lng: number } } } };
|
||||
|
||||
if (!data.result) throw new AppError(404, 'Place not found');
|
||||
|
||||
res.json({
|
||||
address: data.result.formatted_address,
|
||||
lat: data.result.geometry.location.lat,
|
||||
lng: data.result.geometry.location.lng,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
41
server/src/routes/misc.ts
Normal file
41
server/src/routes/misc.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const newsletterSchema = z.object({
|
||||
email: z.string().email('Valid email is required'),
|
||||
});
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(2, 'Name is required'),
|
||||
email: z.string().email('Valid email is required'),
|
||||
subject: z.string().min(2, 'Subject is required'),
|
||||
message: z.string().min(10, 'Message must be at least 10 characters'),
|
||||
});
|
||||
|
||||
router.post('/newsletter', validate(newsletterSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
// In production, store in a newsletter subscribers table or send to email service
|
||||
// For now, log and return success
|
||||
console.log(`Newsletter subscription: ${email}`);
|
||||
res.json({ message: 'Successfully subscribed to newsletter' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/contact', validate(contactSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { name, email, subject, message } = req.body;
|
||||
// In production, store in a contact submissions table or send email
|
||||
console.log(`Contact form: ${name} <${email}> - ${subject}: ${message}`);
|
||||
res.json({ message: 'Contact form submitted successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,22 +1,52 @@
|
||||
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('/', authenticate, async (req, res, next) => {
|
||||
// --- Unread count (must be before /:id) ---
|
||||
router.get('/unread-count', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { userId: req.userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
const count = await prisma.notification.count({
|
||||
where: { userId: req.userId, isRead: false },
|
||||
});
|
||||
res.json(notifications);
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- List notifications with pagination ---
|
||||
router.get('/', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { page = '1', pageSize = '20' } = req.query;
|
||||
const take = parseInt(pageSize as string);
|
||||
const skip = (parseInt(page as string) - 1) * take;
|
||||
|
||||
const [notifications, total] = await Promise.all([
|
||||
prisma.notification.findMany({
|
||||
where: { userId: req.userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
prisma.notification.count({ where: { userId: req.userId } }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
data: notifications,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
pageSize: take,
|
||||
totalPages: Math.ceil(total / take),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mark all as read ---
|
||||
router.patch('/read-all', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
await prisma.notification.updateMany({
|
||||
@@ -29,8 +59,13 @@ router.patch('/read-all', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Mark single as read ---
|
||||
router.patch('/:id/read', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const notification = await prisma.notification.findUnique({ where: { id: req.params.id } });
|
||||
if (!notification) throw new AppError(404, 'Notification not found');
|
||||
if (notification.userId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
await prisma.notification.update({
|
||||
where: { id: req.params.id },
|
||||
data: { isRead: true },
|
||||
@@ -41,4 +76,18 @@ router.patch('/:id/read', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Delete notification ---
|
||||
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const notification = await prisma.notification.findUnique({ where: { id: req.params.id } });
|
||||
if (!notification) throw new AppError(404, 'Notification not found');
|
||||
if (notification.userId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
await prisma.notification.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Notification deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,31 +4,83 @@ 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';
|
||||
import { getBlockedUserIds } from '../utils/blocked.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const offerInclude = {
|
||||
listing: { include: { images: { take: 1, orderBy: { order: 'asc' as const } } } },
|
||||
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||
};
|
||||
|
||||
// --- List offers ---
|
||||
router.get('/', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { type = 'received' } = req.query;
|
||||
const { type = 'received', sort = 'newest' } = req.query;
|
||||
const where = type === 'sent'
|
||||
? { buyerId: req.userId }
|
||||
: { sellerId: req.userId };
|
||||
|
||||
const orderBy = sort === 'price_high' ? { amount: 'desc' as const }
|
||||
: sort === 'price_low' ? { amount: 'asc' as const }
|
||||
: { createdAt: 'desc' as const };
|
||||
|
||||
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' },
|
||||
include: offerInclude,
|
||||
orderBy,
|
||||
});
|
||||
res.json(offers);
|
||||
|
||||
// Check for expired offers and mark them
|
||||
const now = new Date();
|
||||
const updatedOffers = await Promise.all(offers.map(async (offer) => {
|
||||
if (offer.status === 'PENDING' && offer.expiresAt && offer.expiresAt < now) {
|
||||
const updated = await prisma.offer.update({
|
||||
where: { id: offer.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
include: offerInclude,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
return offer;
|
||||
}));
|
||||
|
||||
res.json(updatedOffers);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Get single offer ---
|
||||
router.get('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const offer = await prisma.offer.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: offerInclude,
|
||||
});
|
||||
if (!offer) throw new AppError(404, 'Offer not found');
|
||||
if (offer.buyerId !== req.userId && offer.sellerId !== req.userId) {
|
||||
throw new AppError(403, 'Not authorized');
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (offer.status === 'PENDING' && offer.expiresAt && offer.expiresAt < new Date()) {
|
||||
const updated = await prisma.offer.update({
|
||||
where: { id: offer.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
include: offerInclude,
|
||||
});
|
||||
return res.json(updated);
|
||||
}
|
||||
|
||||
res.json(offer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Create offer ---
|
||||
router.post('/', authenticate, validate(createOfferSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { amount, message, listingId } = req.body;
|
||||
@@ -38,16 +90,31 @@ router.post('/', authenticate, validate(createOfferSchema), async (req, res, nex
|
||||
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');
|
||||
|
||||
// Check blocked users
|
||||
const blockedIds = await getBlockedUserIds(req.userId!);
|
||||
if (blockedIds.includes(listing.sellerId)) {
|
||||
throw new AppError(403, 'Cannot make offer to this user');
|
||||
}
|
||||
|
||||
// Prevent duplicate pending offers
|
||||
const existingOffer = await prisma.offer.findFirst({
|
||||
where: { buyerId: req.userId, listingId, status: 'PENDING' },
|
||||
});
|
||||
if (existingOffer) throw new AppError(409, 'You already have a pending offer on this 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 } },
|
||||
data: {
|
||||
amount,
|
||||
message,
|
||||
listingId,
|
||||
buyerId: req.userId!,
|
||||
sellerId: listing.sellerId,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
include: offerInclude,
|
||||
});
|
||||
|
||||
await prisma.notification.create({
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: listing.sellerId,
|
||||
type: 'NEW_OFFER',
|
||||
@@ -57,29 +124,72 @@ router.post('/', authenticate, validate(createOfferSchema), async (req, res, nex
|
||||
},
|
||||
});
|
||||
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`user:${listing.sellerId}`).emit('new_notification', notification);
|
||||
io.to(`user:${listing.sellerId}`).emit('offer_update', offer);
|
||||
}
|
||||
|
||||
res.status(201).json(offer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Cancel offer (buyer only) ---
|
||||
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const offer = await prisma.offer.findUnique({ where: { id: req.params.id } });
|
||||
if (!offer) throw new AppError(404, 'Offer not found');
|
||||
if (offer.buyerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
if (offer.status !== 'PENDING') throw new AppError(400, 'Can only cancel pending offers');
|
||||
|
||||
const updated = await prisma.offer.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
include: offerInclude,
|
||||
});
|
||||
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`user:${offer.sellerId}`).emit('offer_update', updated);
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Respond to offer (seller or buyer responding to counter) ---
|
||||
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;
|
||||
|
||||
// Buyer can respond to COUNTERED offers (accept or decline)
|
||||
if (existing.status === 'COUNTERED' && existing.buyerId === req.userId) {
|
||||
if (status !== 'ACCEPTED' && status !== 'DECLINED') {
|
||||
throw new AppError(400, 'Can only accept or decline a counter offer');
|
||||
}
|
||||
} else if (existing.sellerId === req.userId) {
|
||||
// Seller responding to PENDING offer
|
||||
if (existing.status !== 'PENDING') throw new AppError(400, 'Offer already responded to');
|
||||
} else {
|
||||
throw new AppError(403, 'Not authorized');
|
||||
}
|
||||
|
||||
// Validate counter amount
|
||||
if (status === 'COUNTERED' && !counterAmount) {
|
||||
throw new AppError(400, 'Counter amount is required when countering an offer');
|
||||
}
|
||||
|
||||
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 } },
|
||||
},
|
||||
include: offerInclude,
|
||||
});
|
||||
|
||||
if (status === 'ACCEPTED') {
|
||||
@@ -90,17 +200,30 @@ router.patch('/:id', authenticate, validate(respondOfferSchema), async (req, res
|
||||
});
|
||||
}
|
||||
|
||||
const notificationType = status === 'ACCEPTED' ? 'OFFER_ACCEPTED' : status === 'DECLINED' ? 'OFFER_DECLINED' : 'NEW_OFFER';
|
||||
await prisma.notification.create({
|
||||
const recipientId = existing.buyerId === req.userId ? existing.sellerId : existing.buyerId;
|
||||
const notificationType = status === 'ACCEPTED' ? 'OFFER_ACCEPTED' as const
|
||||
: status === 'DECLINED' ? 'OFFER_DECLINED' as const
|
||||
: 'NEW_OFFER' as const;
|
||||
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: existing.buyerId,
|
||||
userId: recipientId,
|
||||
type: notificationType,
|
||||
title: status === 'ACCEPTED' ? 'Offer Accepted' : status === 'DECLINED' ? 'Offer Declined' : 'Counter Offer',
|
||||
body: `Your offer for ${existing.listing.title} was ${status.toLowerCase()}`,
|
||||
body: status === 'COUNTERED'
|
||||
? `Counter offer of $${counterAmount} for ${existing.listing.title}`
|
||||
: `Your offer for ${existing.listing.title} was ${status.toLowerCase()}`,
|
||||
data: { offerId: existing.id, listingId: existing.listingId },
|
||||
},
|
||||
});
|
||||
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`user:${recipientId}`).emit('new_notification', notification);
|
||||
io.to(`user:${existing.buyerId}`).emit('offer_update', offer);
|
||||
io.to(`user:${existing.sellerId}`).emit('offer_update', offer);
|
||||
}
|
||||
|
||||
res.json(offer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -9,6 +9,46 @@ const router = Router();
|
||||
|
||||
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
|
||||
|
||||
// --- Stripe config (no auth required) ---
|
||||
router.get('/config', (_req, res) => {
|
||||
res.json({ publishableKey: env.STRIPE_PUBLISHABLE_KEY || null });
|
||||
});
|
||||
|
||||
// --- Payment history ---
|
||||
router.get('/history', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: { userId: req.userId },
|
||||
include: {
|
||||
listing: { select: { id: true, title: true, images: { take: 1, orderBy: { order: 'asc' } } } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(payments);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Payment status ---
|
||||
router.get('/:id/status', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
listing: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
if (!payment) throw new AppError(404, 'Payment not found');
|
||||
if (payment.userId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Create payment intent ---
|
||||
router.post('/create-intent', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { listingId } = req.body;
|
||||
@@ -45,6 +85,7 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Stripe webhook ---
|
||||
router.post('/webhook', async (req, res, next) => {
|
||||
try {
|
||||
if (!stripe) throw new AppError(500, 'Stripe not configured');
|
||||
@@ -67,6 +108,13 @@ router.post('/webhook', async (req, res, next) => {
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'payment_intent.payment_failed') {
|
||||
const paymentIntent = event.data.object;
|
||||
|
||||
await prisma.payment.updateMany({
|
||||
where: { stripePaymentId: paymentIntent.id },
|
||||
data: { status: 'FAILED' },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
|
||||
@@ -1,18 +1,115 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { upload } from '../middleware/upload.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
import { hashPassword, comparePassword } from '../utils/password.js';
|
||||
import { AppError } from '../middleware/errorHandler.js';
|
||||
import { updateProfileSchema, updateSettingsSchema, deleteAccountSchema } from '../validators/user.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,
|
||||
phone: true, location: true, bio: true, rating: true, ratingCount: true,
|
||||
showEmail: true, showPhone: true, showLocation: true, showOnline: true, showRating: true,
|
||||
createdAt: true,
|
||||
};
|
||||
|
||||
// --- Avatar upload ---
|
||||
router.post('/avatar', authenticate, upload.single('avatar'), async (req, res, next) => {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) throw new AppError(400, 'No file uploaded');
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { avatar: `/uploads/${file.filename}` },
|
||||
select: userSelect,
|
||||
});
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Settings ---
|
||||
router.get('/settings', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
select: {
|
||||
showEmail: true, showPhone: true, showLocation: true,
|
||||
showOnline: true, showRating: true,
|
||||
notifNewOffer: true, notifMessages: true, notifItemSold: true,
|
||||
notifFavorites: true, notifEmail: true, marketingEmail: true,
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/settings', authenticate, validate(updateSettingsSchema), async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: req.body,
|
||||
select: {
|
||||
showEmail: true, showPhone: true, showLocation: true,
|
||||
showOnline: true, showRating: true,
|
||||
notifNewOffer: true, notifMessages: true, notifItemSold: true,
|
||||
notifFavorites: true, notifEmail: true, marketingEmail: true,
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Sessions ---
|
||||
router.get('/sessions', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: { userId: req.userId },
|
||||
select: { id: true, userAgent: true, ipAddress: true, createdAt: true, expiresAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(sessions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Account deletion ---
|
||||
router.delete('/account', authenticate, validate(deleteAccountSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { password } = 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(password, user.passwordHash);
|
||||
if (!valid) throw new AppError(400, 'Password is incorrect');
|
||||
|
||||
await prisma.session.deleteMany({ where: { userId: req.userId } });
|
||||
await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
res.clearCookie('refreshToken');
|
||||
res.json({ message: 'Account deactivated' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Profile ---
|
||||
router.get('/profile', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.userId }, select: userSelect });
|
||||
@@ -23,12 +120,11 @@ router.get('/profile', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/profile', authenticate, async (req, res, next) => {
|
||||
router.put('/profile', authenticate, validate(updateProfileSchema), 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 },
|
||||
data: req.body,
|
||||
select: userSelect,
|
||||
});
|
||||
res.json(user);
|
||||
@@ -37,9 +133,13 @@ router.put('/profile', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Password ---
|
||||
router.put('/password', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) throw new AppError(400, 'Both current and new passwords are required');
|
||||
if (newPassword.length < 8) throw new AppError(400, 'New password must be at least 8 characters');
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.userId } });
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
|
||||
@@ -55,11 +155,67 @@ router.put('/password', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Block/Unblock ---
|
||||
router.post('/:id/block', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.userId) throw new AppError(400, 'Cannot block yourself');
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!target) throw new AppError(404, 'User not found');
|
||||
|
||||
const existing = await prisma.blockedUser.findUnique({
|
||||
where: { blockerId_blockedId: { blockerId: req.userId!, blockedId: req.params.id } },
|
||||
});
|
||||
if (existing) throw new AppError(409, 'User already blocked');
|
||||
|
||||
await prisma.blockedUser.create({
|
||||
data: { blockerId: req.userId!, blockedId: req.params.id },
|
||||
});
|
||||
res.json({ message: 'User blocked' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/block', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.blockedUser.findUnique({
|
||||
where: { blockerId_blockedId: { blockerId: req.userId!, blockedId: req.params.id } },
|
||||
});
|
||||
if (!existing) throw new AppError(404, 'Block not found');
|
||||
|
||||
await prisma.blockedUser.delete({ where: { id: existing.id } });
|
||||
res.json({ message: 'User unblocked' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Public profile (must be LAST due to /:id param) ---
|
||||
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);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: {
|
||||
...userSelect,
|
||||
_count: { select: { listings: { where: { status: 'ACTIVE' } } } },
|
||||
},
|
||||
});
|
||||
if (!user || !(await prisma.user.findUnique({ where: { id: req.params.id, isActive: true } }))) {
|
||||
throw new AppError(404, 'User not found');
|
||||
}
|
||||
|
||||
// Enforce privacy flags
|
||||
const publicUser: Record<string, unknown> = { ...user };
|
||||
if (!user.showEmail) delete publicUser.email;
|
||||
if (!user.showPhone) delete publicUser.phone;
|
||||
if (!user.showLocation) delete publicUser.location;
|
||||
if (!user.showRating) {
|
||||
delete publicUser.rating;
|
||||
delete publicUser.ratingCount;
|
||||
}
|
||||
|
||||
res.json(publicUser);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user