diff --git a/packages/database/package.json b/packages/database/package.json index 3be3e42..6c2784e 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -2,6 +2,7 @@ "name": "@luxe/database", "version": "1.0.0", "private": true, + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/packages/database/prisma/create-custom-admin.ts b/packages/database/prisma/create-custom-admin.ts new file mode 100644 index 0000000..26f9333 --- /dev/null +++ b/packages/database/prisma/create-custom-admin.ts @@ -0,0 +1,50 @@ +import { PrismaClient, RoleType } from '@prisma/client'; +import * as argon2 from 'argon2'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('🌱 Creating Custom Super Admin User...'); + + const passwordHash = await argon2.hash('Password123!'); + const user = await prisma.user.upsert({ + where: { email: 'emperor@luxe.com' }, + update: { + role: RoleType.SUPER_ADMIN, + }, + create: { + email: 'emperor@luxe.com', + username: 'luxe_emperor', + passwordHash: passwordHash, + role: RoleType.SUPER_ADMIN, + status: 'ACTIVE', + emailVerifiedAt: new Date(), + }, + }); + + // Create Wallet for Custom Super Admin + await prisma.wallet.upsert({ + where: { userId: user.id }, + update: {}, + create: { + userId: user.id, + balance: 500000.0, + credits: 20000.0, + currency: 'USD', + }, + }); + + console.log('✅ Custom Super Admin created successfully!'); + console.log('📧 Email: emperor@luxe.com'); + console.log('👤 Username: luxe_emperor'); + console.log('🔑 Password: Password123!'); +} + +main() + .catch((e) => { + console.error('❌ Creation failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 83df59c..b2dcca0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,6 +2,7 @@ "name": "@luxe/shared", "version": "1.0.0", "private": true, + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 253ab59..61a6aca 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1,8 +1,33 @@ import { spawn } from 'node:child_process'; import path from 'node:path'; +import fs from 'node:fs'; console.log('🚀 Starting Luxe Monorepo Development Environment...'); +// Load .env file manually into process.env +const envPath = path.resolve(process.cwd(), '.env'); +if (fs.existsSync(envPath)) { + const envConfig = fs.readFileSync(envPath, 'utf8'); + envConfig.split(/\r?\n/).forEach((line) => { + // Skip comments and empty lines + if (line.trim().startsWith('#') || !line.includes('=')) return; + const index = line.indexOf('='); + const key = line.substring(0, index).trim(); + let val = line.substring(index + 1).trim(); + + // Unquote value if quoted + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.substring(1, val.length - 1); + } + + // Set environment variable if not already set by system + if (key && !process.env[key]) { + process.env[key] = val; + } + }); + console.log('📝 Loaded environment variables from .env'); +} + const services = [ { name: 'Gateway', command: 'npx', args: ['tsx', 'watch', 'services/gateway/src/index.ts'] }, { name: 'Notifications', command: 'npx', args: ['tsx', 'watch', 'services/notification-service/src/index.ts'] }, diff --git a/services/gateway/src/index.ts b/services/gateway/src/index.ts index f5893ea..4c7e966 100644 --- a/services/gateway/src/index.ts +++ b/services/gateway/src/index.ts @@ -17,6 +17,8 @@ import { blogRoutes } from './routes/blog.routes.js'; import { adminRoutes } from './routes/admin.routes.js'; import { mediaRoutes } from './routes/media.routes.js'; +import multipart from '@fastify/multipart'; + const PORT = Number(process.env.PORT) || 3000; const HOST = process.env.HOST || '0.0.0.0'; @@ -40,6 +42,12 @@ async function bootstrap() { await app.register(cookie, { secret: process.env.JWT_SECRET || 'luxe_cookie_secret_key_2026', }); + await app.register(multipart, { + limits: { + files: 10, + fileSize: 10 * 1024 * 1024, // 10 MB per image + }, + }); // 2. Swagger Documentation Setup await app.register(swagger, { diff --git a/services/gateway/src/routes/client.routes.ts b/services/gateway/src/routes/client.routes.ts index 1b62ad8..7fb9528 100644 --- a/services/gateway/src/routes/client.routes.ts +++ b/services/gateway/src/routes/client.routes.ts @@ -24,7 +24,7 @@ export async function clientRoutes(fastify: FastifyInstance) { const userPayload = await authenticateClient(request, reply); if (!userPayload) return; - const advertiser = await prisma.advertiserProfile.findUnique({ + let advertiser = await prisma.advertiserProfile.findUnique({ where: { userId: userPayload.userId }, include: { listings: { include: { category: true, city: true, gallery: true } }, @@ -33,7 +33,16 @@ export async function clientRoutes(fastify: FastifyInstance) { }); if (!advertiser) { - return reply.status(404).send(errorResponse('Advertiser profile not found')); + advertiser = await prisma.advertiserProfile.create({ + data: { + userId: userPayload.userId, + type: 'INDIVIDUAL', + }, + include: { + listings: { include: { category: true, city: true, gallery: true } }, + verifications: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); } return reply.send(successResponse(advertiser)); @@ -85,6 +94,79 @@ export async function clientRoutes(fastify: FastifyInstance) { return reply.status(201).send(successResponse(listing, 'Listing created successfully')); }); + // PUT /api/v1/clients/listings/:id — update existing listing + fastify.put('/listings/:id', async (request: FastifyRequest, reply: FastifyReply) => { + const userPayload = await authenticateClient(request, reply); + if (!userPayload) return; + + const { id } = request.params as any; + const { title, description, categoryId, cityId, areaId, price, currency, age, height, tagline, languages, tags } = request.body as any; + + const listing = await prisma.listing.findUnique({ where: { id }, include: { advertiser: true } }); + if (!listing) return reply.status(404).send(errorResponse('Listing not found')); + if (listing.advertiser.userId !== userPayload.userId) return reply.status(403).send(errorResponse('Not authorized')); + + const updated = await prisma.listing.update({ + where: { id }, + data: { + ...(title && { title }), + ...(description && { description }), + ...(categoryId && { categoryId }), + ...(cityId && { cityId }), + ...(areaId && { areaId }), + ...(price !== undefined && { price }), + ...(currency && { currency }), + ...(age !== undefined && { age }), + ...(height && { height }), + ...(tagline && { tagline }), + ...(languages && { languages }), + ...(tags && { tags }), + }, + }); + + return reply.send(successResponse(updated, 'Listing updated successfully')); + }); + + // PATCH /api/v1/clients/listings/:id/gallery/:galleryItemId/primary + fastify.patch('/listings/:id/gallery/:galleryItemId/primary', async (request: FastifyRequest, reply: FastifyReply) => { + const userPayload = await authenticateClient(request, reply); + if (!userPayload) return; + + const { id, galleryItemId } = request.params as any; + + // Verify ownership + const listing = await prisma.listing.findUnique({ where: { id }, include: { advertiser: true } }); + if (!listing) return reply.status(404).send(errorResponse('Listing not found')); + if (listing.advertiser.userId !== userPayload.userId) return reply.status(403).send(errorResponse('Not authorized')); + + // Unset all primary flags for this listing, then set the new one + await prisma.listingGallery.updateMany({ where: { listingId: id }, data: { isPrimary: false } }); + const updated = await prisma.listingGallery.update({ where: { id: galleryItemId }, data: { isPrimary: true } }); + + return reply.send(successResponse(updated, 'Primary image updated')); + }); + + // DELETE /api/v1/clients/gallery/:galleryItemId + fastify.delete('/gallery/:galleryItemId', async (request: FastifyRequest, reply: FastifyReply) => { + const userPayload = await authenticateClient(request, reply); + if (!userPayload) return; + + const { galleryItemId } = request.params as any; + + // Verify ownership via listing relation + const galleryItem = await prisma.listingGallery.findUnique({ + where: { id: galleryItemId }, + include: { listing: { include: { advertiser: true } } } + }); + + if (!galleryItem) return reply.status(404).send(errorResponse('Gallery item not found')); + if (galleryItem.listing.advertiser.userId !== userPayload.userId) return reply.status(403).send(errorResponse('Not authorized')); + + await prisma.listingGallery.delete({ where: { id: galleryItemId } }); + + return reply.send(successResponse(null, 'Image deleted successfully')); + }); + // POST /api/v1/clients/my-profile/gallery fastify.post('/my-profile/gallery', async (request: FastifyRequest, reply: FastifyReply) => { const userPayload = await authenticateClient(request, reply); diff --git a/services/gateway/src/routes/listing.routes.ts b/services/gateway/src/routes/listing.routes.ts index ded1f01..101b862 100644 --- a/services/gateway/src/routes/listing.routes.ts +++ b/services/gateway/src/routes/listing.routes.ts @@ -1,6 +1,23 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { prisma } from '@luxe/database'; -import { successResponse, paginatedResponse, errorResponse } from '@luxe/shared'; +import { successResponse, paginatedResponse, errorResponse, verifyToken } from '@luxe/shared'; + +async function authenticateUser(request: FastifyRequest, reply: FastifyReply) { + const authHeader = request.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + reply.status(401).send(errorResponse('Authorization header missing')); + return null; + } + try { + const token = authHeader.split(' ')[1]; + const decoded = verifyToken(token) as any; + return decoded; + } catch (err) { + reply.status(401).send(errorResponse('Invalid or expired token')); + return null; + } +} + export async function listingRoutes(fastify: FastifyInstance) { // GET /api/v1/search/profiles @@ -66,7 +83,7 @@ export async function listingRoutes(fastify: FastifyInstance) { category: { select: { name: true, slug: true } }, city: { select: { name: true, slug: true } }, area: { select: { name: true, slug: true } }, - gallery: { where: { isPrimary: true }, take: 1 }, + gallery: { orderBy: { isPrimary: 'desc' }, take: 5 }, advertiser: { select: { rating: true, reviewCount: true, verificationStatus: true } }, }, orderBy: { [sortBy]: 'desc' }, @@ -140,4 +157,141 @@ export async function listingRoutes(fastify: FastifyInstance) { return reply.send(successResponse(categories)); }); + + // GET /api/v1/profiles/:slug/reviews + fastify.get('/profiles/:slug/reviews', { + schema: { + description: 'Get all reviews for a companion profile', + tags: ['Reviews'], + params: { + type: 'object', + properties: { + slug: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + rating: { type: 'number' }, + comment: { type: 'string' }, + createdAt: { type: 'string' }, + user: { + type: 'object', + properties: { + username: { type: 'string' }, + avatar: { type: 'string', nullable: true } + } + } + } + } + } + } + } + } + } + }, async (request: FastifyRequest, reply: FastifyReply) => { + const { slug } = request.params as any; + + const listing = await prisma.listing.findUnique({ + where: { slug }, + }); + + if (!listing) { + return reply.status(404).send(errorResponse('Listing profile not found')); + } + + const reviews = await prisma.review.findMany({ + where: { listingId: listing.id }, + include: { + user: { select: { username: true, avatar: true } } + }, + orderBy: { createdAt: 'desc' } + }); + + return reply.send(successResponse(reviews)); + }); + + // POST /api/v1/profiles/:slug/reviews + fastify.post('/profiles/:slug/reviews', { + schema: { + description: 'Submit a new review for a companion profile', + tags: ['Reviews'], + security: [{ bearerAuth: [] }], + params: { + type: 'object', + properties: { + slug: { type: 'string' } + } + }, + body: { + type: 'object', + required: ['rating', 'comment'], + properties: { + rating: { type: 'number', minimum: 1, maximum: 5 }, + comment: { type: 'string', minLength: 10 } + } + } + } + }, async (request: FastifyRequest, reply: FastifyReply) => { + const { slug } = request.params as any; + const userPayload = await authenticateUser(request, reply); + if (!userPayload) return; + + const { rating, comment } = request.body as any; + + if (!rating || rating < 1 || rating > 5) { + return reply.status(400).send(errorResponse('Rating must be between 1 and 5')); + } + + if (!comment || comment.length < 10) { + return reply.status(400).send(errorResponse('Comment must be at least 10 characters long')); + } + + const listing = await prisma.listing.findUnique({ + where: { slug }, + }); + + if (!listing) { + return reply.status(404).send(errorResponse('Listing profile not found')); + } + + const review = await prisma.review.create({ + data: { + listingId: listing.id, + userId: userPayload.userId, + rating: parseInt(rating), + comment, + }, + include: { + user: { select: { username: true, avatar: true } } + } + }); + + // Update advertiser profile rating & reviewCount aggregates + const advertiserId = listing.profileId; + const advertiserReviews = await prisma.review.findMany({ + where: { listing: { profileId: advertiserId } } + }); + + const totalReviews = advertiserReviews.length; + const averageRating = advertiserReviews.reduce((sum, r) => sum + r.rating, 0) / totalReviews; + + await prisma.advertiserProfile.update({ + where: { id: advertiserId }, + data: { + rating: parseFloat(averageRating.toFixed(1)), + reviewCount: totalReviews + } + }); + + return reply.status(201).send(successResponse(review, 'Review submitted successfully')); + }); } diff --git a/services/gateway/src/routes/media.routes.ts b/services/gateway/src/routes/media.routes.ts index d5244ad..471e9a8 100644 --- a/services/gateway/src/routes/media.routes.ts +++ b/services/gateway/src/routes/media.routes.ts @@ -1,5 +1,12 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { verifyToken, successResponse, errorResponse } from '@luxe/shared'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream'; +import { promisify } from 'node:util'; +import { randomUUID } from 'node:crypto'; + +const pump = promisify(pipeline); export async function mediaRoutes(fastify: FastifyInstance) { // POST /api/v1/media/presigned-url @@ -31,4 +38,108 @@ export async function mediaRoutes(fastify: FastifyInstance) { return reply.status(401).send(errorResponse('Invalid token')); } }); + + // POST /api/v1/media/upload + fastify.post('/upload', async (request: FastifyRequest, reply: FastifyReply) => { + const authHeader = request.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return reply.status(401).send(errorResponse('Bearer token required')); + } + + try { + verifyToken(authHeader.split(' ')[1]); + } catch (err) { + return reply.status(401).send(errorResponse('Invalid token')); + } + + if (!request.isMultipart()) { + return reply.status(415).send(errorResponse('Content-Type must be multipart/form-data')); + } + + const uploadDir = path.join(process.cwd(), 'uploads'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + const uploadedFiles: Array<{ url: string; filename: string; mimeType: string }> = []; + const savedPaths: string[] = []; + const removeSavedFiles = () => { + for (const savedPath of savedPaths) { + if (fs.existsSync(savedPath)) fs.unlinkSync(savedPath); + } + }; + const host = request.headers.host || 'localhost:3000'; + const forwardedProtocol = request.headers['x-forwarded-proto']; + const protocol = Array.isArray(forwardedProtocol) + ? forwardedProtocol[0] + : (forwardedProtocol || 'http').split(',')[0].trim(); + + try { + for await (const data of request.files()) { + if (!data.mimetype.startsWith('image/')) { + data.file.resume(); + removeSavedFiles(); + return reply.status(400).send(errorResponse('Only image files are allowed')); + } + + // Drop path segments and keep the filename safe for use on disk and in URLs. + const originalName = path.basename(data.filename).replace(/[^a-zA-Z0-9._-]/g, '_'); + const filename = `${Date.now()}-${randomUUID()}-${originalName || 'image'}`; + const filePath = path.join(uploadDir, filename); + + await pump(data.file, fs.createWriteStream(filePath)); + savedPaths.push(filePath); + + if (data.file.truncated) { + removeSavedFiles(); + return reply.status(413).send(errorResponse('Each image must be 10 MB or smaller')); + } + + uploadedFiles.push({ + url: `${protocol}://${host}/api/v1/media/uploads/${filename}`, + filename, + mimeType: data.mimetype, + }); + } + } catch (err) { + removeSavedFiles(); + throw err; + } + + if (uploadedFiles.length === 0) { + return reply.status(400).send(errorResponse('At least one image file is required')); + } + + return reply.send(successResponse({ + // `url` is retained for existing clients that upload one image. + url: uploadedFiles[0].url, + files: uploadedFiles, + }, `${uploadedFiles.length} image(s) uploaded successfully`)); + }); + + // GET /api/v1/media/uploads/:filename + fastify.get('/uploads/:filename', async (request: FastifyRequest, reply: FastifyReply) => { + const { filename } = request.params as any; + const filePath = path.join(process.cwd(), 'uploads', filename); + + if (!fs.existsSync(filePath)) { + return reply.status(404).send(errorResponse('File not found')); + } + + const stream = fs.createReadStream(filePath); + + if (filename.endsWith('.png')) { + reply.header('Content-Type', 'image/png'); + } else if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) { + reply.header('Content-Type', 'image/jpeg'); + } else if (filename.endsWith('.webp')) { + reply.header('Content-Type', 'image/webp'); + } else if (filename.endsWith('.gif')) { + reply.header('Content-Type', 'image/gif'); + } else { + reply.header('Content-Type', 'application/octet-stream'); + } + + return reply.send(stream); + }); } diff --git a/services/notification-service/src/index.ts b/services/notification-service/src/index.ts index 785fc24..976e320 100644 --- a/services/notification-service/src/index.ts +++ b/services/notification-service/src/index.ts @@ -60,6 +60,10 @@ const worker = new Worker( connection: { host: REDIS_HOST, port: REDIS_PORT, + retryStrategy: (times: number) => { + // Slow down retries to once every 30 seconds to avoid flooding console logs + return 30000; + }, }, concurrency: 5, } @@ -72,3 +76,8 @@ worker.on('completed', (job) => { worker.on('failed', (job, err) => { logger.error(`Job ${job?.id} failed with error: ${err.message}`); }); + +worker.on('error', (err) => { + logger.warn(`Queue worker connection warning: Redis is offline (${err.message}). Worker will retry connecting automatically.`); +}); + diff --git a/uploads/1786130428280-89899b05c1eb44fa9fcb9f8af588f033_lg.webp b/uploads/1786130428280-89899b05c1eb44fa9fcb9f8af588f033_lg.webp new file mode 100644 index 0000000..3cfd604 Binary files /dev/null and b/uploads/1786130428280-89899b05c1eb44fa9fcb9f8af588f033_lg.webp differ diff --git a/uploads/1786130471762-74ddeed855b0495fb46e158a22086136_lg.webp b/uploads/1786130471762-74ddeed855b0495fb46e158a22086136_lg.webp new file mode 100644 index 0000000..a3ce1df Binary files /dev/null and b/uploads/1786130471762-74ddeed855b0495fb46e158a22086136_lg.webp differ diff --git a/uploads/1786131218476-ff0a530c-e09d-4d0c-8ace-f7eb296f3473-74ddeed855b0495fb46e158a22086136_lg.webp b/uploads/1786131218476-ff0a530c-e09d-4d0c-8ace-f7eb296f3473-74ddeed855b0495fb46e158a22086136_lg.webp new file mode 100644 index 0000000..a3ce1df Binary files /dev/null and b/uploads/1786131218476-ff0a530c-e09d-4d0c-8ace-f7eb296f3473-74ddeed855b0495fb46e158a22086136_lg.webp differ diff --git a/uploads/1786131415505-55164ea9-b265-4aec-9434-3a8056d52002-89899b05c1eb44fa9fcb9f8af588f033_lg.webp b/uploads/1786131415505-55164ea9-b265-4aec-9434-3a8056d52002-89899b05c1eb44fa9fcb9f8af588f033_lg.webp new file mode 100644 index 0000000..3cfd604 Binary files /dev/null and b/uploads/1786131415505-55164ea9-b265-4aec-9434-3a8056d52002-89899b05c1eb44fa9fcb9f8af588f033_lg.webp differ