added apis
This commit is contained in:
parent
146ef59847
commit
8b7a58c333
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in New Issue