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:
likegears
2025-12-12 14:00:17 +08:00
commit 7af9c323f6
86 changed files with 20343 additions and 0 deletions

56
api/Dockerfile Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

55
api/src/app.ts Normal file
View 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
View 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
View 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);
});

View 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`,
},
});
}

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

View 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;

View 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;

View 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;

View 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;

View 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;
}

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

View 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
View 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
View 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
View 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"]
}