diff --git a/apps/fabrikanabytok/lib/config.ts b/apps/fabrikanabytok/lib/config.ts new file mode 100644 index 0000000..462684d --- /dev/null +++ b/apps/fabrikanabytok/lib/config.ts @@ -0,0 +1,48 @@ +export interface AppConfig { + maintenanceMode: boolean + websocket: { + maxConnectionsPerIP: number + allowedChannels: string[] + } + features: { + enableAI: boolean + enable3DPlanner: boolean + enableCollaboration: boolean + } + limits: { + maxUploadSize: number + maxProjectsPerUser: Record + } +} + +const defaultConfig: AppConfig = { + maintenanceMode: false, + websocket: { + maxConnectionsPerIP: 5, + allowedChannels: ["notifications", "updates", "chat", "admin", "workspace", "planner", "inventory"], + }, + features: { + enableAI: true, + enable3DPlanner: true, + enableCollaboration: true, + }, + limits: { + maxUploadSize: 10 * 1024 * 1024, // 10MB + maxProjectsPerUser: { + visitor: 0, + customer: 5, + distributor: 20, + admin: 100, + superadmin: -1, // unlimited + }, + }, +} + +export async function getConfig(): Promise { + // In the future, this can load from database + return defaultConfig +} + +export function getConfigSync(): AppConfig { + return defaultConfig +} diff --git a/apps/fabrikanabytok/lib/db/analytics.ts b/apps/fabrikanabytok/lib/db/analytics.ts new file mode 100644 index 0000000..2661135 --- /dev/null +++ b/apps/fabrikanabytok/lib/db/analytics.ts @@ -0,0 +1,24 @@ +import { logger } from "../utils/logger" + +export async function logActivity( + action: string, + description: string, + details: { + type: string + metadata?: Record + userId?: string + ipAddress?: string + }, +) { + try { + // TODO: Save to MongoDB when database is setup + logger.debug("Activity logged", { + action, + description, + ...details, + timestamp: new Date(), + }) + } catch (error) { + logger.error("Failed to log activity", error) + } +} diff --git a/apps/fabrikanabytok/lib/db/indexes.ts b/apps/fabrikanabytok/lib/db/indexes.ts new file mode 100644 index 0000000..2a6eec4 --- /dev/null +++ b/apps/fabrikanabytok/lib/db/indexes.ts @@ -0,0 +1,635 @@ +/** + * Database Index Recommendations + * Optimize MongoDB queries for production + */ + +import { getDb } from "./mongodb" + +export interface IndexDefinition { + collection: string + index: any + options?: any + reason: string +} + +/** + * Recommended indexes for optimal performance + */ +export const RECOMMENDED_INDEXES: IndexDefinition[] = [ + // Products + { + collection: "products", + index: { id: 1 }, + options: { unique: true }, + reason: "Primary lookup by custom ID", + }, + { + collection: "products", + index: { slug: 1 }, + options: { unique: true }, + reason: "Product detail page lookup", + }, + { + collection: "products", + index: { status: 1, categoryIds: 1 }, + reason: "Category filtering on shop", + }, + { + collection: "products", + index: { "stock": 1 }, + reason: "Low stock queries", + }, + + // Designs + { + collection: "designs", + index: { id: 1 }, + options: { unique: true }, + reason: "Design lookup", + }, + { + collection: "designs", + index: { userId: 1, updatedAt: -1 }, + reason: "User's designs sorted by date", + }, + + // Users + { + collection: "users", + index: { id: 1 }, + options: { unique: true }, + reason: "User lookup", + }, + { + collection: "users", + index: { email: 1 }, + options: { unique: true }, + reason: "Login by email", + }, + + // Employees + { + collection: "employees", + index: { id: 1 }, + options: { unique: true }, + reason: "Employee lookup", + }, + { + collection: "employees", + index: { userId: 1 }, + options: { unique: true }, + reason: "Auth to employee mapping", + }, + { + collection: "employees", + index: { employeeNumber: 1 }, + options: { unique: true }, + reason: "Employee number lookup", + }, + + // Employee Actions (for audit trail) + { + collection: "employee_actions", + index: { employeeId: 1, startedAt: -1 }, + reason: "Employee activity history", + }, + { + collection: "employee_actions", + index: { module: 1, startedAt: -1 }, + reason: "Actions by module", + }, + + // Inventory + { + collection: "inventory_adjustments", + index: { productId: 1, timestamp: -1 }, + reason: "Product adjustment history", + }, + { + collection: "location_stock", + index: { locationId: 1, productId: 1 }, + options: { unique: true }, + reason: "Stock by location and product", + }, + + // Orders + { + collection: "orders", + index: { id: 1 }, + options: { unique: true }, + reason: "Order lookup", + }, + { + collection: "orders", + index: { userId: 1, createdAt: -1 }, + reason: "User's orders", + }, + { + collection: "orders", + index: { status: 1, createdAt: -1 }, + reason: "Orders by status", + }, + + // Categories + { + collection: "categories", + index: { id: 1 }, + options: { unique: true }, + reason: "Category lookup", + }, + { + collection: "categories", + index: { slug: 1 }, + options: { unique: true }, + reason: "Category by slug", + }, + + // Files + { + collection: "files", + index: { id: 1 }, + options: { unique: true }, + reason: "File lookup", + }, + { + collection: "files", + index: { category: 1, uploadedAt: -1 }, + reason: "Files by category", + }, + + // Settings + { + collection: "settings", + index: { id: 1 }, + options: { unique: true }, + reason: "Settings lookup", + }, + + // ========== MANUFACTURING MANAGEMENT ========== + + // Customers + { + collection: "customers", + index: { id: 1 }, + options: { unique: true }, + reason: "Customer lookup", + }, + { + collection: "customers", + index: { customerNumber: 1 }, + options: { unique: true }, + reason: "Customer number lookup", + }, + { + collection: "customers", + index: { email: 1 }, + reason: "Customer email search", + }, + { + collection: "customers", + index: { type: 1, status: 1 }, + reason: "Filter customers by type and status", + }, + { + collection: "customers", + index: { category: 1 }, + reason: "Filter by customer category", + }, + { + collection: "customers", + index: { taxNumber: 1 }, + reason: "Tax number lookup", + }, + { + collection: "customers", + index: { createdAt: -1 }, + reason: "Recent customers", + }, + { + collection: "customers", + index: { "contactPersons.email": 1 }, + reason: "Find customer by contact person email", + }, + { + collection: "customers", + index: { "contactPersons.userId": 1 }, + reason: "Link contact person to user", + }, + + // Customer Activities + { + collection: "customer_activities", + index: { customerId: 1, timestamp: -1 }, + reason: "Customer activity history", + }, + { + collection: "customer_activities", + index: { type: 1, timestamp: -1 }, + reason: "Activity by type", + }, + + // Manufacturing Orders + { + collection: "manufacturing_orders", + index: { id: 1 }, + options: { unique: true }, + reason: "Order lookup", + }, + { + collection: "manufacturing_orders", + index: { orderNumber: 1 }, + options: { unique: true }, + reason: "Order number lookup", + }, + { + collection: "manufacturing_orders", + index: { externalOrderNumber: 1 }, + reason: "External order number lookup", + }, + { + collection: "manufacturing_orders", + index: { customerId: 1, createdAt: -1 }, + reason: "Customer orders sorted by date", + }, + { + collection: "manufacturing_orders", + index: { status: 1, priority: -1, createdAt: -1 }, + reason: "Orders by status and priority", + }, + { + collection: "manufacturing_orders", + index: { deliveryWeek: 1, deliveryYear: 1 }, + reason: "Orders by delivery week", + }, + { + collection: "manufacturing_orders", + index: { assignedProductionManager: 1 }, + reason: "Orders by production manager", + }, + { + collection: "manufacturing_orders", + index: { invoiceStatus: 1 }, + reason: "Filter by invoice status", + }, + { + collection: "manufacturing_orders", + index: { paymentStatus: 1 }, + reason: "Filter by payment status", + }, + { + collection: "manufacturing_orders", + index: { importBatchId: 1 }, + reason: "Find orders from specific import", + }, + { + collection: "manufacturing_orders", + index: { createdAt: -1 }, + reason: "Recent orders", + }, + + // Order Items + { + collection: "order_items", + index: { orderId: 1 }, + reason: "Items by order", + }, + { + collection: "order_items", + index: { "production.assignedTo": 1, "production.stage": 1 }, + reason: "Employee production assignments", + }, + { + collection: "order_items", + index: { "production.stage": 1 }, + reason: "Items by production stage", + }, + { + collection: "order_items", + index: { productId: 1 }, + reason: "Items by product", + }, + { + collection: "order_items", + index: { model: 1 }, + reason: "Items by model", + }, + + // Production Schedules + { + collection: "production_schedules", + index: { weekNumber: 1, year: 1 }, + options: { unique: true }, + reason: "Schedule by week and year", + }, + { + collection: "production_schedules", + index: { status: 1, startDate: 1 }, + reason: "Active schedules", + }, + + // Worktops + { + collection: "worktops", + index: { id: 1 }, + options: { unique: true }, + reason: "Worktop lookup", + }, + { + collection: "worktops", + index: { code: 1 }, + options: { unique: true }, + reason: "Worktop code lookup", + }, + { + collection: "worktops", + index: { material: 1, colorName: 1 }, + reason: "Search by material and color", + }, + { + collection: "worktops", + index: { status: 1, inStock: 1 }, + reason: "Available worktops", + }, + { + collection: "worktops", + index: { supplierId: 1 }, + reason: "Worktops by supplier", + }, + + // Worktop Orders + { + collection: "worktop_orders", + index: { orderId: 1, orderItemId: 1 }, + reason: "Worktop orders by order", + }, + { + collection: "worktop_orders", + index: { worktopId: 1 }, + reason: "Orders using specific worktop", + }, + { + collection: "worktop_orders", + index: { productionStatus: 1 }, + reason: "Worktop production status", + }, + { + collection: "worktop_orders", + index: { assignedTo: 1 }, + reason: "Worktop production assignments", + }, + + // Accessories + { + collection: "accessories", + index: { id: 1 }, + options: { unique: true }, + reason: "Accessory lookup", + }, + { + collection: "accessories", + index: { code: 1 }, + options: { unique: true }, + reason: "Accessory code lookup", + }, + { + collection: "accessories", + index: { category: 1, status: 1 }, + reason: "Accessories by category", + }, + { + collection: "accessories", + index: { status: 1, inStock: 1 }, + reason: "Available accessories", + }, + { + collection: "accessories", + index: { supplierId: 1 }, + reason: "Accessories by supplier", + }, + { + collection: "accessories", + index: { assignedToProducts: 1 }, + reason: "Accessories assigned to products", + }, + + // Accessory Variants + { + collection: "accessory_variants", + index: { accessoryId: 1, isActive: 1 }, + reason: "Variants by accessory", + }, + { + collection: "accessory_variants", + index: { sku: 1 }, + options: { unique: true }, + reason: "Variant SKU lookup", + }, + + // Invoices + { + collection: "invoices", + index: { id: 1 }, + options: { unique: true }, + reason: "Invoice lookup", + }, + { + collection: "invoices", + index: { invoiceNumber: 1 }, + options: { unique: true }, + reason: "Invoice number lookup", + }, + { + collection: "invoices", + index: { customerId: 1, invoiceDate: -1 }, + reason: "Customer invoices", + }, + { + collection: "invoices", + index: { status: 1, dueDate: 1 }, + reason: "Invoices by status and due date", + }, + { + collection: "invoices", + index: { paymentStatus: 1 }, + reason: "Invoices by payment status", + }, + { + collection: "invoices", + index: { orderIds: 1 }, + reason: "Invoices for specific orders", + }, + { + collection: "invoices", + index: { invoiceDate: -1 }, + reason: "Recent invoices", + }, + { + collection: "invoices", + index: { dueDate: 1, paymentStatus: 1 }, + reason: "Overdue invoices", + }, + { + collection: "invoices", + index: { "externalApiSync.provider": 1, "externalApiSync.synced": 1 }, + reason: "API sync status", + }, + + // Coupons + { + collection: "coupons", + index: { id: 1 }, + options: { unique: true }, + reason: "Coupon lookup", + }, + { + collection: "coupons", + index: { code: 1 }, + options: { unique: true }, + reason: "Coupon code lookup", + }, + { + collection: "coupons", + index: { status: 1, isActive: 1 }, + reason: "Active coupons", + }, + { + collection: "coupons", + index: { "limits.startDate": 1, "limits.endDate": 1 }, + reason: "Time-based coupon validity", + }, + { + collection: "coupons", + index: { type: 1 }, + reason: "Coupons by type", + }, + + // Customer Pricing Rules + { + collection: "customer_pricing_rules", + index: { customerId: 1, isActive: 1 }, + reason: "Active pricing rules for customer", + }, + { + collection: "customer_pricing_rules", + index: { "productRules.productId": 1 }, + reason: "Pricing rules for product", + }, + + // Promotional Campaigns + { + collection: "promotional_campaigns", + index: { status: 1, startDate: 1 }, + reason: "Active campaigns", + }, + { + collection: "promotional_campaigns", + index: { endDate: 1 }, + reason: "Expiring campaigns", + }, + + // Excel Import Batches + { + collection: "excel_import_batches", + index: { id: 1 }, + options: { unique: true }, + reason: "Import batch lookup", + }, + { + collection: "excel_import_batches", + index: { status: 1, createdAt: -1 }, + reason: "Import batches by status", + }, + { + collection: "excel_import_batches", + index: { createdBy: 1, createdAt: -1 }, + reason: "Imports by user", + }, + + // Import History + { + collection: "import_history", + index: { batchId: 1 }, + reason: "History by batch", + }, + { + collection: "import_history", + index: { importedBy: 1, importedAt: -1 }, + reason: "Import history by user", + }, + { + collection: "import_history", + index: { status: 1 }, + reason: "Import history by status", + }, +] + +/** + * Create all recommended indexes + */ +export async function createRecommendedIndexes(): Promise<{ + created: number + failed: number + errors: string[] +}> { + try { + const db = await getDb() + let created = 0 + let failed = 0 + const errors: string[] = [] + + for (const indexDef of RECOMMENDED_INDEXES) { + try { + await db.collection(indexDef.collection).createIndex( + indexDef.index, + indexDef.options || {} + ) + created++ + console.log(`✅ Created index on ${indexDef.collection}:`, indexDef.index) + } catch (error: any) { + // Index might already exist + if (error.code === 85 || error.codeName === "IndexOptionsConflict") { + console.log(`ℹ️ Index already exists on ${indexDef.collection}`) + } else { + failed++ + errors.push(`${indexDef.collection}: ${error.message}`) + console.error(`❌ Failed to create index on ${indexDef.collection}:`, error.message) + } + } + } + + return { created, failed, errors } + } catch (error: any) { + console.error("Failed to create indexes:", error) + throw new Error("Index creation failed") + } +} + +/** + * Analyze current indexes + */ +export async function analyzeIndexes() { + try { + const db = await getDb() + const collections = await db.listCollections().toArray() + + const indexInfo: any[] = [] + + for (const collection of collections) { + const indexes = await db.collection(collection.name).indexes() + indexInfo.push({ + collection: collection.name, + indexes: indexes.map((idx) => ({ + name: idx.name, + keys: idx.key, + unique: idx.unique || false, + })), + }) + } + + return indexInfo + } catch (error) { + console.error("Failed to analyze indexes:", error) + return [] + } +} + diff --git a/apps/fabrikanabytok/lib/db/migrations.ts b/apps/fabrikanabytok/lib/db/migrations.ts new file mode 100644 index 0000000..e3aee04 --- /dev/null +++ b/apps/fabrikanabytok/lib/db/migrations.ts @@ -0,0 +1,177 @@ +/** + * Database Migration Utilities + * Ensures all documents have custom UUID fields for security + */ + +import { getDb } from "./mongodb" + +/** + * Migrate products to use custom id field + * Adds 'id' field (UUID) to all products that don't have one + */ +export async function migrateProductsToCustomIds() { + const db = await getDb() + + const products = await db.collection("products").find({ id: { $exists: false } }).toArray() + + console.log(`[Migration] Found ${products.length} products without custom IDs`) + + for (const product of products) { + const customId = crypto.randomUUID() + await db.collection("products").updateOne( + { _id: product._id }, + { $set: { id: customId } } + ) + console.log(`[Migration] Added id ${customId} to product ${product.name}`) + } + + console.log(`[Migration] Products migration complete`) + return { migrated: products.length } +} + +/** + * Migrate categories to use custom id field + */ +export async function migrateCategoriesToCustomIds() { + const db = await getDb() + + const categories = await db.collection("categories").find({ id: { $exists: false } }).toArray() + + console.log(`[Migration] Found ${categories.length} categories without custom IDs`) + + for (const category of categories) { + const customId = crypto.randomUUID() + await db.collection("categories").updateOne( + { _id: category._id }, + { $set: { id: customId } } + ) + console.log(`[Migration] Added id ${customId} to category ${category.name}`) + } + + console.log(`[Migration] Categories migration complete`) + return { migrated: categories.length } +} + +/** + * Migrate designs to ensure all have required fields + */ +export async function migrateDesignsToEnhancedFormat() { + const db = await getDb() + + const designs = await db.collection("designs").find({ + $or: [ + { layers: { $exists: false } }, + { snapSettings: { $exists: false } }, + { renderSettings: { $exists: false } }, + ] + }).toArray() + + console.log(`[Migration] Found ${designs.length} designs to enhance`) + + for (const design of designs) { + const updates: any = {} + + if (!design.layers) { + updates.layers = [ + { id: "default", name: "Default", visible: true, locked: false, color: "#3b82f6", order: 0, objectIds: [] } + ] + } + + if (!design.snapSettings) { + updates.snapSettings = { + enabled: true, + gridSize: 0.5, + snapToObjects: true, + snapToWalls: true, + snapAngle: 15, + snapDistance: 0.2, + showGuides: true, + } + } + + if (!design.renderSettings) { + updates.renderSettings = { + quality: "preview", + shadows: true, + shadowQuality: "medium", + ambientOcclusion: false, + antialiasing: true, + postProcessing: false, + lightingPreset: "day", + } + } + + if (!design.cameraPresets) { + updates.cameraPresets = [] + } + + if (!design.placedObjects) { + updates.placedObjects = [] + } + + if (Object.keys(updates).length > 0) { + await db.collection("designs").updateOne( + { _id: design._id }, + { $set: updates } + ) + console.log(`[Migration] Enhanced design ${design.name}`) + } + } + + console.log(`[Migration] Designs migration complete`) + return { migrated: designs.length } +} + +/** + * Migrate placed objects to have enhanced properties + */ +export async function migratePlacedObjectsToEnhancedFormat() { + const db = await getDb() + + const designs = await db.collection("designs").find({ placedObjects: { $exists: true, $ne: [] } }).toArray() + + console.log(`[Migration] Found ${designs.length} designs with placed objects`) + + for (const design of designs) { + const enhancedObjects = design.placedObjects.map((obj: any) => ({ + ...obj, + children: obj.children || [], + isGroup: obj.isGroup || false, + locked: obj.locked || false, + visible: obj.visible !== false, + elements: obj.elements || [], + tags: obj.tags || [], + layer: obj.layer || "default", + opacity: obj.opacity !== undefined ? obj.opacity : 1, + castShadow: obj.castShadow !== false, + receiveShadow: obj.receiveShadow !== false, + dimensions: obj.dimensions || { width: 0.6, height: 0.9, depth: 0.6 }, + })) + + await db.collection("designs").updateOne( + { _id: design._id }, + { $set: { placedObjects: enhancedObjects } } + ) + } + + console.log(`[Migration] Placed objects migration complete`) + return { designs: designs.length } +} + +/** + * Run all migrations + */ +export async function runAllMigrations() { + console.log("[Migration] Starting all migrations...") + + const results = { + products: await migrateProductsToCustomIds(), + categories: await migrateCategoriesToCustomIds(), + designs: await migrateDesignsToEnhancedFormat(), + placedObjects: await migratePlacedObjectsToEnhancedFormat(), + } + + console.log("[Migration] All migrations complete:", results) + return results +} + diff --git a/apps/fabrikanabytok/lib/db/mongodb.ts b/apps/fabrikanabytok/lib/db/mongodb.ts new file mode 100644 index 0000000..490d17e --- /dev/null +++ b/apps/fabrikanabytok/lib/db/mongodb.ts @@ -0,0 +1,58 @@ +/** + * MongoDB Connection Manager + * + * SERVER-ONLY MODULE + * This file should ONLY be imported in: + * - Server Components (without 'use client') + * - Server Actions (with 'use server') + * - API Routes + * + * NEVER import in client components + * + * All client components access data through Server Actions + */ + +import { MongoClient, type Db } from "mongodb" + +const uri = process.env.MONGODB_URI || "mongodb://localhost:27017/fabrika_nabytok" +const options = {} + +let client: MongoClient +let clientPromise: Promise + +declare global { + var _mongoClientPromise: Promise | undefined +} + +if (process.env.NODE_ENV === "development") { + if (!global._mongoClientPromise) { + client = new MongoClient(uri, options) + global._mongoClientPromise = client.connect() + } + clientPromise = global._mongoClientPromise +} else { + client = new MongoClient(uri, options) + clientPromise = client.connect() +} + +export default clientPromise + +export async function getDb(): Promise { + const client = await clientPromise + return client.db(process.env.MONGODB_DB || "fabrika_nabytok") +} + +export async function checkDatabaseConnection(): Promise { + try { + const client = await clientPromise + await client.db().admin().ping() + return true + } catch (error) { + console.error("[MongoDB] Connection failed:", error) + return false + } +} + +export async function getClient(): Promise { + return await clientPromise +} \ No newline at end of file diff --git a/apps/fabrikanabytok/lib/db/setup.ts b/apps/fabrikanabytok/lib/db/setup.ts new file mode 100644 index 0000000..fe1f2a2 --- /dev/null +++ b/apps/fabrikanabytok/lib/db/setup.ts @@ -0,0 +1,258 @@ +"use server" + +import { getDb } from "./mongodb" +import { hash } from "bcryptjs" +import type { UserRole } from "@/lib/types/user.types" + +export async function isSystemInitialized(): Promise { + try { + const db = await getDb() + if (!db) { + throw new Error("Database not connected") + } + const systemConfig = await db?.collection("system_config").findOne({ key: "initialized" }) + return systemConfig?.value === true + } catch (error) { + console.error("[Setup] Error checking system initialization:", error) + return false + } +} + +export async function initializeDatabase(adminData: { + email: string + password: string + firstName: string + lastName: string + companyName?: string +}) { + try { + const db = await getDb() + if (!db) { + throw new Error("Database not connected") + } + + // Check if already initialized + const initialized = await isSystemInitialized() + if (initialized) { + throw new Error("A rendszer már inicializálva van") + } + + // Create collections with indexes + await Promise.all([ + // Users collection + db + ?.collection("users") + .createIndex({ email: 1 }, { unique: true }), + db?.collection("users").createIndex({ role: 1 }), + + // Products collection + db + ?.collection("products") + .createIndex({ slug: 1 }, { unique: true }), + db?.collection("products").createIndex({ category: 1 }), + db?.collection("products").createIndex({ tags: 1 }), + db?.collection("products").createIndex({ status: 1 }), + + // Categories collection + db + ?.collection("categories") + .createIndex({ slug: 1 }, { unique: true }), + + // Orders collection + db + ?.collection("orders") + .createIndex({ userId: 1 }), + db?.collection("orders").createIndex({ status: 1 }), + db?.collection("orders").createIndex({ createdAt: -1 }), + + // Subscriptions collection + db + ?.collection("subscriptions") + .createIndex({ userId: 1 }), + db?.collection("subscriptions").createIndex({ status: 1 }), + + // 3D Models collection + db + ?.collection("3d_models") + .createIndex({ category: 1 }), + db?.collection("3d_models").createIndex({ tags: 1 }), + + // Designs collection + db + ?.collection("designs") + .createIndex({ userId: 1 }), + db?.collection("designs").createIndex({ createdAt: -1 }), + + // Credit transactions collection + db + ?.collection("credit_transactions") + .createIndex({ userId: 1 }), + db?.collection("credit_transactions").createIndex({ createdAt: -1 }), + ]) + + // Create superadmin user + const hashedPassword = await hash(adminData.password, 12) + const superAdmin = { + email: adminData.email, + password: hashedPassword, + firstName: adminData.firstName, + lastName: adminData.lastName, + role: "superadmin" as UserRole, + status: "active", + emailVerified: true, + avatar: null, + phone: null, + address: null, + credits: 0, + subscription: null, + metadata: { + companyName: adminData.companyName || null, + }, + createdAt: new Date(), + updatedAt: new Date(), + } + + await db?.collection("users")?.insertOne(superAdmin) + + // Create default subscription plans + const defaultPlans = [ + { + name: "Ingyenes", + slug: "free", + description: "Alapvető funkciók korlátozott hozzáféréssel", + price: 0, + billingCycle: "monthly", + currency: "HUF", + features: { + maxProjects: 1, + exportFormats: ["png"], + aiSuggestionsQuota: 5, + modelLibraryAccess: "basic", + collaborativeDesign: false, + prioritySupport: false, + discountPercentage: 0, + }, + monthlyCredits: 10, + trialDays: 0, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + name: "Prémium", + slug: "premium", + description: "Teljes hozzáférés minden funkcióhoz", + price: 9900, + billingCycle: "monthly", + currency: "HUF", + features: { + maxProjects: 10, + exportFormats: ["png", "pdf", "glb"], + aiSuggestionsQuota: 100, + modelLibraryAccess: "full", + collaborativeDesign: true, + prioritySupport: true, + discountPercentage: 10, + }, + monthlyCredits: 100, + trialDays: 14, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + name: "Professzionális", + slug: "professional", + description: "Nagykereskedelmi árak és korlátlan lehetőségek", + price: 29900, + billingCycle: "monthly", + currency: "HUF", + features: { + maxProjects: -1, // unlimited + exportFormats: ["png", "pdf", "glb", "dwg"], + aiSuggestionsQuota: -1, // unlimited + modelLibraryAccess: "full", + collaborativeDesign: true, + prioritySupport: true, + discountPercentage: 25, + }, + monthlyCredits: 500, + trialDays: 30, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + ] + + await db?.collection("subscription_plans")?.insertMany(defaultPlans) + + // Create default categories + const defaultCategories = [ + { + name: "Alsó szekrények", + slug: "also-szekreny", + description: "Moduláris alsó szekrények konyhai használatra", + image: "/kitchen-base-cabinet.jpg", + icon: "cabinet", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + name: "Felső szekrények", + slug: "felso-szekreny", + description: "Fali szekrények optimális tároláshoz", + image: "/kitchen-wall-cabinet.png", + icon: "cabinet", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + name: "Sarok elemek", + slug: "sarok-elemek", + description: "Speciális sarok megoldások", + image: "/corner-kitchen-cabinet.jpg", + icon: "corner", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + name: "Készülékek", + slug: "keszulekek", + description: "Beépíthető konyhai készülékek", + image: "/built-in-kitchen-appliances.jpg", + icon: "appliance", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + name: "Munkalapok", + slug: "munkalapok", + description: "Minőségi munkalapok különböző anyagokból", + image: "/kitchen-countertop.png", + icon: "countertop", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }, + ] + + await db?.collection("categories")?.insertMany(defaultCategories) + + // Mark system as initialized + await db?.collection("system_config")?.insertOne({ + key: "initialized", + value: true, + initializedAt: new Date(), + initializedBy: adminData.email, + }) + + return { success: true, message: "Rendszer sikeresen inicializálva" } + } catch (error) { + console.error("[Setup] Database initialization failed:", error) + throw new Error("Adatbázis inicializálás sikertelen: " + (error as Error).message) + } +} diff --git a/apps/fabrikanabytok/lib/socket/events.ts b/apps/fabrikanabytok/lib/socket/events.ts new file mode 100644 index 0000000..260e146 --- /dev/null +++ b/apps/fabrikanabytok/lib/socket/events.ts @@ -0,0 +1,134 @@ +/** + * WebSocket Event Emitters + * Helper functions to emit real-time events + */ + +import type { + ManufacturingOrderEvent, + ProductionAssignmentEvent, + InvoiceEvent, + ImportProgressEvent, +} from "@/lib/types/socket.types" + +/** + * Get Socket.IO instance + * In production, this should connect to your WebSocket server + */ +function getSocketIO() { + // This would be your Socket.IO instance + // For now, we'll return a mock that logs events + return { + emit: (event: string, data: any) => { + if (process.env.NODE_ENV === "development") { + console.log(`[WebSocket] ${event}:`, data) + } + // TODO: Implement actual Socket.IO emission + // io.emit(event, data) + }, + to: (room: string) => ({ + emit: (event: string, data: any) => { + if (process.env.NODE_ENV === "development") { + console.log(`[WebSocket] ${event} to ${room}:`, data) + } + // TODO: Implement actual Socket.IO room emission + // io.to(room).emit(event, data) + }, + }), + } +} + +/** + * Emit manufacturing order status change + */ +export function emitOrderStatusChange(event: ManufacturingOrderEvent) { + const io = getSocketIO() + + // Emit to all admins + io.to("admin").emit("manufacturing_order:status_changed", event) + + // Emit to specific customer if they're online + io.to(`customer:${event.orderId}`).emit("order:status_changed", { + orderNumber: event.orderNumber, + status: event.status, + timestamp: event.timestamp, + }) +} + +/** + * Emit production assignment event + */ +export function emitProductionAssignment(event: ProductionAssignmentEvent) { + const io = getSocketIO() + + // Notify the assigned employee + io.to(`employee:${event.employeeId}`).emit("production:assignment", event) + + // Notify production managers + io.to("production_manager").emit("production:activity", event) +} + +/** + * Emit invoice event + */ +export function emitInvoiceEvent(event: InvoiceEvent) { + const io = getSocketIO() + + // Notify admins + io.to("admin").emit("invoice:event", event) + + // Notify customer + io.to(`customer:${event.customerId}`).emit("invoice:update", { + invoiceNumber: event.invoiceNumber, + status: event.status, + eventType: event.eventType, + timestamp: event.timestamp, + }) +} + +/** + * Emit import progress + */ +export function emitImportProgress(event: ImportProgressEvent) { + const io = getSocketIO() + + // Notify admins watching this import + io.to(`import:${event.batchId}`).emit("import:progress", event) +} + +/** + * Notify employees of new assignments + */ +export function notifyNewAssignment(employeeId: string, orderNumber: string, itemName: string) { + const io = getSocketIO() + + io.to(`employee:${employeeId}`).emit("notification", { + type: "new_assignment", + title: "Új feladat", + message: `Új feladat hozzárendelve: ${itemName} (${orderNumber})`, + timestamp: new Date(), + }) +} + +/** + * Broadcast system notification + */ +export function broadcastSystemNotification( + type: "info" | "warning" | "error" | "success", + message: string, + target: "all" | "admin" | "employees" | "customers" = "all" +) { + const io = getSocketIO() + + const notification = { + type, + message, + timestamp: new Date(), + } + + if (target === "all") { + io.emit("system:notification", notification) + } else { + io.to(target).emit("system:notification", notification) + } +} + diff --git a/apps/fabrikanabytok/lib/store/planner-store.ts b/apps/fabrikanabytok/lib/store/planner-store.ts new file mode 100644 index 0000000..17e895d --- /dev/null +++ b/apps/fabrikanabytok/lib/store/planner-store.ts @@ -0,0 +1,569 @@ +import { create } from "zustand" +import type { PlacedObject, PlacedElement, PlacedPart, DesignLayer, SnapSettings, RenderSettings } from "@/lib/types/planner.types" + +interface HistoryState { + placedObjects: PlacedObject[] + layers: DesignLayer[] + timestamp: number + description?: string // Optional description of the change +} + +interface PlannerStore { + // Core state + placedObjects: PlacedObject[] + selectedObjectId: string | null + selectedElementId: string | null // Selected element within object + selectedPartId: string | null // Selected part within element + draggedProduct: any | null + + // Tool modes + toolMode: "select" | "move" | "rotate" | "scale" | "delete" | "measure" | "paint" + + // Snap settings + snapSettings: SnapSettings + + // Render settings + renderSettings: RenderSettings + + // Layers + layers: DesignLayer[] + activeLayerId: string | null + + // View settings + showGrid: boolean + showMeasurements: boolean + showBoundingBoxes: boolean + showSnapGuides: boolean + + // History + history: HistoryState[] + historyIndex: number + + // Multi-selection + selectedObjectIds: string[] + + // Camera + cameraMode: "perspective" | "orthographic" | "walkthrough" + + // Object operations + addObject: (obj: PlacedObject) => void + removeObject: (id: string) => void + updateObject: (id: string, updates: Partial) => void + selectObject: (id: string | null) => void + selectMultipleObjects: (ids: string[]) => void + selectElement: (objectId: string, elementId: string) => void + selectPart: (objectId: string, elementId: string, partId: string) => void + + // Hierarchical operations + groupObjects: (objectIds: string[]) => void + ungroupObject: (groupId: string) => void + setParent: (childId: string, parentId: string | null) => void + lockObject: (id: string, locked: boolean) => void + toggleVisibility: (id: string) => void + + // Element operations + updateElement: (objectId: string, elementId: string, updates: Partial) => void + toggleElementVisibility: (objectId: string, elementId: string) => void + + // Part operations + updatePart: (objectId: string, elementId: string, partId: string, updates: Partial) => void + togglePartVisibility: (objectId: string, elementId: string, partId: string) => void + + // Layer operations + addLayer: (name: string) => void + removeLayer: (id: string) => void + setActiveLayer: (id: string) => void + toggleLayerVisibility: (id: string) => void + toggleLayerLock: (id: string) => void + moveObjectToLayer: (objectId: string, layerId: string) => void + + // Tool operations + setDraggedProduct: (product: any | null) => void + setToolMode: (mode: "select" | "move" | "rotate" | "scale" | "delete" | "measure" | "paint") => void + setCameraMode: (mode: "perspective" | "orthographic" | "walkthrough") => void + + // Snap operations + updateSnapSettings: (updates: Partial) => void + toggleSnapToGrid: () => void + setGridSize: (size: number) => void + + // Render operations + updateRenderSettings: (updates: Partial) => void + + // View toggles + toggleGrid: () => void + toggleMeasurements: () => void + toggleBoundingBoxes: () => void + toggleSnapGuides: () => void + + // History operations + undo: () => void + redo: () => void + saveToHistory: (description?: string) => void + + // Utility operations + duplicateObject: (id: string) => void + duplicateSelection: () => void + deleteSelection: () => void + getObjectById: (id: string) => PlacedObject | undefined + getChildObjects: (parentId: string) => PlacedObject[] +} + +export const usePlannerStore = create((set, get) => ({ + // Initial state + placedObjects: [], + selectedObjectId: null, + selectedElementId: null, + selectedPartId: null, + draggedProduct: null, + toolMode: "select", + snapSettings: { + enabled: true, + gridSize: 0.5, + snapToObjects: true, + snapToWalls: true, + snapAngle: 15, + snapDistance: 0.2, + showGuides: true, + }, + renderSettings: { + quality: "preview", + shadows: true, + shadowQuality: "medium", + ambientOcclusion: false, + antialiasing: true, + postProcessing: false, + lightingPreset: "day", + }, + layers: [ + { id: "default", name: "Default", visible: true, locked: false, color: "#3b82f6", order: 0, objectIds: [] } + ], + activeLayerId: "default", + showGrid: true, + showMeasurements: false, + showBoundingBoxes: false, + showSnapGuides: true, + history: [], + historyIndex: -1, + selectedObjectIds: [], + cameraMode: "perspective", + + // Object operations + addObject: (obj) => { + const state = get() + const enhancedObj: PlacedObject = { + ...obj, + children: obj.children || [], + isGroup: obj.isGroup || false, + locked: obj.locked || false, + visible: obj.visible !== false, + elements: obj.elements || [], + tags: obj.tags || [], + layer: obj.layer || state.activeLayerId || "default", + opacity: obj.opacity || 1, + castShadow: obj.castShadow !== false, + receiveShadow: obj.receiveShadow !== false, + } + + set((state) => ({ + placedObjects: [...state.placedObjects, enhancedObj], + layers: state.layers.map((layer) => + layer.id === enhancedObj.layer + ? { ...layer, objectIds: [...layer.objectIds, enhancedObj.id] } + : layer + ), + })) + get().saveToHistory("Added object") + }, + + removeObject: (id) => { + const state = get() + const obj = state.placedObjects.find((o) => o.id === id) + if (!obj) return + + // Also remove all children + const idsToRemove = [id, ...get().getChildObjects(id).map((c) => c.id)] + + set((state) => ({ + placedObjects: state.placedObjects.filter((o) => !idsToRemove.includes(o.id)), + selectedObjectId: state.selectedObjectId === id ? null : state.selectedObjectId, + selectedObjectIds: state.selectedObjectIds.filter((oid) => !idsToRemove.includes(oid)), + layers: state.layers.map((layer) => ({ + ...layer, + objectIds: layer.objectIds.filter((oid) => !idsToRemove.includes(oid)), + })), + })) + get().saveToHistory("Removed object") + }, + + updateObject: (id, updates) => { + set((state) => ({ + placedObjects: state.placedObjects.map((o) => (o.id === id ? { ...o, ...updates } : o)), + })) + }, + + selectObject: (id) => { + set({ + selectedObjectId: id, + selectedObjectIds: id ? [id] : [], + selectedElementId: null, + selectedPartId: null, + }) + }, + + selectMultipleObjects: (ids) => { + set({ selectedObjectIds: ids, selectedObjectId: ids[0] || null }) + }, + + selectElement: (objectId, elementId) => { + set({ selectedObjectId: objectId, selectedElementId: elementId, selectedPartId: null }) + }, + + selectPart: (objectId, elementId, partId) => { + set({ selectedObjectId: objectId, selectedElementId: elementId, selectedPartId: partId }) + }, + + // Hierarchical operations + groupObjects: (objectIds) => { + const state = get() + if (objectIds.length < 2) return + + const objects = objectIds.map((id) => state.placedObjects.find((o) => o.id === id)).filter(Boolean) as PlacedObject[] + + // Calculate center position + const centerPos: [number, number, number] = [ + objects.reduce((sum, o) => sum + o.position[0], 0) / objects.length, + objects.reduce((sum, o) => sum + o.position[1], 0) / objects.length, + objects.reduce((sum, o) => sum + o.position[2], 0) / objects.length, + ] + + const groupObj: PlacedObject = { + id: `group-${Date.now()}`, + name: "Group", + modelUrl: "", + position: centerPos, + rotation: [0, 0, 0], + scale: [1, 1, 1], + dimensions: { width: 0, height: 0, depth: 0 }, + price: objects.reduce((sum, o) => sum + o.price, 0), + materials: {}, + colors: {}, + children: objectIds, + isGroup: true, + locked: false, + visible: true, + elements: [], + tags: [], + layer: state.activeLayerId || "default", + opacity: 1, + castShadow: false, + receiveShadow: false, + } + + set((state) => ({ + placedObjects: [ + ...state.placedObjects.map((o) => + objectIds.includes(o.id) ? { ...o, parentId: groupObj.id } : o + ), + groupObj, + ], + })) + get().saveToHistory("Grouped objects") + }, + + ungroupObject: (groupId) => { + const state = get() + const group = state.placedObjects.find((o) => o.id === groupId && o.isGroup) + if (!group) return + + set((state) => ({ + placedObjects: state.placedObjects + .filter((o) => o.id !== groupId) + .map((o) => (o.parentId === groupId ? { ...o, parentId: undefined } : o)), + })) + get().saveToHistory("Ungrouped objects") + }, + + setParent: (childId, parentId) => { + set((state) => ({ + placedObjects: state.placedObjects.map((o) => + o.id === childId ? { ...o, parentId: parentId || undefined } : o + ), + + })) + }, + + lockObject: (id, locked) => { + set((state) => ({ + placedObjects: state.placedObjects.map((o) => (o.id === id ? { ...o, locked } : o)), + })) + }, + + toggleVisibility: (id) => { + set((state) => ({ + placedObjects: state.placedObjects.map((o) => + o.id === id ? { ...o, visible: !o.visible } : o + ), + })) + }, + + // Element operations + updateElement: (objectId, elementId, updates) => { + set((state) => ({ + placedObjects: state.placedObjects.map((o) => + o.id === objectId + ? { + ...o, + elements: o.elements.map((e) => (e.id === elementId ? { ...e, ...updates } : e)), + } + : o + ), + })) + }, + + toggleElementVisibility: (objectId, elementId) => { + const state = get() + const obj = state.placedObjects.find((o) => o.id === objectId) + if (!obj) return + + get().updateElement(objectId, elementId, { + visible: !obj.elements.find((e) => e.id === elementId)?.visible, + }) + }, + + // Part operations + updatePart: (objectId, elementId, partId, updates) => { + set((state) => ({ + placedObjects: state.placedObjects.map((o) => + o.id === objectId + ? { + ...o, + elements: o.elements.map((e) => + e.id === elementId + ? { + ...e, + parts: e.parts.map((p) => (p.id === partId ? { ...p, ...updates } : p)), + } + : e + ), + } + : o + ), + })) + }, + + togglePartVisibility: (objectId, elementId, partId) => { + const state = get() + const obj = state.placedObjects.find((o) => o.id === objectId) + if (!obj) return + + const element = obj.elements.find((e) => e.id === elementId) + if (!element) return + + const part = element.parts.find((p) => p.id === partId) + if (!part) return + + get().updatePart(objectId, elementId, partId, { visible: !part.visible }) + }, + + // Layer operations + addLayer: (name) => { + const id = `layer-${Date.now()}` + set((state) => ({ + layers: [ + ...state.layers, + { + id, + name, + visible: true, + locked: false, + color: `#${Math.floor(Math.random() * 16777215).toString(16)}`, + order: state.layers.length, + objectIds: [], + }, + ], + })) + }, + + removeLayer: (id) => { + const state = get() + if (id === "default") return // Can't remove default layer + + // Move objects to default layer + const layer = state.layers.find((l) => l.id === id) + if (layer) { + layer.objectIds.forEach((objId) => { + get().moveObjectToLayer(objId, "default") + }) + } + + set((state) => ({ + layers: state.layers.filter((l) => l.id !== id), + activeLayerId: state.activeLayerId === id ? "default" : state.activeLayerId, + })) + }, + + setActiveLayer: (id) => { + set({ activeLayerId: id }) + }, + + toggleLayerVisibility: (id) => { + set((state) => ({ + layers: state.layers.map((l) => (l.id === id ? { ...l, visible: !l.visible } : l)), + placedObjects: state.placedObjects.map((o) => { + const layer = state.layers.find((l) => l.id === id) + return layer?.objectIds.includes(o.id) ? { ...o, visible: !layer.visible } : o + }), + })) + }, + + toggleLayerLock: (id) => { + set((state) => ({ + layers: state.layers.map((l) => (l.id === id ? { ...l, locked: !l.locked } : l)), + placedObjects: state.placedObjects.map((o) => { + const layer = state.layers.find((l) => l.id === id) + return layer?.objectIds.includes(o.id) ? { ...o, locked: !layer.locked } : o + }), + })) + }, + + moveObjectToLayer: (objectId, layerId) => { + set((state) => ({ + placedObjects: state.placedObjects.map((o) => (o.id === objectId ? { ...o, layer: layerId } : o)), + layers: state.layers.map((l) => ({ + ...l, + objectIds: + l.id === layerId + ? [...l.objectIds.filter((id) => id !== objectId), objectId] + : l.objectIds.filter((id) => id !== objectId), + })), + })) + }, + + // Tool operations + setDraggedProduct: (product) => set({ draggedProduct: product }), + + setToolMode: (mode) => set({ toolMode: mode }), + + setCameraMode: (mode) => set({ cameraMode: mode }), + + // Snap operations + updateSnapSettings: (updates) => { + set((state) => ({ + snapSettings: { ...state.snapSettings, ...updates }, + })) + }, + + toggleSnapToGrid: () => { + set((state) => ({ + snapSettings: { ...state.snapSettings, enabled: !state.snapSettings.enabled }, + })) + }, + + setGridSize: (size) => { + set((state) => ({ + snapSettings: { ...state.snapSettings, gridSize: size }, + })) + }, + + // Render operations + updateRenderSettings: (updates) => { + set((state) => ({ + renderSettings: { ...state.renderSettings, ...updates }, + })) + }, + + // View toggles + toggleGrid: () => set((state) => ({ showGrid: !state.showGrid })), + + toggleMeasurements: () => set((state) => ({ showMeasurements: !state.showMeasurements })), + + toggleBoundingBoxes: () => set((state) => ({ showBoundingBoxes: !state.showBoundingBoxes })), + + toggleSnapGuides: () => set((state) => ({ showSnapGuides: !state.showSnapGuides })), + + // History operations + saveToHistory: (description) => { + set((state) => { + const newHistory = state.history.slice(0, state.historyIndex + 1) + newHistory.push({ + placedObjects: JSON.parse(JSON.stringify(state.placedObjects)), + layers: JSON.parse(JSON.stringify(state.layers)), + timestamp: Date.now(), + description, + }) + // Keep only last 100 states + if (newHistory.length > 100) newHistory.shift() + return { + history: newHistory, + historyIndex: newHistory.length - 1, + } + }) + }, + + undo: () => { + set((state) => { + if (state.historyIndex <= 0) return state + const previousState = state.history[state.historyIndex - 1] + return { + placedObjects: JSON.parse(JSON.stringify(previousState.placedObjects)), + layers: JSON.parse(JSON.stringify(previousState.layers)), + historyIndex: state.historyIndex - 1, + selectedObjectId: null, + selectedElementId: null, + selectedPartId: null, + } + }) + }, + + redo: () => { + set((state) => { + if (state.historyIndex >= state.history.length - 1) return state + const nextState = state.history[state.historyIndex + 1] + return { + placedObjects: JSON.parse(JSON.stringify(nextState.placedObjects)), + layers: JSON.parse(JSON.stringify(nextState.layers)), + historyIndex: state.historyIndex + 1, + selectedObjectId: null, + selectedElementId: null, + selectedPartId: null, + } + }) + }, + + // Utility operations + duplicateObject: (id) => { + const state = get() + const obj = state.placedObjects.find((o) => o.id === id) + if (!obj) return + + const newObj: PlacedObject = { + ...JSON.parse(JSON.stringify(obj)), + id: `obj-${Date.now()}-${Math.random()}`, + position: [obj.position[0] + 1, obj.position[1], obj.position[2] + 1] as [number, number, number], + children: [], // Don't duplicate children + parentId: undefined, + } + + get().addObject(newObj) + set({ selectedObjectId: newObj.id }) + }, + + duplicateSelection: () => { + const state = get() + state.selectedObjectIds.forEach((id) => get().duplicateObject(id)) + }, + + deleteSelection: () => { + const state = get() + state.selectedObjectIds.forEach((id) => get().removeObject(id)) + }, + + getObjectById: (id) => { + return get().placedObjects.find((o) => o.id === id) + }, + + getChildObjects: (parentId) => { + return get().placedObjects.filter((o) => o.parentId === parentId) + }, +})) diff --git a/apps/fabrikanabytok/lib/utils.ts b/apps/fabrikanabytok/lib/utils.ts new file mode 100644 index 0000000..32314a2 --- /dev/null +++ b/apps/fabrikanabytok/lib/utils.ts @@ -0,0 +1,21 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +/** + * Format bytes to human-readable string + */ +export function formatBytes(bytes: number, decimals: number = 2): string { + if (bytes === 0) return '0 Bytes' + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] +} diff --git a/apps/fabrikanabytok/lib/websocket/socket-client.ts b/apps/fabrikanabytok/lib/websocket/socket-client.ts new file mode 100644 index 0000000..29504d8 --- /dev/null +++ b/apps/fabrikanabytok/lib/websocket/socket-client.ts @@ -0,0 +1,183 @@ +"use client" + +import { io, type Socket } from "socket.io-client" +import { logger } from "../utils/logger" + +let socket: Socket | null = null +let reconnectAttempts = 0 +const MAX_RECONNECT_ATTEMPTS = 10 + +interface SocketConfig { + token?: string + clientId?: string +} + +export function initializeSocket(config?: SocketConfig): Socket { + if (socket?.connected) { + return socket + } + + const url = process.env.NEXT_PUBLIC_SOCKET_URL || (typeof window !== "undefined" ? window.location.origin : "") + + socket = io(url, { + path: "/socket.io", + // Try WebSocket first, fallback to polling + transports: ["websocket", "polling"], + // Upgrade from polling to WebSocket when possible + upgrade: true, + // Auth configuration + auth: { + token: config?.token, + clientId: config?.clientId || generateClientId(), + }, + // Reconnection strategy + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + reconnectionAttempts: MAX_RECONNECT_ATTEMPTS, + // Timeouts + timeout: 20000, + // Auto-connect + autoConnect: true + }) + + // Connection established + socket.on("connect", () => { + reconnectAttempts = 0 + const transport = socket?.io.engine.transport.name + logger.info(`[Socket.IO] Connected via ${transport}:`, socket?.id) + + // Track connection type for analytics + if (typeof window !== "undefined") { + (window as any).__socketTransport = transport + } + }) + + // Connection failed + socket.on("connect_error", (error) => { + reconnectAttempts++ + logger.error(`[Socket.IO] Connection error (attempt ${reconnectAttempts}):`, error.message) + + // If WebSocket fails, force polling + if (reconnectAttempts === 3 && socket) { + logger.warn("[Socket.IO] WebSocket failing, forcing HTTP long polling") + socket.io.opts.transports = ["polling"] + socket.connect() + } + + // If all attempts fail, notify user + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + logger.error("[Socket.IO] Max reconnection attempts reached. Real-time features disabled.") + if (typeof window !== "undefined") { + (window as any).__socketDisabled = true + } + } + }) + + // Disconnected + socket.on("disconnect", (reason) => { + logger.warn(`[Socket.IO] Disconnected: ${reason}`) + + // If server initiated disconnect, don't reconnect + if (reason === "io server disconnect") { + socket?.connect() + } + }) + + // Transport changed (WebSocket ↔ Polling) + socket.io.engine.on("upgrade", (transport) => { + logger.info(`[Socket.IO] Upgraded to ${transport.name}`) + }) + + socket.io.engine.on("close", (reason) => { + logger.warn(`[Socket.IO] Transport closed: ${reason}`) + }) + + // Connection recovered (after temporary disconnect) + socket.on("connect", () => { + if (reconnectAttempts > 0) { + logger.info("[Socket.IO] Connection recovered!") + } + }) + + return socket +} + +export function getSocket(): Socket | null { + return socket +} + +export function disconnectSocket(): void { + if (socket) { + socket.disconnect() + socket = null + } +} + +function generateClientId(): string { + return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` +} + +// Helper functions for common socket operations +export function subscribeToChannel(channel: string): void { + socket?.emit("subscribe", channel) +} + +export function unsubscribeFromChannel(channel: string): void { + socket?.emit("unsubscribe", channel) +} + +export function emitEvent(event: string, data: any): void { + socket?.emit(event, data) +} + +export function onEvent(event: string, callback: (data: any) => void): void { + socket?.on(event, callback) +} + +export function offEvent(event: string, callback?: (data: any) => void): void { + if (callback) { + socket?.off(event, callback) + } else { + socket?.off(event) + } +} + +/** + * Track user activity via Socket.IO + */ +export function trackActivity(eventType: string, eventData: any): void { + socket?.emit("track:activity", { + eventType, + eventData, + timestamp: Date.now(), + page: typeof window !== "undefined" ? window.location.pathname : undefined, + }) +} + +/** + * Track feature usage + */ +export function trackFeature(feature: string, action: string, metadata?: any): void { + socket?.emit("track:feature", { + feature, + action, + metadata, + timestamp: Date.now(), + }) +} + +/** + * Get connection status + */ +export function getConnectionStatus(): { + connected: boolean + transport: string | null + disabled: boolean +} { + return { + connected: socket?.connected || false, + transport: socket?.io?.engine?.transport?.name || null, + disabled: typeof window !== "undefined" ? (window as any).__socketDisabled || false : false, + } +} diff --git a/apps/fabrikanabytok/lib/websocket/socket-server.ts b/apps/fabrikanabytok/lib/websocket/socket-server.ts new file mode 100644 index 0000000..7cdef52 --- /dev/null +++ b/apps/fabrikanabytok/lib/websocket/socket-server.ts @@ -0,0 +1,713 @@ +import type { Server as SocketIOServer, Socket } from "socket.io" +import { logger } from "../utils/logger" +import { verifyToken } from "../utils/jwt" +import { logActivity } from "../db/analytics" +import { getConfig } from "../config" +import { getDb } from "../db/mongodb" +import { + trackActivity, + trackFeatureUsage, + checkSubscriptionLimit, + detectSuspiciousActivity +} from "../utils/activity-tracker" +import type { UserActivityEvent, FeatureUsageEvent } from "../types/socket.types" + +let io: SocketIOServer | null = null + +const connectionsByIP = new Map>() +const connectionsByClientId = new Map() +const roomSubscriptions = new Map>() // room -> Set of clientIds + +export function setupSocketIO(socketIO: SocketIOServer) { + io = socketIO + + io.use(async (socket, next) => { + try { + const token = socket.handshake.auth.token + const clientId = socket.handshake.auth.clientId || socket.id + const ipAddress = socket.handshake.address + + socket.data.clientId = clientId + socket.data.ipAddress = ipAddress + + const existingConnections = connectionsByIP.get(ipAddress) || new Set() + const config = await getConfig() + const maxConnectionsPerIP = config.websocket?.maxConnectionsPerIP || 5 + + if (existingConnections.size >= maxConnectionsPerIP && !existingConnections.has(clientId)) { + logger.warn(`Max connections reached for IP: ${ipAddress}`) + return next(new Error("Maximum connections per IP reached")) + } + + if (token) { + try { + const decoded = await verifyToken(token) + socket.data.user = decoded + socket.data.isAdmin = decoded.role === "admin" || decoded.role === "superadmin" + socket.data.isSuperAdmin = decoded.role === "superadmin" + } catch (error) { + logger.warn("Invalid token provided", { clientId }) + } + } + + next() + } catch (error) { + logger.error("Socket authentication error:", error as Record) + next(new Error("Authentication failed")) + } + }) + + io.on("connection", async (socket: Socket) => { + const clientId = socket.data.clientId + const ipAddress = socket.data.ipAddress + const isAdmin = socket.data.isAdmin || false + const isSuperAdmin = socket.data.isSuperAdmin || false + + logger.info(`Client connected: ${clientId} from ${ipAddress} (Admin: ${isAdmin}, SuperAdmin: ${isSuperAdmin})`) + + if (!connectionsByIP.has(ipAddress)) { + connectionsByIP.set(ipAddress, new Set()) + } + connectionsByIP.get(ipAddress)!.add(clientId) + connectionsByClientId.set(clientId, socket) + + if (isSuperAdmin) { + socket.join("superadmin") + } + if (isAdmin) { + socket.join("admin") + } + socket.join("public") + + // Join user's personal notification channel for gamification and other user-specific events + const userId = socket.data.user?.id || socket.data.clientId + socket.join(`user-${userId}`) + logger.info(`User ${userId} joined personal notification channel`) + + await logActivity("websocket_connection", "Client connected", { + type: "websocket_connection", + metadata: { clientId, ipAddress, isAdmin, isSuperAdmin }, + }) + + socket.on("ping", (callback) => { + if (typeof callback === "function") { + callback({ + pong: true, + timestamp: Date.now(), + transport: socket.conn.transport.name, // WebSocket or polling + }) + } + }) + + // ===== ACTIVITY TRACKING EVENTS ===== + + socket.on("track:activity", async (data: { + eventType: string + eventData: any + timestamp: number + page?: string + }) => { + const userId = socket.data.user?.id || socket.data.clientId + + const activityEvent: UserActivityEvent = { + userId, + sessionId: socket.id, + eventType: data.eventType as any, + eventData: data.eventData, + timestamp: new Date(data.timestamp), + page: data.page, + userAgent: socket.handshake.headers["user-agent"], + ipAddress: socket.data.ipAddress, + } + + await trackActivity(activityEvent) + + // Check for suspicious patterns + const suspiciousCheck = await detectSuspiciousActivity(userId, socket.id) + if (suspiciousCheck.suspicious) { + socket.emit("security:warning", { + reasons: suspiciousCheck.reasons, + severity: "medium", + }) + + // Notify admins + io!.to("admin").emit("security:alert", { + userId, + reasons: suspiciousCheck.reasons, + timestamp: Date.now(), + }) + } + }) + + socket.on("track:feature", async (data: { + feature: string + action: string + metadata?: any + timestamp: number + }) => { + const userId = socket.data.user?.id || socket.data.clientId + + // Check subscription limits BEFORE allowing action + const limitCheck = await checkSubscriptionLimit(userId, data.feature) + + if (!limitCheck.allowed) { + socket.emit("subscription:limit_reached", { + feature: data.feature, + currentUsage: limitCheck.currentUsage, + limit: limitCheck.limit, + message: `You've reached your ${data.feature} limit. Upgrade to continue.`, + }) + return + } + + const featureEvent: FeatureUsageEvent = { + userId, + feature: data.feature, + action: data.action, + metadata: data.metadata, + subscriptionTier: socket.data.user?.subscriptionTier || "free", + timestamp: new Date(data.timestamp), + } + + await trackFeatureUsage(featureEvent) + + // Warn user if approaching limit (80%) + if (limitCheck.percentage >= 80 && limitCheck.percentage < 100) { + socket.emit("subscription:usage_warning", { + feature: data.feature, + currentUsage: limitCheck.currentUsage, + limit: limitCheck.limit, + percentage: limitCheck.percentage, + }) + } + }) + + // ===== END ACTIVITY TRACKING EVENTS ===== + + socket.on("subscribe", (channel: string) => { + const config = getConfigSync() + const allowedChannels = config.websocket?.allowedChannels || ["notifications", "updates"] + + if (allowedChannels.includes(channel)) { + socket.join(channel) + + if (!roomSubscriptions.has(channel)) { + roomSubscriptions.set(channel, new Set()) + } + roomSubscriptions.get(channel)!.add(clientId) + + logger.info(`Client ${clientId} subscribed to ${channel}`) + socket.emit("subscribed", { channel }) + } else { + socket.emit("error", { message: "Channel not allowed" }) + } + }) + + socket.on("unsubscribe", (channel: string) => { + socket.leave(channel) + + if (roomSubscriptions.has(channel)) { + roomSubscriptions.get(channel)!.delete(clientId) + } + + logger.info(`Client ${clientId} unsubscribed from ${channel}`) + socket.emit("unsubscribed", { channel }) + }) + + socket.on("ai:coach:start", (data: { userId: string }) => { + logger.info(`AI Coach session started for user: ${data.userId}`) + socket.join(`ai-coach-${data.userId}`) + }) + + socket.on("ai:coach:stop", (data: { userId: string }) => { + logger.info(`AI Coach session stopped for user: ${data.userId}`) + socket.leave(`ai-coach-${data.userId}`) + }) + + socket.on("workspace:join", async (data: { workspaceId: string }) => { + const { workspaceId } = data + socket.join(`workspace-${workspaceId}`) + + const userName = socket.data.user?.name || "Anonymous" + const userId = socket.data.user?.id || socket.data.clientId + + // Notify others in the workspace + socket.to(`workspace-${workspaceId}`).emit("workspace:user-joined", { + userId, + userName, + timestamp: Date.now(), + }) + + // Send current users list to the joining user + const socketsInRoom = await io!.in(`workspace-${workspaceId}`).fetchSockets() + const users = socketsInRoom.map((s) => ({ + userId: s.data.user?.id || s.data.clientId, + userName: s.data.user?.name || "Anonymous", + socketId: s.id, + })) + + socket.emit("workspace:users", { users }) + + logger.info(`User ${userName} joined workspace ${workspaceId}`) + }) + + socket.on("workspace:leave", (data: { workspaceId: string }) => { + const { workspaceId } = data + const userName = socket.data.user?.name || "Anonymous" + const userId = socket.data.user?.id || socket.data.clientId + + socket.leave(`workspace-${workspaceId}`) + socket.to(`workspace-${workspaceId}`).emit("workspace:user-left", { + userId, + userName, + timestamp: Date.now(), + }) + + logger.info(`User ${userName} left workspace ${workspaceId}`) + }) + + socket.on("workspace:send-message", async (data: { workspaceId: string; message: string }) => { + const { workspaceId, message } = data + const userName = socket.data.user?.name || "Anonymous" + const userId = socket.data.user?.id || socket.data.clientId + + const messageData = { + userId, + userName, + message, + createdAt: new Date().toISOString(), + } + + // Broadcast to all users in the workspace including sender + io!.to(`workspace-${workspaceId}`).emit("workspace:message", messageData) + + // Save message to database + try { + const db = await getDb() + await db?.collection("workspace_messages")?.insertOne({ + workspaceId, + ...messageData, + }) + } catch (error) { + logger.error("Failed to save workspace message:", error as Record) + } + }) + + socket.on( + "workspace:document-update", + (data: { workspaceId: string; documentId: string; content: any; cursorPosition?: any }) => { + const { workspaceId, documentId, content, cursorPosition } = data + const userName = socket.data.user?.name || "Anonymous" + const userId = socket.data.user?.id || socket.data.clientId + + // Broadcast document update to others (not sender) + socket.to(`workspace-${workspaceId}`).emit("workspace:document-updated", { + documentId, + content, + userId, + userName, + cursorPosition, + timestamp: Date.now(), + }) + }, + ) + + socket.on("workspace:cursor-move", (data: { workspaceId: string; documentId: string; position: any }) => { + const { workspaceId, documentId, position } = data + const userName = socket.data.user?.name || "Anonymous" + const userId = socket.data.user?.id || socket.data.clientId + + // Broadcast cursor position to others + socket.to(`workspace-${workspaceId}`).emit("workspace:cursor-moved", { + documentId, + userId, + userName, + position, + timestamp: Date.now(), + }) + }) + + socket.on("workspace:whiteboard-update", (data: { workspaceId: string; shapes: any }) => { + const { workspaceId, shapes } = data + + // Broadcast whiteboard update to others + socket.to(`workspace-${workspaceId}`).emit("workspace:whiteboard-updated", { + shapes, + timestamp: Date.now(), + }) + }) + + socket.on("workspace:typing-start", (data: { workspaceId: string }) => { + const { workspaceId } = data + const userName = socket.data.user?.name || "Anonymous" + const userId = socket.data.user?.id || socket.data.clientId + + socket.to(`workspace-${workspaceId}`).emit("workspace:user-typing", { + userId, + userName, + isTyping: true, + }) + }) + + socket.on("workspace:typing-stop", (data: { workspaceId: string }) => { + const { workspaceId } = data + const userName = socket.data.user?.name || "Anonymous" + const userId = socket.data.user?.id || socket.data.clientId + + socket.to(`workspace-${workspaceId}`).emit("workspace:user-typing", { + userId, + userName, + isTyping: false, + }) + }) + + // ===== PLANNER COLLABORATION EVENTS ===== + + socket.on("design:join", async (data: { designId: string; userId: string; userName: string }) => { + const { designId, userId, userName } = data + const room = `design-${designId}` + + socket.join(room) + + // Notify others in the design + socket.to(room).emit("design:user-joined", { + userId, + userName, + timestamp: Date.now(), + }) + + // Send current users list + const socketsInRoom = await io!.in(room).fetchSockets() + const users = socketsInRoom + .filter((s) => s.id !== socket.id) + .map((s) => ({ + userId: s.data.user?.id || s.data.clientId, + userName: s.data.user?.name || "Anonymous", + socketId: s.id, + })) + + socket.emit("design:users", { users }) + + logger.info(`User ${userName} joined design ${designId}`) + }) + + socket.on("design:leave", (data: { designId: string; userId: string }) => { + const { designId, userId } = data + const room = `design-${designId}` + const userName = socket.data.user?.name || "Anonymous" + + socket.leave(room) + socket.to(room).emit("design:user-left", { + userId, + userName, + timestamp: Date.now(), + }) + + logger.info(`User ${userName} left design ${designId}`) + }) + + socket.on("design:cursor-move", (data: { + designId: string + position: [number, number, number] + userId: string + userName: string + timestamp: number + }) => { + const { designId, position, userId, userName, timestamp } = data + const room = `design-${designId}` + + // Get user color (assign based on userId hash) + const color = getUserColor(userId) + + // Broadcast to others + socket.to(room).emit("design:cursor-moved", { + userId, + userName, + position, + color, + timestamp, + }) + }) + + socket.on("design:object-update", (data: { + designId: string + objectId: string + updates: any + userId: string + userName: string + }) => { + const { designId, objectId, updates, userId, userName } = data + const room = `design-${designId}` + + // Broadcast to others + socket.to(room).emit("design:object-updated", { + objectId, + updates, + userId, + userName, + timestamp: Date.now(), + }) + + logger.info(`User ${userName} updated object ${objectId} in design ${designId}`) + }) + + socket.on("design:object-add", (data: { + designId: string + object: any + userId: string + }) => { + const { designId, object, userId } = data + const room = `design-${designId}` + + socket.to(room).emit("design:object-added", { + object, + userId, + timestamp: Date.now(), + }) + }) + + socket.on("design:object-remove", (data: { + designId: string + objectId: string + userId: string + }) => { + const { designId, objectId, userId } = data + const room = `design-${designId}` + + socket.to(room).emit("design:object-removed", { + objectId, + userId, + timestamp: Date.now(), + }) + }) + + socket.on("design:selection-change", (data: { + designId: string + objectId: string | null + userId: string + userName: string + }) => { + const { designId, objectId, userId, userName } = data + const room = `design-${designId}` + const color = getUserColor(userId) + + socket.to(room).emit("design:selection-changed", { + userId, + userName, + objectId, + color, + timestamp: Date.now(), + }) + }) + + // ===== END PLANNER COLLABORATION EVENTS ===== + + socket.on("webinar:join", async (data: { webinarId: string; userId: string }) => { + const room = `webinar:${data.webinarId}` + socket.join(room) + + // Broadcast to room + socketIO.to(room).emit("webinar:participant-joined", { + userId: data.userId, + userName: socket.data.userName || "Anonymous", + role: socket.data.role || "attendee", + }) + + console.log(`[v0] User ${data.userId} joined webinar ${data.webinarId}`) + }) + + socket.on("webinar:leave", async (data: { webinarId: string; userId: string }) => { + const room = `webinar:${data.webinarId}` + socket.leave(room) + + socketIO.to(room).emit("webinar:participant-left", { + userId: data.userId, + }) + + console.log(`[v0] User ${data.userId} left webinar ${data.webinarId}`) + }) + + if (isSuperAdmin) { + socket.on("admin:broadcast", (data: { channel: string; event: string; message: any }) => { + io!.to(data.channel).emit(data.event, data.message) + logger.info(`SuperAdmin broadcast to ${data.channel}: ${data.event}`) + + logActivity("admin_broadcast", "SuperAdmin broadcast message", { + type: "admin_broadcast", + metadata: { channel: data.channel, event: data.event, clientId }, + }) + }) + + socket.on("admin:stats", async (callback) => { + const stats = { + totalConnections: io!.engine.clientsCount, + connectionsByIP: Array.from(connectionsByIP.entries()).map(([ip, clients]) => ({ + ip, + count: clients.size, + clients: Array.from(clients), + })), + rooms: Array.from(roomSubscriptions.entries()).map(([room, clients]) => ({ + room, + count: clients.size, + clients: Array.from(clients), + })), + timestamp: Date.now(), + } + + if (typeof callback === "function") { + callback(stats) + } + }) + + socket.on("admin:disconnect_client", (targetClientId: string) => { + const targetSocket = connectionsByClientId.get(targetClientId) + if (targetSocket) { + targetSocket.disconnect(true) + logger.info(`SuperAdmin disconnected client: ${targetClientId}`) + + logActivity("admin_disconnect", "SuperAdmin disconnected client", { + type: "admin_disconnect", + metadata: { targetClientId, adminClientId: clientId }, + }) + } + }) + + socket.on("admin:create_room", (roomName: string) => { + if (!roomSubscriptions.has(roomName)) { + roomSubscriptions.set(roomName, new Set()) + logger.info(`SuperAdmin created room: ${roomName}`) + socket.emit("room_created", { room: roomName }) + } + }) + + socket.on("admin:emit_to_client", (data: { clientId: string; event: string; message: any }) => { + const targetSocket = connectionsByClientId.get(data.clientId) + if (targetSocket) { + targetSocket.emit(data.event, data.message) + logger.info(`SuperAdmin emitted to client ${data.clientId}: ${data.event}`) + } + }) + } + + socket.on("disconnect", (reason) => { + logger.info(`Client disconnected: ${clientId} (${reason})`) + + const ipConnections = connectionsByIP.get(ipAddress) + if (ipConnections) { + ipConnections.delete(clientId) + if (ipConnections.size === 0) { + connectionsByIP.delete(ipAddress) + } + } + connectionsByClientId.delete(clientId) + + // Clean up room subscriptions + roomSubscriptions.forEach((clients) => { + clients.delete(clientId) + }) + + logActivity("websocket_disconnection", "Client disconnected", { + type: "websocket_disconnection", + metadata: { clientId, ipAddress, reason }, + }) + }) + }) + + logger.info("Socket.IO server setup complete") +} + +export function getSocketIO(): SocketIOServer { + if (!io) { + throw new Error("Socket.IO server not initialized") + } + return io +} + +export function emitToChannel(channel: string, event: string, data: any) { + if (io) { + io.to(channel).emit(event, data) + } +} + +export function emitToAdmin(event: string, data: any) { + if (io) { + io.to("admin").emit(event, data) + } +} + +export function emitToSuperAdmin(event: string, data: any) { + if (io) { + io.to("superadmin").emit(event, data) + } +} + +export function emitToAll(event: string, data: any) { + if (io) { + io.emit(event, data) + } +} + +export function emitToClient(clientId: string, event: string, data: any) { + const socket = connectionsByClientId.get(clientId) + if (socket) { + socket.emit(event, data) + } +} + +// Emit to user by userId (preferred for authenticated users) +export function emitToUser(userId: string, event: string, data: any) { + if (io) { + io.to(`user-${userId}`).emit(event, data) + logger.info(`Emitted ${event} to user ${userId}`) + } +} + +export function emitToAICoach(userId: string, event: string, data: any) { + if (io) { + io.to(`ai-coach-${userId}`).emit(event, data) + } +} + +export function emitToWorkspace(workspaceId: string, event: string, data: any) { + if (io) { + io.to(`workspace-${workspaceId}`).emit(event, data) + } +} + +let cachedConfigSync: any = null +function getConfigSync() { + return ( + cachedConfigSync || { + websocket: { + maxConnectionsPerIP: 5, + allowedChannels: ["notifications", "updates", "chat", "admin", "workspace", "webinar"], + }, + } + ) +} + +export function updateCachedConfigSync(config: any) { + cachedConfigSync = config +} + +// Helper function to assign consistent colors to users +function getUserColor(userId: string): string { + const colors = [ + "#3b82f6", // blue + "#22c55e", // green + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // purple + "#ec4899", // pink + "#06b6d4", // cyan + "#f97316", // orange + ] + + // Simple hash function to assign color + let hash = 0 + for (let i = 0; i < userId.length; i++) { + hash = userId.charCodeAt(i) + ((hash << 5) - hash) + } + + return colors[Math.abs(hash) % colors.length] +} diff --git a/apps/fabrikanabytok/proxy.ts b/apps/fabrikanabytok/proxy.ts new file mode 100644 index 0000000..1003288 --- /dev/null +++ b/apps/fabrikanabytok/proxy.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import authConfig from "./lib/auth/auth.config" +import NextAuth from "next-auth" +import { getConfig } from "./lib/config" + +const { auth } = NextAuth(authConfig) + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + + const maintenanceMode = (process.env.MAINTENANCE_MODE === "true" || (await getConfig()).maintenanceMode) ? true : false + + if (maintenanceMode && !pathname.startsWith("/maintenance") && !pathname.startsWith("/admin")) { + return NextResponse.redirect(new URL("/maintenance", request.url)) + } + + // Public routes that don't require authentication + const publicRoutes = [ + "/", + "/login", + "/register", + "/reset-password", + "/blog", + "/pricing", + "/maintenance", + "/verify-email", + ] + + // Check if current path is public + const isPublicRoute = publicRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`)) + + // Allow static files and API routes + if (pathname.startsWith("/_next") || pathname.startsWith("/api") || pathname.includes(".")) { + return NextResponse.next() + } + + return NextResponse.next() +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|public/).*)"], +} diff --git a/apps/fabrikanabytok/server.ts b/apps/fabrikanabytok/server.ts new file mode 100644 index 0000000..7b7db78 --- /dev/null +++ b/apps/fabrikanabytok/server.ts @@ -0,0 +1,112 @@ +import { createServer } from "http" +import { parse } from "url" +import next from "next" +import { Server as SocketIOServer } from "socket.io" +import { setupSocketIO } from "./lib/websocket/socket-server" +import { logger } from "./lib/utils/logger" + +const dev = process.env.NODE_ENV !== "production" +const hostname = process.env.HOSTNAME || "localhost" +const port = Number.parseInt(process.env.PORT || "3000", 10) + +const app = next({ + dev, + hostname, + port, + turbo: false, // Turbopack not compatible with custom servers +}) +const handle = app.getRequestHandler() + +app + .prepare() + .then(() => { + const server = createServer(async (req, res) => { + try { + const parsedUrl = parse(req.url!, true) + + // Handle Next.js HMR WebSocket connections + if (parsedUrl.pathname === "/_next/webpack-hmr") { + await handle(req, res, parsedUrl) + return + } + + // Handle all other requests with Next.js + await handle(req, res, parsedUrl) + } catch (err) { + logger.error("Error handling request:", err as any) + res.statusCode = 500 + res.end("Internal Server Error") + } + }) + + const io = new SocketIOServer(server, { + path: "/socket.io", + cors: { + origin: dev + ? "*" + : [ + `http://localhost:${port}`, + `http://${hostname}:${port}`, + process.env.NEXT_PUBLIC_SITE_URL || `http://localhost:${port}`, + ], + methods: ["GET", "POST"], + credentials: true, + }, + // Transports configuration - prefer WebSocket, fallback to polling + transports: ["websocket", "polling"], + // Allow upgrade from polling to WebSocket + allowUpgrades: true, + // Increase timeouts for reliability + pingTimeout: 60000, + pingInterval: 25000, + upgradeTimeout: 30000, + // Increase buffer for large payloads (e.g., design data) + maxHttpBufferSize: 5e6, // 5MB + // Enable compression for better performance + perMessageDeflate: { + threshold: 1024, // Compress messages > 1KB + }, + // Connection state recovery for reconnections + connectionStateRecovery: { + maxDisconnectionDuration: 2 * 60 * 1000, // 2 minutes + skipMiddlewares: false, + }, + }) + + setupSocketIO(io) + + // Monitor Socket.IO health + setInterval(() => { + const sockets = io.sockets.sockets + const wsCount = Array.from(sockets.values()).filter(s => s.conn.transport.name === "websocket").length + const pollingCount = Array.from(sockets.values()).filter(s => s.conn.transport.name === "polling").length + + logger.info(`[Socket.IO Health] Total: ${sockets.size}, WebSocket: ${wsCount}, Polling: ${pollingCount}`) + }, 60000) // Every minute + + server.listen(port, hostname, () => { + logger.info(`> Ready on http://${hostname}:${port}`) + logger.info(`> Socket.IO ready on ws://${hostname}:${port}/socket.io`) + logger.info(`> Environment: ${dev ? "development" : "production"}`) + logger.info(`> Transports: WebSocket (preferred), HTTP Long Polling (fallback)`) + logger.info(`> Connection State Recovery: Enabled (2 min window)`) + }) + + const shutdown = () => { + logger.info("Shutting down server...") + io.close(() => { + logger.info("Socket.IO server closed") + server.close(() => { + logger.info("HTTP server closed") + process.exit(0) + }) + }) + } + + process.on("SIGTERM", shutdown) + process.on("SIGINT", shutdown) + }) + .catch((err) => { + logger.error("Failed to start server:", err as any) + process.exit(1) + })