feat: add core infrastructure and database layer
This commit is contained in:
48
apps/fabrikanabytok/lib/config.ts
Normal file
48
apps/fabrikanabytok/lib/config.ts
Normal file
@@ -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<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
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<AppConfig> {
|
||||
// In the future, this can load from database
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
export function getConfigSync(): AppConfig {
|
||||
return defaultConfig
|
||||
}
|
||||
24
apps/fabrikanabytok/lib/db/analytics.ts
Normal file
24
apps/fabrikanabytok/lib/db/analytics.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { logger } from "../utils/logger"
|
||||
|
||||
export async function logActivity(
|
||||
action: string,
|
||||
description: string,
|
||||
details: {
|
||||
type: string
|
||||
metadata?: Record<string, any>
|
||||
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)
|
||||
}
|
||||
}
|
||||
635
apps/fabrikanabytok/lib/db/indexes.ts
Normal file
635
apps/fabrikanabytok/lib/db/indexes.ts
Normal file
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
177
apps/fabrikanabytok/lib/db/migrations.ts
Normal file
177
apps/fabrikanabytok/lib/db/migrations.ts
Normal file
@@ -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
|
||||
}
|
||||
|
||||
58
apps/fabrikanabytok/lib/db/mongodb.ts
Normal file
58
apps/fabrikanabytok/lib/db/mongodb.ts
Normal file
@@ -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<MongoClient>
|
||||
|
||||
declare global {
|
||||
var _mongoClientPromise: Promise<MongoClient> | 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<Db> {
|
||||
const client = await clientPromise
|
||||
return client.db(process.env.MONGODB_DB || "fabrika_nabytok")
|
||||
}
|
||||
|
||||
export async function checkDatabaseConnection(): Promise<boolean> {
|
||||
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<MongoClient> {
|
||||
return await clientPromise
|
||||
}
|
||||
258
apps/fabrikanabytok/lib/db/setup.ts
Normal file
258
apps/fabrikanabytok/lib/db/setup.ts
Normal file
@@ -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<boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
134
apps/fabrikanabytok/lib/socket/events.ts
Normal file
134
apps/fabrikanabytok/lib/socket/events.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
569
apps/fabrikanabytok/lib/store/planner-store.ts
Normal file
569
apps/fabrikanabytok/lib/store/planner-store.ts
Normal file
@@ -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<PlacedObject>) => 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<PlacedElement>) => void
|
||||
toggleElementVisibility: (objectId: string, elementId: string) => void
|
||||
|
||||
// Part operations
|
||||
updatePart: (objectId: string, elementId: string, partId: string, updates: Partial<PlacedPart>) => 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<SnapSettings>) => void
|
||||
toggleSnapToGrid: () => void
|
||||
setGridSize: (size: number) => void
|
||||
|
||||
// Render operations
|
||||
updateRenderSettings: (updates: Partial<RenderSettings>) => 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<PlannerStore>((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)
|
||||
},
|
||||
}))
|
||||
21
apps/fabrikanabytok/lib/utils.ts
Normal file
21
apps/fabrikanabytok/lib/utils.ts
Normal file
@@ -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]
|
||||
}
|
||||
183
apps/fabrikanabytok/lib/websocket/socket-client.ts
Normal file
183
apps/fabrikanabytok/lib/websocket/socket-client.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
713
apps/fabrikanabytok/lib/websocket/socket-server.ts
Normal file
713
apps/fabrikanabytok/lib/websocket/socket-server.ts
Normal file
@@ -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<string, Set<string>>()
|
||||
const connectionsByClientId = new Map<string, Socket>()
|
||||
const roomSubscriptions = new Map<string, Set<string>>() // 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<string, any>)
|
||||
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<string, any>)
|
||||
}
|
||||
})
|
||||
|
||||
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]
|
||||
}
|
||||
43
apps/fabrikanabytok/proxy.ts
Normal file
43
apps/fabrikanabytok/proxy.ts
Normal file
@@ -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/).*)"],
|
||||
}
|
||||
112
apps/fabrikanabytok/server.ts
Normal file
112
apps/fabrikanabytok/server.ts
Normal file
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user