Initial commit: 3D Viewer application
Features: - Vue 3 frontend with Three.js/Online3DViewer - Node.js API with PostgreSQL and Redis - Python worker for model conversion - Docker Compose for deployment - ViewCube navigation with drag rotation and 90° snap - Cross-section, exploded view, and render settings - Parts tree with visibility controls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
56
api/Dockerfile
Normal file
56
api/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 expressjs
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN pnpm install --prod
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R expressjs:nodejs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER expressjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Start application
|
||||
CMD ["node", "dist/index.js"]
|
||||
44
api/package.json
Normal file
44
api/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "viewer3d-api",
|
||||
"version": "1.0.0",
|
||||
"description": "3D Model Viewer API Server",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"bullmq": "^5.12.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.21.0",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"minio": "^8.0.0",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.12.0",
|
||||
"pino": "^9.0.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"tsx": "^4.16.0",
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
2975
api/pnpm-lock.yaml
generated
Normal file
2975
api/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
api/src/app.ts
Normal file
55
api/src/app.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { env } from './config/env.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error.middleware.js';
|
||||
import healthRoutes from './routes/health.routes.js';
|
||||
import uploadRoutes from './routes/upload.routes.js';
|
||||
import modelsRoutes from './routes/models.routes.js';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
|
||||
// CORS
|
||||
app.use(cors({
|
||||
origin: env.CORS_ORIGINS,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
|
||||
exposedHeaders: ['X-Request-ID'],
|
||||
maxAge: 86400,
|
||||
}));
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
if (req.url !== '/api/health') {
|
||||
logger.info({ method: req.method, url: req.url }, 'Request received');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Routes
|
||||
app.use('/api/health', healthRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/models', modelsRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export default createApp;
|
||||
56
api/src/config/env.ts
Normal file
56
api/src/config/env.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
// Application
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().transform(Number).default('3000'),
|
||||
API_PREFIX: z.string().default('/api'),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
DATABASE_POOL_MIN: z.string().transform(Number).default('2'),
|
||||
DATABASE_POOL_MAX: z.string().transform(Number).default('10'),
|
||||
|
||||
// Redis
|
||||
REDIS_URL: z.string().url(),
|
||||
|
||||
// MinIO
|
||||
MINIO_ENDPOINT: z.string(),
|
||||
MINIO_PORT: z.string().transform(Number).default('9000'),
|
||||
MINIO_PUBLIC_ENDPOINT: z.string().optional(), // External endpoint for browser access
|
||||
MINIO_PUBLIC_PORT: z.string().transform(Number).optional(),
|
||||
MINIO_ACCESS_KEY: z.string().min(1),
|
||||
MINIO_SECRET_KEY: z.string().min(1),
|
||||
MINIO_USE_SSL: z.string().transform((v) => v === 'true').default('false'),
|
||||
MINIO_BUCKET_RAW: z.string().default('raw-models'),
|
||||
MINIO_BUCKET_CONVERTED: z.string().default('converted-models'),
|
||||
MINIO_BUCKET_THUMBNAILS: z.string().default('thumbnails'),
|
||||
|
||||
// Security
|
||||
CORS_ORIGINS: z.string().transform((s) => s.split(',')).default('http://localhost:5173'),
|
||||
PRESIGNED_URL_EXPIRY: z.string().transform(Number).default('3600'),
|
||||
|
||||
// Logging
|
||||
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
function validateEnv(): Env {
|
||||
try {
|
||||
return envSchema.parse(process.env);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const missing = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('\n');
|
||||
console.error('Environment validation failed:\n', missing);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
94
api/src/index.ts
Normal file
94
api/src/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createApp } from './app.js';
|
||||
import { env } from './config/env.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { testConnection, closePool } from './services/database.service.js';
|
||||
import { initializeBuckets } from './services/storage.service.js';
|
||||
import { closeQueue, setupQueueEvents } from './services/queue.service.js';
|
||||
import * as modelsService from './services/models.service.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
logger.info({ env: env.NODE_ENV }, 'Starting API server...');
|
||||
|
||||
// Test database connection
|
||||
const dbConnected = await testConnection();
|
||||
if (!dbConnected) {
|
||||
logger.fatal('Failed to connect to database');
|
||||
process.exit(1);
|
||||
}
|
||||
logger.info('Database connected');
|
||||
|
||||
// Initialize MinIO buckets
|
||||
try {
|
||||
await initializeBuckets();
|
||||
logger.info('MinIO buckets initialized');
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to initialize MinIO buckets');
|
||||
// Continue anyway - buckets might already exist
|
||||
}
|
||||
|
||||
// Setup queue event handlers
|
||||
setupQueueEvents(
|
||||
async (jobId, result) => {
|
||||
// Update model on job completion
|
||||
const data = result as { modelUrl?: string; thumbnailUrl?: string; metadata?: Record<string, unknown> };
|
||||
await modelsService.updateModel(jobId, {
|
||||
conversion_status: 'completed',
|
||||
model_url: data.modelUrl,
|
||||
thumbnail_url: data.thumbnailUrl,
|
||||
metadata: data.metadata,
|
||||
});
|
||||
},
|
||||
async (jobId, error) => {
|
||||
// Update model on job failure
|
||||
await modelsService.updateModel(jobId, {
|
||||
conversion_status: 'failed',
|
||||
conversion_error: error,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Create Express app
|
||||
const app = createApp();
|
||||
|
||||
// Start server
|
||||
const server = app.listen(env.PORT, () => {
|
||||
logger.info({ port: env.PORT }, 'API server listening');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
logger.info({ signal }, 'Shutdown signal received');
|
||||
|
||||
// Stop accepting new connections
|
||||
server.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
});
|
||||
|
||||
// Close queue connections
|
||||
await closeQueue();
|
||||
|
||||
// Close database pool
|
||||
await closePool();
|
||||
|
||||
logger.info('Graceful shutdown complete');
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.fatal(error, 'Uncaught exception');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error({ reason }, 'Unhandled rejection');
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.fatal(error, 'Failed to start server');
|
||||
process.exit(1);
|
||||
});
|
||||
119
api/src/middleware/error.middleware.ts
Normal file
119
api/src/middleware/error.middleware.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
export interface ApiError extends Error {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error classes
|
||||
*/
|
||||
export class NotFoundError extends Error implements ApiError {
|
||||
statusCode = 404;
|
||||
code = 'NOT_FOUND';
|
||||
|
||||
constructor(message: string = 'Resource not found') {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends Error implements ApiError {
|
||||
statusCode = 400;
|
||||
code = 'VALIDATION_ERROR';
|
||||
details?: unknown;
|
||||
|
||||
constructor(message: string = 'Validation failed', details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends Error implements ApiError {
|
||||
statusCode = 409;
|
||||
code = 'CONFLICT';
|
||||
|
||||
constructor(message: string = 'Resource conflict') {
|
||||
super(message);
|
||||
this.name = 'ConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends Error implements ApiError {
|
||||
statusCode = 503;
|
||||
code = 'STORAGE_ERROR';
|
||||
|
||||
constructor(message: string = 'Storage service error') {
|
||||
super(message);
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global error handler middleware
|
||||
*/
|
||||
export function errorHandler(
|
||||
err: ApiError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void {
|
||||
// Log the error
|
||||
logger.error({
|
||||
err,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
body: req.body,
|
||||
}, 'Request error');
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
details: err.errors.map((e) => ({
|
||||
path: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle custom errors
|
||||
const statusCode = err.statusCode || 500;
|
||||
const code = err.code || 'INTERNAL_ERROR';
|
||||
const message = statusCode === 500 ? 'Internal server error' : err.message;
|
||||
|
||||
const errorResponse: { code: string; message: string; details?: unknown } = {
|
||||
code,
|
||||
message,
|
||||
};
|
||||
if (err.details) {
|
||||
errorResponse.details = err.details;
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: errorResponse,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 handler for unknown routes
|
||||
*/
|
||||
export function notFoundHandler(req: Request, res: Response): void {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: `Route ${req.method} ${req.path} not found`,
|
||||
},
|
||||
});
|
||||
}
|
||||
75
api/src/middleware/validation.middleware.ts
Normal file
75
api/src/middleware/validation.middleware.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Validation middleware factory
|
||||
*/
|
||||
export function validate<T extends z.ZodSchema>(
|
||||
schema: T,
|
||||
source: 'body' | 'query' | 'params' = 'body'
|
||||
) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
const data = schema.parse(req[source]);
|
||||
req[source] = data;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Common validation schemas
|
||||
export const schemas = {
|
||||
// UUID parameter
|
||||
uuidParam: z.object({
|
||||
id: z.string().uuid('Invalid ID format'),
|
||||
}),
|
||||
|
||||
// Pagination query
|
||||
pagination: z.object({
|
||||
limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).optional(),
|
||||
offset: z.string().transform(Number).pipe(z.number().min(0)).optional(),
|
||||
}),
|
||||
|
||||
// Model list query
|
||||
modelListQuery: z.object({
|
||||
search: z.string().max(255).optional(),
|
||||
status: z.enum(['pending', 'processing', 'completed', 'failed']).optional(),
|
||||
format: z.string().max(10).optional(),
|
||||
limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).default('50'),
|
||||
offset: z.string().transform(Number).pipe(z.number().min(0)).default('0'),
|
||||
}),
|
||||
|
||||
// Upload initialization
|
||||
initUpload: z.object({
|
||||
filename: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine(
|
||||
(name) => !name.includes('..') && !name.includes('/'),
|
||||
'Invalid filename'
|
||||
)
|
||||
.refine(
|
||||
(name) => {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
return ['step', 'stp', 'stl', 'obj', 'glb', 'gltf', '3ds', 'fbx', 'iges', 'igs'].includes(ext || '');
|
||||
},
|
||||
'Unsupported file format'
|
||||
),
|
||||
}),
|
||||
|
||||
// Upload confirmation
|
||||
confirmUpload: z.object({
|
||||
modelId: z.string().uuid(),
|
||||
filename: z.string().min(1).max(255),
|
||||
fileSize: z.number().positive().max(500 * 1024 * 1024), // Max 500MB
|
||||
storageKey: z.string().min(1),
|
||||
}),
|
||||
|
||||
// Model update
|
||||
updateModel: z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
}),
|
||||
};
|
||||
92
api/src/routes/health.routes.ts
Normal file
92
api/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { testConnection } from '../services/database.service.js';
|
||||
import { redis } from '../services/queue.service.js';
|
||||
import { minioClient } from '../services/storage.service.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
interface HealthCheck {
|
||||
status: 'up' | 'down';
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /health - Basic liveness check
|
||||
*/
|
||||
router.get('/', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /health/ready - Full readiness check
|
||||
*/
|
||||
router.get('/ready', async (_req, res) => {
|
||||
const checks: Record<string, HealthCheck> = {};
|
||||
let allHealthy = true;
|
||||
|
||||
// Check database
|
||||
const dbStart = Date.now();
|
||||
try {
|
||||
const dbOk = await testConnection();
|
||||
checks.database = {
|
||||
status: dbOk ? 'up' : 'down',
|
||||
latency: Date.now() - dbStart,
|
||||
};
|
||||
if (!dbOk) allHealthy = false;
|
||||
} catch (error) {
|
||||
checks.database = {
|
||||
status: 'down',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
allHealthy = false;
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
const redisStart = Date.now();
|
||||
try {
|
||||
await redis.ping();
|
||||
checks.redis = {
|
||||
status: 'up',
|
||||
latency: Date.now() - redisStart,
|
||||
};
|
||||
} catch (error) {
|
||||
checks.redis = {
|
||||
status: 'down',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
allHealthy = false;
|
||||
}
|
||||
|
||||
// Check MinIO
|
||||
const minioStart = Date.now();
|
||||
try {
|
||||
await minioClient.listBuckets();
|
||||
checks.minio = {
|
||||
status: 'up',
|
||||
latency: Date.now() - minioStart,
|
||||
};
|
||||
} catch (error) {
|
||||
checks.minio = {
|
||||
status: 'down',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
allHealthy = false;
|
||||
}
|
||||
|
||||
const response = {
|
||||
status: allHealthy ? 'healthy' : 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
checks,
|
||||
};
|
||||
|
||||
if (!allHealthy) {
|
||||
logger.warn(response, 'Health check failed');
|
||||
}
|
||||
|
||||
res.status(allHealthy ? 200 : 503).json(response);
|
||||
});
|
||||
|
||||
export default router;
|
||||
303
api/src/routes/models.routes.ts
Normal file
303
api/src/routes/models.routes.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import multer from 'multer';
|
||||
import { validate, schemas } from '../middleware/validation.middleware.js';
|
||||
import { NotFoundError } from '../middleware/error.middleware.js';
|
||||
import * as modelsService from '../services/models.service.js';
|
||||
import { addThumbnailJob } from '../services/queue.service.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Configure multer for thumbnail uploads (memory storage for small images)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 2 * 1024 * 1024, // 2MB max
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'image/png' || file.mimetype === 'image/jpeg') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PNG and JPEG images are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models - List all models
|
||||
*/
|
||||
router.get('/', validate(schemas.modelListQuery, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const query = req.query as unknown as {
|
||||
search?: string;
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
format?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
const result = await modelsService.getModels({
|
||||
search: query.search,
|
||||
status: query.status,
|
||||
format: query.format,
|
||||
limit: Number(query.limit) || 20,
|
||||
offset: Number(query.offset) || 0,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.models,
|
||||
meta: {
|
||||
total: result.total,
|
||||
limit: Number(query.limit) || 20,
|
||||
offset: Number(query.offset) || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id - Get a single model
|
||||
*/
|
||||
router.get('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const model = await modelsService.getModelById(id);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: model,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/parts - Get model parts
|
||||
*/
|
||||
router.get('/:id/parts', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const model = await modelsService.getModelById(id);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
const parts = await modelsService.getModelParts(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: parts,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/url - Get download URL for viewing
|
||||
*/
|
||||
router.get('/:id/url', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const url = await modelsService.getModelDownloadUrl(id);
|
||||
if (!url) {
|
||||
throw new NotFoundError('Model not ready or not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { url },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/lod - Get all LOD URLs for a model
|
||||
*/
|
||||
router.get('/:id/lod', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const lodUrls = await modelsService.getModelLodUrls(id);
|
||||
if (!lodUrls) {
|
||||
throw new NotFoundError('Model not ready or not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: lodUrls,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/lod/:level - Get URL for specific LOD level
|
||||
*/
|
||||
router.get('/:id/lod/:level', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id, level } = req.params;
|
||||
const lodLevel = parseInt(level, 10);
|
||||
|
||||
if (isNaN(lodLevel) || lodLevel < 0 || lodLevel > 2) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid LOD level. Must be 0, 1, or 2.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = await modelsService.getModelLodUrl(id, lodLevel);
|
||||
if (!url) {
|
||||
throw new NotFoundError('Model not ready or not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { url, level: lodLevel },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /models/:id - Update model metadata
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
validate(schemas.uuidParam, 'params'),
|
||||
validate(schemas.updateModel),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const model = await modelsService.updateModel(id, updates);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
logger.info({ modelId: id }, 'Model updated');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: model,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /models/:id - Delete a model
|
||||
*/
|
||||
router.delete('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await modelsService.deleteModel(id);
|
||||
if (!deleted) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
logger.info({ modelId: id }, 'Model deleted');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Model deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /models/:id/thumbnail - Upload a thumbnail for a model
|
||||
*/
|
||||
router.post(
|
||||
'/:id/thumbnail',
|
||||
validate(schemas.uuidParam, 'params'),
|
||||
upload.single('thumbnail'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No thumbnail file provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await modelsService.uploadThumbnail(id, req.file.buffer);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
logger.info({ modelId: id }, 'Thumbnail uploaded');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: model,
|
||||
thumbnail_url: model.thumbnail_url,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /models/:id/regenerate-thumbnail - Regenerate thumbnail for a model
|
||||
*/
|
||||
router.post(
|
||||
'/:id/regenerate-thumbnail',
|
||||
validate(schemas.uuidParam, 'params'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const model = await modelsService.getModelById(id);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
if (!model.model_url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Model not ready for thumbnail generation',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addThumbnailJob({ modelId: id, modelUrl: model.model_url });
|
||||
|
||||
logger.info({ modelId: id }, 'Thumbnail regeneration job queued');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Thumbnail regeneration job queued',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
52
api/src/routes/upload.routes.ts
Normal file
52
api/src/routes/upload.routes.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { validate, schemas } from '../middleware/validation.middleware.js';
|
||||
import * as modelsService from '../services/models.service.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
/**
|
||||
* POST /upload/presigned-url - Get a presigned URL for uploading
|
||||
*/
|
||||
router.post('/presigned-url', validate(schemas.initUpload), async (req, res, next) => {
|
||||
try {
|
||||
const { filename } = req.body;
|
||||
|
||||
const result = await modelsService.initializeUpload(filename);
|
||||
|
||||
logger.info({ modelId: result.modelId, filename }, 'Upload initialized');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
uploadUrl: result.uploadUrl,
|
||||
modelId: result.modelId,
|
||||
storageKey: result.storageKey,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /upload/complete - Confirm upload and start conversion
|
||||
*/
|
||||
router.post('/complete', validate(schemas.confirmUpload), async (req, res, next) => {
|
||||
try {
|
||||
const { modelId, filename, fileSize, storageKey } = req.body;
|
||||
|
||||
const model = await modelsService.confirmUpload(modelId, filename, fileSize, storageKey);
|
||||
|
||||
logger.info({ modelId: model.id }, 'Upload confirmed, conversion queued');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: model,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
api/src/services/database.service.ts
Normal file
46
api/src/services/database.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import pg from 'pg';
|
||||
import { env } from '../config/env.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
// Create connection pool
|
||||
export const pool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
min: env.DATABASE_POOL_MIN,
|
||||
max: env.DATABASE_POOL_MAX,
|
||||
});
|
||||
|
||||
// Test connection on startup
|
||||
pool.on('connect', () => {
|
||||
logger.debug('New database connection established');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
logger.error(err, 'Unexpected database pool error');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
*/
|
||||
export async function testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
await client.query('SELECT 1');
|
||||
client.release();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(error, 'Database connection test failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database pool
|
||||
*/
|
||||
export async function closePool(): Promise<void> {
|
||||
await pool.end();
|
||||
logger.info('Database pool closed');
|
||||
}
|
||||
|
||||
export default pool;
|
||||
328
api/src/services/models.service.ts
Normal file
328
api/src/services/models.service.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import pool from './database.service.js';
|
||||
import { addConversionJob, addThumbnailJob } from './queue.service.js';
|
||||
import { BUCKETS, getPresignedUploadUrl, getPresignedDownloadUrl, deleteObjectsByPrefix, getPublicUrl, toPublicUrl, uploadBuffer } from './storage.service.js';
|
||||
import type { Model, ModelPart, CreateModelInput, UpdateModelInput, ConversionStatus } from '../types/model.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Transform model URLs to use public endpoint
|
||||
*/
|
||||
function transformModelUrls(model: Model): Model {
|
||||
return {
|
||||
...model,
|
||||
model_url: toPublicUrl(model.model_url),
|
||||
thumbnail_url: toPublicUrl(model.thumbnail_url),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models with optional filtering
|
||||
*/
|
||||
export async function getModels(options: {
|
||||
search?: string;
|
||||
status?: ConversionStatus;
|
||||
format?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}): Promise<{ models: Model[]; total: number }> {
|
||||
const { search, status, format, limit = 50, offset = 0 } = options;
|
||||
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (search) {
|
||||
whereClause += ` AND (name ILIKE $${paramIndex} OR original_filename ILIKE $${paramIndex})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause += ` AND conversion_status = $${paramIndex}`;
|
||||
params.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (format) {
|
||||
whereClause += ` AND original_format = $${paramIndex}`;
|
||||
params.push(format);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) FROM models ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
// Get models
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM models ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
models: (result.rows as Model[]).map(transformModelUrls),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single model by ID
|
||||
*/
|
||||
export async function getModelById(id: string): Promise<Model | null> {
|
||||
const result = await pool.query('SELECT * FROM models WHERE id = $1', [id]);
|
||||
const model = result.rows[0] as Model | null;
|
||||
return model ? transformModelUrls(model) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new model record
|
||||
*/
|
||||
export async function createModel(input: CreateModelInput): Promise<Model> {
|
||||
const { name, original_filename, original_format, file_size, raw_storage_key } = input;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO models (name, original_filename, original_format, file_size, raw_storage_key, conversion_status)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||
RETURNING *`,
|
||||
[name, original_filename, original_format, file_size, raw_storage_key]
|
||||
);
|
||||
|
||||
const model = result.rows[0] as Model;
|
||||
logger.info({ modelId: model.id }, 'Model record created');
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a model
|
||||
*/
|
||||
export async function updateModel(id: string, input: UpdateModelInput): Promise<Model | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = $${paramIndex}`);
|
||||
values.push(key === 'metadata' ? JSON.stringify(value) : value);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return getModelById(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE models SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] as Model | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a model and its associated files
|
||||
*/
|
||||
export async function deleteModel(id: string): Promise<boolean> {
|
||||
const model = await getModelById(id);
|
||||
if (!model) return false;
|
||||
|
||||
// Delete from database (cascade will delete model_parts)
|
||||
await pool.query('DELETE FROM models WHERE id = $1', [id]);
|
||||
|
||||
// Delete files from MinIO
|
||||
try {
|
||||
await deleteObjectsByPrefix(BUCKETS.RAW, `${id}/`);
|
||||
await deleteObjectsByPrefix(BUCKETS.CONVERTED, `${id}/`);
|
||||
await deleteObjectsByPrefix(BUCKETS.THUMBNAILS, `${id}/`);
|
||||
} catch (error) {
|
||||
logger.error({ modelId: id, error }, 'Error deleting model files from storage');
|
||||
}
|
||||
|
||||
logger.info({ modelId: id }, 'Model deleted');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model parts
|
||||
*/
|
||||
export async function getModelParts(modelId: string): Promise<ModelPart[]> {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM model_parts WHERE model_id = $1 ORDER BY name',
|
||||
[modelId]
|
||||
);
|
||||
return result.rows as ModelPart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate presigned upload URL for a new model
|
||||
*/
|
||||
export async function initializeUpload(filename: string): Promise<{
|
||||
uploadUrl: string;
|
||||
modelId: string;
|
||||
storageKey: string;
|
||||
}> {
|
||||
const modelId = uuidv4();
|
||||
const storageKey = `${modelId}/${filename}`;
|
||||
const uploadUrl = await getPresignedUploadUrl(BUCKETS.RAW, storageKey);
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
modelId,
|
||||
storageKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm upload and start conversion
|
||||
*/
|
||||
export async function confirmUpload(
|
||||
modelId: string,
|
||||
filename: string,
|
||||
fileSize: number,
|
||||
storageKey: string
|
||||
): Promise<Model> {
|
||||
const format = filename.split('.').pop()?.toLowerCase() || 'unknown';
|
||||
const name = filename.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||
|
||||
// Create model record
|
||||
const model = await createModel({
|
||||
name,
|
||||
original_filename: filename,
|
||||
original_format: format,
|
||||
file_size: fileSize,
|
||||
raw_storage_key: storageKey,
|
||||
});
|
||||
|
||||
// Queue conversion job (unless already GLB)
|
||||
if (format !== 'glb' && format !== 'gltf') {
|
||||
await addConversionJob({
|
||||
modelId: model.id,
|
||||
key: storageKey,
|
||||
fileType: format,
|
||||
});
|
||||
logger.info({ modelId: model.id }, 'Conversion job queued');
|
||||
} else {
|
||||
// GLB/GLTF don't need conversion - file stays in raw bucket
|
||||
const modelUrl = getPublicUrl(BUCKETS.RAW, storageKey);
|
||||
await updateModel(model.id, {
|
||||
conversion_status: 'completed',
|
||||
// Don't set converted_storage_key - file is in raw bucket, not converted bucket
|
||||
model_url: modelUrl,
|
||||
});
|
||||
|
||||
// Queue thumbnail generation job for GLB/GLTF files
|
||||
await addThumbnailJob({
|
||||
modelId: model.id,
|
||||
modelUrl: modelUrl,
|
||||
});
|
||||
logger.info({ modelId: model.id }, 'Thumbnail job queued for GLB/GLTF');
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download URL for a model
|
||||
*/
|
||||
export async function getModelDownloadUrl(id: string): Promise<string | null> {
|
||||
const model = await getModelById(id);
|
||||
if (!model || model.conversion_status !== 'completed') return null;
|
||||
|
||||
// If model_url is already set (GLB/GLTF files or converted models), return it directly
|
||||
if (model.model_url) {
|
||||
return model.model_url;
|
||||
}
|
||||
|
||||
// Otherwise generate presigned URL for files that need it
|
||||
const key = model.converted_storage_key || model.raw_storage_key;
|
||||
if (!key) return null;
|
||||
|
||||
const bucket = model.converted_storage_key ? BUCKETS.CONVERTED : BUCKETS.RAW;
|
||||
return getPresignedDownloadUrl(bucket, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download URL for a specific LOD level
|
||||
* @param id Model ID
|
||||
* @param lodLevel LOD level (0, 1, or 2). Default is 0 (highest quality)
|
||||
*/
|
||||
export async function getModelLodUrl(id: string, lodLevel: number = 0): Promise<string | null> {
|
||||
const model = await getModelById(id);
|
||||
if (!model || model.conversion_status !== 'completed') return null;
|
||||
|
||||
// Check if LOD URLs are available in metadata
|
||||
const metadata = model.metadata as Record<string, unknown> | null;
|
||||
const lodUrls = metadata?.lod_urls as Record<string, string> | undefined;
|
||||
|
||||
if (lodUrls) {
|
||||
const lodKey = `lod${lodLevel}`;
|
||||
if (lodUrls[lodKey]) {
|
||||
return toPublicUrl(lodUrls[lodKey]);
|
||||
}
|
||||
// Fallback to LOD0 if requested level not available
|
||||
if (lodUrls['lod0']) {
|
||||
return toPublicUrl(lodUrls['lod0']);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original model_url for backward compatibility
|
||||
return model.model_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available LOD URLs for a model
|
||||
*/
|
||||
export async function getModelLodUrls(id: string): Promise<Record<string, string> | null> {
|
||||
const model = await getModelById(id);
|
||||
if (!model || model.conversion_status !== 'completed') return null;
|
||||
|
||||
const metadata = model.metadata as Record<string, unknown> | null;
|
||||
const lodUrls = metadata?.lod_urls as Record<string, string> | undefined;
|
||||
|
||||
if (lodUrls) {
|
||||
// Transform all URLs to public URLs
|
||||
const publicLodUrls: Record<string, string> = {};
|
||||
for (const [key, url] of Object.entries(lodUrls)) {
|
||||
const publicUrl = toPublicUrl(url);
|
||||
if (publicUrl) {
|
||||
publicLodUrls[key] = publicUrl;
|
||||
}
|
||||
}
|
||||
return Object.keys(publicLodUrls).length > 0 ? publicLodUrls : null;
|
||||
}
|
||||
|
||||
// Fallback: return model_url as lod0 for backward compatibility
|
||||
if (model.model_url) {
|
||||
return { lod0: model.model_url };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a thumbnail for a model
|
||||
*/
|
||||
export async function uploadThumbnail(id: string, buffer: Buffer): Promise<Model | null> {
|
||||
const model = await getModelById(id);
|
||||
if (!model) return null;
|
||||
|
||||
// Upload to MinIO
|
||||
const thumbnailKey = `${id}/preview.png`;
|
||||
const thumbnailUrl = await uploadBuffer(BUCKETS.THUMBNAILS, thumbnailKey, buffer, 'image/png');
|
||||
|
||||
// Update database
|
||||
const result = await pool.query(
|
||||
`UPDATE models SET thumbnail_url = $1, thumbnail_storage_key = $2, updated_at = NOW() WHERE id = $3 RETURNING *`,
|
||||
[thumbnailUrl, thumbnailKey, id]
|
||||
);
|
||||
|
||||
logger.info({ modelId: id }, 'Thumbnail uploaded');
|
||||
return result.rows[0] ? transformModelUrls(result.rows[0] as Model) : null;
|
||||
}
|
||||
129
api/src/services/queue.service.ts
Normal file
129
api/src/services/queue.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Queue, QueueEvents } from 'bullmq';
|
||||
import { Redis } from 'ioredis';
|
||||
import { env } from '../config/env.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import type { ConversionJobData, ThumbnailJobData, QueueJobData } from '../types/model.js';
|
||||
|
||||
// Parse Redis URL
|
||||
const redisUrl = new URL(env.REDIS_URL);
|
||||
const redisConnection = {
|
||||
host: redisUrl.hostname,
|
||||
port: parseInt(redisUrl.port || '6379'),
|
||||
password: redisUrl.password || undefined,
|
||||
};
|
||||
|
||||
// Create Redis client
|
||||
export const redis = new Redis(env.REDIS_URL);
|
||||
|
||||
// Queue name
|
||||
const QUEUE_NAME = 'model-conversion';
|
||||
|
||||
// Default job options
|
||||
const defaultJobOptions = {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential' as const,
|
||||
delay: 5000, // 5s, 25s, 125s
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 3600, // Keep for 1 hour
|
||||
count: 100, // Keep last 100
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 86400, // Keep failures for 24 hours
|
||||
},
|
||||
};
|
||||
|
||||
// Create the queue (accepts both conversion and thumbnail jobs)
|
||||
export const conversionQueue = new Queue<QueueJobData>(QUEUE_NAME, {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions,
|
||||
});
|
||||
|
||||
// Create queue events listener
|
||||
export const queueEvents = new QueueEvents(QUEUE_NAME, {
|
||||
connection: redisConnection,
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a conversion job to the queue
|
||||
*/
|
||||
export async function addConversionJob(data: ConversionJobData): Promise<string> {
|
||||
const job = await conversionQueue.add('convert', data, {
|
||||
jobId: data.modelId, // Prevent duplicate jobs for same model
|
||||
});
|
||||
logger.info({ jobId: job.id, modelId: data.modelId }, 'Conversion job added to queue');
|
||||
return job.id!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a thumbnail-only job to the queue (for GLB/GLTF files that don't need conversion)
|
||||
*/
|
||||
export async function addThumbnailJob(data: { modelId: string; modelUrl: string }): Promise<string> {
|
||||
const thumbnailJobData: ThumbnailJobData = {
|
||||
modelId: data.modelId,
|
||||
modelUrl: data.modelUrl,
|
||||
jobType: 'thumbnail',
|
||||
};
|
||||
const job = await conversionQueue.add('thumbnail', thumbnailJobData, {
|
||||
jobId: `thumbnail-${data.modelId}`, // Unique job ID for thumbnail
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'exponential' as const,
|
||||
delay: 3000,
|
||||
},
|
||||
});
|
||||
logger.info({ jobId: job.id, modelId: data.modelId }, 'Thumbnail job added to queue');
|
||||
return job.id!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job status
|
||||
*/
|
||||
export async function getJobStatus(jobId: string): Promise<{
|
||||
state: string;
|
||||
progress: number;
|
||||
error?: string;
|
||||
} | null> {
|
||||
const job = await conversionQueue.getJob(jobId);
|
||||
if (!job) return null;
|
||||
|
||||
const state = await job.getState();
|
||||
return {
|
||||
state,
|
||||
progress: job.progress as number || 0,
|
||||
error: job.failedReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup queue event handlers
|
||||
*/
|
||||
export function setupQueueEvents(
|
||||
onCompleted: (jobId: string, result: unknown) => Promise<void>,
|
||||
onFailed: (jobId: string, error: string) => Promise<void>
|
||||
): void {
|
||||
queueEvents.on('completed', async ({ jobId, returnvalue }) => {
|
||||
logger.info({ jobId }, 'Job completed');
|
||||
await onCompleted(jobId, returnvalue);
|
||||
});
|
||||
|
||||
queueEvents.on('failed', async ({ jobId, failedReason }) => {
|
||||
logger.error({ jobId, error: failedReason }, 'Job failed');
|
||||
await onFailed(jobId, failedReason);
|
||||
});
|
||||
|
||||
queueEvents.on('progress', ({ jobId, data }) => {
|
||||
logger.debug({ jobId, progress: data }, 'Job progress');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
export async function closeQueue(): Promise<void> {
|
||||
await queueEvents.close();
|
||||
await conversionQueue.close();
|
||||
await redis.quit();
|
||||
logger.info('Queue connections closed');
|
||||
}
|
||||
146
api/src/services/storage.service.ts
Normal file
146
api/src/services/storage.service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Client as MinioClient } from 'minio';
|
||||
import { env } from '../config/env.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Internal client for server-to-server operations
|
||||
const minioClient = new MinioClient({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
useSSL: env.MINIO_USE_SSL,
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
region: 'us-east-1', // Fixed region to avoid bucket region lookup
|
||||
});
|
||||
|
||||
// Public client for generating presigned URLs (uses public endpoint but region is fixed)
|
||||
const publicMinioClient = new MinioClient({
|
||||
endPoint: env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PUBLIC_PORT || env.MINIO_PORT,
|
||||
useSSL: env.MINIO_USE_SSL,
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
region: 'us-east-1', // Fixed region to avoid bucket region lookup
|
||||
});
|
||||
|
||||
// Bucket names
|
||||
export const BUCKETS = {
|
||||
RAW: env.MINIO_BUCKET_RAW,
|
||||
CONVERTED: env.MINIO_BUCKET_CONVERTED,
|
||||
THUMBNAILS: env.MINIO_BUCKET_THUMBNAILS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Initialize MinIO buckets (ensure they exist)
|
||||
*/
|
||||
export async function initializeBuckets(): Promise<void> {
|
||||
for (const bucket of Object.values(BUCKETS)) {
|
||||
const exists = await minioClient.bucketExists(bucket);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(bucket);
|
||||
logger.info(`Created bucket: ${bucket}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for uploading a file (uses public endpoint for browser access)
|
||||
*/
|
||||
export async function getPresignedUploadUrl(
|
||||
bucket: string,
|
||||
key: string,
|
||||
expirySeconds: number = env.PRESIGNED_URL_EXPIRY
|
||||
): Promise<string> {
|
||||
return publicMinioClient.presignedPutObject(bucket, key, expirySeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for downloading a file (uses public endpoint for browser access)
|
||||
*/
|
||||
export async function getPresignedDownloadUrl(
|
||||
bucket: string,
|
||||
key: string,
|
||||
expirySeconds: number = env.PRESIGNED_URL_EXPIRY
|
||||
): Promise<string> {
|
||||
return publicMinioClient.presignedGetObject(bucket, key, expirySeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an object from MinIO
|
||||
*/
|
||||
export async function deleteObject(bucket: string, key: string): Promise<void> {
|
||||
await minioClient.removeObject(bucket, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple objects with a prefix
|
||||
*/
|
||||
export async function deleteObjectsByPrefix(bucket: string, prefix: string): Promise<void> {
|
||||
const objects = minioClient.listObjects(bucket, prefix, true);
|
||||
const objectsToDelete: string[] = [];
|
||||
|
||||
for await (const obj of objects) {
|
||||
objectsToDelete.push(obj.name);
|
||||
}
|
||||
|
||||
if (objectsToDelete.length > 0) {
|
||||
await minioClient.removeObjects(bucket, objectsToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object exists
|
||||
*/
|
||||
export async function objectExists(bucket: string, key: string): Promise<boolean> {
|
||||
try {
|
||||
await minioClient.statObject(bucket, key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for an object (for publicly accessible buckets)
|
||||
*/
|
||||
export function getPublicUrl(bucket: string, key: string): string {
|
||||
const protocol = env.MINIO_USE_SSL ? 'https' : 'http';
|
||||
const endpoint = env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT;
|
||||
const port = env.MINIO_PUBLIC_PORT || env.MINIO_PORT;
|
||||
return `${protocol}://${endpoint}:${port}/${bucket}/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform internal MinIO URL to public URL
|
||||
* Handles URLs that were stored with internal hostname or localhost
|
||||
*/
|
||||
export function toPublicUrl(url: string | null): string | null {
|
||||
if (!url) return null;
|
||||
|
||||
const publicEndpoint = env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT;
|
||||
const publicPort = env.MINIO_PUBLIC_PORT || env.MINIO_PORT;
|
||||
|
||||
// Replace internal hostname patterns with public endpoint
|
||||
// Also handle localhost URLs from legacy data
|
||||
return url
|
||||
.replace(/minio:9000/g, `${publicEndpoint}:${publicPort}`)
|
||||
.replace(/localhost:9000/g, `${publicEndpoint}:${publicPort}`)
|
||||
.replace(/127\.0\.0\.1:9000/g, `${publicEndpoint}:${publicPort}`)
|
||||
.replace(new RegExp(`${env.MINIO_ENDPOINT}:${env.MINIO_PORT}`, 'g'), `${publicEndpoint}:${publicPort}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a buffer directly to MinIO
|
||||
*/
|
||||
export async function uploadBuffer(
|
||||
bucket: string,
|
||||
key: string,
|
||||
buffer: Buffer,
|
||||
contentType: string = 'application/octet-stream'
|
||||
): Promise<string> {
|
||||
await minioClient.putObject(bucket, key, buffer, buffer.length, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
return getPublicUrl(bucket, key);
|
||||
}
|
||||
|
||||
export { minioClient };
|
||||
76
api/src/types/model.ts
Normal file
76
api/src/types/model.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
original_filename: string;
|
||||
original_format: string;
|
||||
file_size: number;
|
||||
raw_storage_key: string | null;
|
||||
converted_storage_key: string | null;
|
||||
thumbnail_storage_key: string | null;
|
||||
model_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
conversion_status: ConversionStatus;
|
||||
conversion_error: string | null;
|
||||
metadata: ModelMetadata;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export type ConversionStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
export interface ModelMetadata {
|
||||
vertices?: number;
|
||||
faces?: number;
|
||||
bounding_box?: BoundingBox;
|
||||
parts_count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
min: { x: number; y: number; z: number };
|
||||
max: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
export interface ModelPart {
|
||||
id: string;
|
||||
model_id: string;
|
||||
name: string | null;
|
||||
mesh_index: number | null;
|
||||
bounding_box: BoundingBox;
|
||||
center_point: { x: number; y: number; z: number };
|
||||
parent_part_id: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateModelInput {
|
||||
name: string;
|
||||
original_filename: string;
|
||||
original_format: string;
|
||||
file_size: number;
|
||||
raw_storage_key: string;
|
||||
}
|
||||
|
||||
export interface UpdateModelInput {
|
||||
name?: string;
|
||||
converted_storage_key?: string;
|
||||
thumbnail_storage_key?: string;
|
||||
model_url?: string;
|
||||
thumbnail_url?: string;
|
||||
conversion_status?: ConversionStatus;
|
||||
conversion_error?: string;
|
||||
metadata?: ModelMetadata;
|
||||
}
|
||||
|
||||
export interface ConversionJobData {
|
||||
modelId: string;
|
||||
key: string;
|
||||
fileType: string;
|
||||
}
|
||||
|
||||
export interface ThumbnailJobData {
|
||||
modelId: string;
|
||||
modelUrl: string;
|
||||
jobType: 'thumbnail';
|
||||
}
|
||||
|
||||
export type QueueJobData = ConversionJobData | ThumbnailJobData;
|
||||
22
api/src/utils/logger.ts
Normal file
22
api/src/utils/logger.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
transport:
|
||||
env.NODE_ENV === 'development'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
base: {
|
||||
env: env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
|
||||
export default logger;
|
||||
24
api/tsconfig.json
Normal file
24
api/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user