added apis
This commit is contained in:
parent
146ef59847
commit
8b7a58c333
|
|
@ -2,6 +2,7 @@
|
||||||
"name": "@luxe/database",
|
"name": "@luxe/database",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"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",
|
"name": "@luxe/shared",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,33 @@
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
console.log('🚀 Starting Luxe Monorepo Development Environment...');
|
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 = [
|
const services = [
|
||||||
{ name: 'Gateway', command: 'npx', args: ['tsx', 'watch', 'services/gateway/src/index.ts'] },
|
{ 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'] },
|
{ 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 { adminRoutes } from './routes/admin.routes.js';
|
||||||
import { mediaRoutes } from './routes/media.routes.js';
|
import { mediaRoutes } from './routes/media.routes.js';
|
||||||
|
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
|
@ -40,6 +42,12 @@ async function bootstrap() {
|
||||||
await app.register(cookie, {
|
await app.register(cookie, {
|
||||||
secret: process.env.JWT_SECRET || 'luxe_cookie_secret_key_2026',
|
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
|
// 2. Swagger Documentation Setup
|
||||||
await app.register(swagger, {
|
await app.register(swagger, {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export async function clientRoutes(fastify: FastifyInstance) {
|
||||||
const userPayload = await authenticateClient(request, reply);
|
const userPayload = await authenticateClient(request, reply);
|
||||||
if (!userPayload) return;
|
if (!userPayload) return;
|
||||||
|
|
||||||
const advertiser = await prisma.advertiserProfile.findUnique({
|
let advertiser = await prisma.advertiserProfile.findUnique({
|
||||||
where: { userId: userPayload.userId },
|
where: { userId: userPayload.userId },
|
||||||
include: {
|
include: {
|
||||||
listings: { include: { category: true, city: true, gallery: true } },
|
listings: { include: { category: true, city: true, gallery: true } },
|
||||||
|
|
@ -33,7 +33,16 @@ export async function clientRoutes(fastify: FastifyInstance) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!advertiser) {
|
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));
|
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'));
|
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
|
// POST /api/v1/clients/my-profile/gallery
|
||||||
fastify.post('/my-profile/gallery', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.post('/my-profile/gallery', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const userPayload = await authenticateClient(request, reply);
|
const userPayload = await authenticateClient(request, reply);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { prisma } from '@luxe/database';
|
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) {
|
export async function listingRoutes(fastify: FastifyInstance) {
|
||||||
// GET /api/v1/search/profiles
|
// GET /api/v1/search/profiles
|
||||||
|
|
@ -66,7 +83,7 @@ export async function listingRoutes(fastify: FastifyInstance) {
|
||||||
category: { select: { name: true, slug: true } },
|
category: { select: { name: true, slug: true } },
|
||||||
city: { select: { name: true, slug: true } },
|
city: { select: { name: true, slug: true } },
|
||||||
area: { 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 } },
|
advertiser: { select: { rating: true, reviewCount: true, verificationStatus: true } },
|
||||||
},
|
},
|
||||||
orderBy: { [sortBy]: 'desc' },
|
orderBy: { [sortBy]: 'desc' },
|
||||||
|
|
@ -140,4 +157,141 @@ export async function listingRoutes(fastify: FastifyInstance) {
|
||||||
|
|
||||||
return reply.send(successResponse(categories));
|
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 { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { verifyToken, successResponse, errorResponse } from '@luxe/shared';
|
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) {
|
export async function mediaRoutes(fastify: FastifyInstance) {
|
||||||
// POST /api/v1/media/presigned-url
|
// POST /api/v1/media/presigned-url
|
||||||
|
|
@ -31,4 +38,108 @@ export async function mediaRoutes(fastify: FastifyInstance) {
|
||||||
return reply.status(401).send(errorResponse('Invalid token'));
|
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: {
|
connection: {
|
||||||
host: REDIS_HOST,
|
host: REDIS_HOST,
|
||||||
port: REDIS_PORT,
|
port: REDIS_PORT,
|
||||||
|
retryStrategy: (times: number) => {
|
||||||
|
// Slow down retries to once every 30 seconds to avoid flooding console logs
|
||||||
|
return 30000;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
concurrency: 5,
|
concurrency: 5,
|
||||||
}
|
}
|
||||||
|
|
@ -72,3 +76,8 @@ worker.on('completed', (job) => {
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', (job, err) => {
|
||||||
logger.error(`Job ${job?.id} failed with error: ${err.message}`);
|
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