added apis

This commit is contained in:
hardik 2026-08-08 01:13:16 +05:30
parent 146ef59847
commit 8b7a58c333
13 changed files with 445 additions and 4 deletions

View File

@ -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": {

View File

@ -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();
});

View File

@ -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": {

View File

@ -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'] },

View File

@ -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, {

View File

@ -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);

View File

@ -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'));
});
}

View File

@ -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);
});
}

View File

@ -60,6 +60,10 @@ const worker = new Worker<NotificationJobData>(
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.`);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB