feat: add core infrastructure and database layer

This commit is contained in:
Gergely Hortobágyi
2025-11-28 20:47:00 +01:00
parent ac7021758d
commit 486e584cff
13 changed files with 2975 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

View 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/).*)"],
}

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