Initial commit

This commit is contained in:
odzugkoev
2025-10-23 19:02:56 -04:00
commit 14958ca851
32 changed files with 8937 additions and 0 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
npm-debug.log*
.DS_Store
.git
.gitignore
**/*.log
# Local databases and generated data
/data
/public/uploads
# Editor/OS
.vscode
.idea
*.swp

15
.env Normal file
View File

@@ -0,0 +1,15 @@
# Kitchen Agent Configuration
# Google reCAPTCHA Site Key (v2 Invisible)
RECAPTCHA_SITE_KEY=6LfoaqIqAAAAABi1P-6T1gQpfXNXMv6aQqH0lwGK
# Server Configuration
PORT=3000
HOST=0.0.0.0
# Cookie Secret (change this in production!)
COOKIE_SECRET=0e4cfa9b-ba26-43b7-a6a1-a21585a6495b
# ThinkLink API URL
API_URL=https://api.thinklink.ai

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Dependency directories
node_modules/
jspm_packages/ # If using JSPM
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production artifacts
build/
dist/
.next/ # For Next.js projects
out/ # For static site generators
# Editor/IDE specific files
.vscode/ # VS Code settings
.idea/ # IntelliJ IDEA settings
*.sublime-project
*.sublime-workspace
# OS generated files
.DS_Store
Thumbs.db
# Test coverage
coverage/
# Misc
*.log
*.pid
*.seed
*.gz
pids/
logs/
results/
data/*

54
Dockerfile Normal file
View File

@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1.6
FROM node:20-bookworm-slim AS base
WORKDIR /app
ENV NODE_ENV=production
# Install dependencies first to leverage Docker layer caching
COPY package.json package-lock.json ./
FROM base AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install production deps (will compile native modules like better-sqlite3/serialport if needed)
RUN npm ci --omit=dev
# Copy source
COPY . .
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Runtime OS packages for printing via pdf-to-printer (lp/lpr)
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
cups-client \
cups-bsd \
libcups2 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Copy built app with node_modules from the build stage
COPY --from=build /app /app
# Create volumes for persistent data and user uploads
VOLUME ["/app/data", "/app/public/uploads"]
# Ensure non-root runtime; change ownership so the node user can write to volumes
RUN chown -R node:node /app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --start-period=30s --timeout=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["npm", "start"]

94
api-client.js Normal file
View File

@@ -0,0 +1,94 @@
const fetch = require('node-fetch');
class APIClient {
constructor(baseUrl = process.env.API_URL || 'https://api.thinklink.ai') {
this.baseUrl = baseUrl;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
method: options.method || 'POST',
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const data = await response.json();
return data;
} catch (error) {
console.error(`API request failed: ${endpoint}`, error.message);
return {
error: true,
message: `Network error: ${error.message}`
};
}
}
async login(email, password, recaptchaToken) {
return this.request('/user/login', {
body: {
login: email,
password: password,
'g-recaptcha-response': recaptchaToken
}
});
}
async getBots(token) {
return this.request('/bot/list', {
body: {
token: token
}
});
}
async getOrders(token, botId, afterId = 0, options = {}) {
const body = {
token: token,
botId: parseInt(botId, 10),
afterId: afterId || 0,
limit: options.limit || 50,
includeCanceled: options.includeCanceled || false
};
if (options.orderStatus) {
body.orderStatus = options.orderStatus;
}
if (options.sinceTs) {
body.sinceTs = options.sinceTs;
}
return this.request('/food-order/orders', { body });
}
async modifyOrder(token, botId, orderId, action, cancellationReason = '') {
const body = {
token: token,
botId: parseInt(botId, 10),
orderId: parseInt(orderId, 10),
action: action
};
if (action === 'cancel' && cancellationReason) {
body.cancellationReason = cancellationReason;
}
return this.request('/food-order/modify', { body });
}
isTokenExpired(expirationDate) {
if (!expirationDate) return true;
const expiry = new Date(expirationDate);
const now = new Date();
return now >= expiry;
}
}
module.exports = new APIClient();

159
config.js Normal file
View File

@@ -0,0 +1,159 @@
const database = require('./database');
const crypto = require('crypto');
class ConfigManager {
constructor() {
this.encryptionKey = null;
}
getOrCreateEncryptionKey() {
// Lazy initialization - only create key when first needed
if (this.encryptionKey) {
return this.encryptionKey;
}
// In production, this should be stored securely (environment variable or secure file)
// For now, we'll generate a random key and store it in the database
try {
let key = database.getConfig('_encryption_key');
if (!key) {
key = crypto.randomBytes(32).toString('hex');
database.setConfig('_encryption_key', key);
}
this.encryptionKey = key;
return key;
} catch (error) {
// If database not ready, generate temporary key
console.warn('Database not ready, using temporary encryption key');
this.encryptionKey = crypto.randomBytes(32).toString('hex');
return this.encryptionKey;
}
}
encrypt(text) {
if (!text) return null;
try {
const key = this.getOrCreateEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
'aes-256-cbc',
Buffer.from(key, 'hex'),
iv
);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
} catch (error) {
console.error('Encryption error:', error.message);
return null;
}
}
decrypt(text) {
if (!text) return null;
try {
const key = this.getOrCreateEncryptionKey();
const parts = text.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(
'aes-256-cbc',
Buffer.from(key, 'hex'),
iv
);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Decryption error:', error.message);
return null;
}
}
get(key) {
try {
const value = database.getConfig(key);
// Decrypt token if it's the auth token
if (key === 'authToken' && value) {
return this.decrypt(value);
}
return value;
} catch (error) {
console.warn(`Config.get('${key}') failed:`, error.message);
return null;
}
}
set(key, value) {
try {
// Encrypt token if it's the auth token
if (key === 'authToken' && value) {
value = this.encrypt(value);
}
database.setConfig(key, value);
} catch (error) {
console.error(`Config.set('${key}') failed:`, error.message);
}
}
getAll() {
try {
const config = database.getConfig();
// Decrypt auth token if present
if (config.authToken) {
config.authToken = this.decrypt(config.authToken);
}
return config;
} catch (error) {
console.warn('Config.getAll() failed:', error.message);
return {};
}
}
setMultiple(configObj) {
try {
// Encrypt auth token if present
if (configObj.authToken) {
configObj.authToken = this.encrypt(configObj.authToken);
}
database.setConfigMultiple(configObj);
} catch (error) {
console.error('Config.setMultiple() failed:', error.message);
}
}
isAuthenticated() {
try {
const token = this.get('authToken');
const expiry = this.get('tokenExpiry');
if (!token) return false;
if (!expiry) return false;
const expiryDate = new Date(expiry);
const now = new Date();
return now < expiryDate;
} catch (error) {
return false;
}
}
clearAuth() {
try {
this.set('authToken', '');
this.set('tokenExpiry', '');
} catch (error) {
console.error('Failed to clear auth:', error.message);
}
}
}
module.exports = new ConfigManager();

721
database.js Normal file
View File

@@ -0,0 +1,721 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
class DatabaseManager {
constructor() {
this.db = null;
}
init() {
// Ensure data directory exists
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'kitchen.db');
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.createTables();
console.log('Database initialized successfully');
}
createTables() {
// Config table
this.db.exec(`
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT
)
`);
// Orders table
this.db.exec(`
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER UNIQUE NOT NULL,
bot_id INTEGER NOT NULL,
status TEXT NOT NULL,
local_status TEXT,
order_data TEXT NOT NULL,
customer_data TEXT NOT NULL,
total_amount REAL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
printed_at INTEGER,
synced_at INTEGER
)
`);
// Print queue table
this.db.exec(`
CREATE TABLE IF NOT EXISTS print_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
print_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
printed_at INTEGER,
FOREIGN KEY (order_id) REFERENCES orders(order_id)
)
`);
// Printers table - stores individual printer configurations
this.db.exec(`
CREATE TABLE IF NOT EXISTS printers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
interface TEXT NOT NULL,
printer_type TEXT NOT NULL DEFAULT 'epson',
paper_width INTEGER NOT NULL DEFAULT 48,
paper_format TEXT NOT NULL DEFAULT '80mm',
is_default INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
font_size TEXT DEFAULT 'normal',
line_style TEXT DEFAULT 'single',
qr_code_enabled INTEGER DEFAULT 1,
qr_code_size INTEGER DEFAULT 3,
qr_code_correction TEXT DEFAULT 'M',
qr_code_content_template TEXT DEFAULT 'ORDER-{id}',
header_text TEXT DEFAULT 'KITCHEN ORDER',
footer_text TEXT DEFAULT 'Thank you!',
business_name TEXT,
business_address TEXT,
business_phone TEXT,
business_website TEXT,
business_email TEXT,
business_contact_size TEXT DEFAULT 'normal',
show_customer_info INTEGER DEFAULT 1,
show_order_items INTEGER DEFAULT 1,
show_prices INTEGER DEFAULT 1,
show_timestamps INTEGER DEFAULT 1,
logo_path TEXT,
logo_max_width_dots INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Create indexes
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_local_status ON orders(local_status);
CREATE INDEX IF NOT EXISTS idx_orders_created ON orders(created_at);
CREATE INDEX IF NOT EXISTS idx_print_queue_status ON print_queue(status);
CREATE INDEX IF NOT EXISTS idx_printers_enabled ON printers(is_enabled);
CREATE INDEX IF NOT EXISTS idx_printers_default ON printers(is_default);
`);
// Initialize default config values if not exists
this.setConfigDefaults();
// Migrate old printer config to new table if needed
this.migrateOldPrinterConfig();
}
setConfigDefaults() {
const defaults = {
apiUrl: 'https://api.thinklink.ai',
pollingInterval: '15000',
dashboardRefreshInterval: '10000',
printerType: 'epson',
printerInterface: 'usb',
printerPath: '/dev/usb/lp0',
printerWidth: '48',
fontSize: 'normal',
qrCodeEnabled: 'true',
headerText: 'KITCHEN ORDER',
footerText: 'Thank you!',
showOrderStats: 'true',
lineStyle: 'single',
printMargins: JSON.stringify({ left: 0, right: 0 }),
showCustomerInfo: 'true',
showOrderItems: 'true',
showPrices: 'true',
showTimestamps: 'true'
};
const insert = this.db.prepare('INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)');
for (const [key, value] of Object.entries(defaults)) {
insert.run(key, value);
}
}
// Config operations
getConfig(key = null) {
if (key) {
const row = this.db.prepare('SELECT value FROM config WHERE key = ?').get(key);
return row ? row.value : null;
}
const rows = this.db.prepare('SELECT key, value FROM config').all();
const config = {};
for (const row of rows) {
config[row.key] = row.value;
}
return config;
}
setConfig(key, value) {
this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, value);
}
setConfigMultiple(configObj) {
const insert = this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)');
const transaction = this.db.transaction(() => {
for (const [key, value] of Object.entries(configObj)) {
insert.run(key, value);
}
});
transaction();
}
// Order operations
insertOrder(order) {
const now = Math.floor(Date.now() / 1000);
return this.db.prepare(`
INSERT INTO orders (order_id, bot_id, status, local_status, order_data, customer_data, total_amount, created_at, updated_at, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
order.id,
order.botId,
order.status,
'new',
JSON.stringify(order.order),
JSON.stringify(order.customer),
order.totalAmount,
order.createdAt,
order.updatedAt,
now
);
}
getOrderById(orderId) {
const row = this.db.prepare(`
SELECT * FROM orders WHERE order_id = ?
`).get(orderId);
if (!row) return null;
return {
id: row.order_id,
botId: row.bot_id,
status: row.status,
localStatus: row.local_status,
order: JSON.parse(row.order_data),
customer: JSON.parse(row.customer_data),
totalAmount: row.total_amount,
createdAt: row.created_at,
updatedAt: row.updated_at,
printedAt: row.printed_at,
syncedAt: row.synced_at
};
}
getLastOrder() {
const row = this.db.prepare(`
SELECT * FROM orders ORDER BY order_id DESC LIMIT 1
`).get();
if (!row) return null;
return {
order_id: row.order_id,
botId: row.bot_id,
status: row.status,
localStatus: row.local_status
};
}
updateOrder(order) {
const now = Math.floor(Date.now() / 1000);
return this.db.prepare(`
UPDATE orders
SET status = ?, order_data = ?, customer_data = ?, total_amount = ?, updated_at = ?, synced_at = ?
WHERE order_id = ?
`).run(
order.status,
JSON.stringify(order.order),
JSON.stringify(order.customer),
order.totalAmount,
order.updatedAt,
now,
order.id
);
}
updateOrderStatus(orderId, localStatus) {
const now = Math.floor(Date.now() / 1000);
return this.db.prepare(`
UPDATE orders SET local_status = ?, updated_at = ? WHERE order_id = ?
`).run(localStatus, now, orderId);
}
markOrderPrinted(orderId) {
const now = Math.floor(Date.now() / 1000);
return this.db.prepare(`
UPDATE orders SET printed_at = ? WHERE order_id = ?
`).run(now, orderId);
}
getOrders(filters = {}) {
let query = 'SELECT * FROM orders WHERE 1=1';
const params = [];
if (filters.status) {
query += ' AND local_status = ?';
params.push(filters.status);
}
if (filters.date) {
// Get orders from start of day to end of day
const startOfDay = Math.floor(new Date(filters.date).setHours(0, 0, 0, 0) / 1000);
const endOfDay = Math.floor(new Date(filters.date).setHours(23, 59, 59, 999) / 1000);
query += ' AND created_at BETWEEN ? AND ?';
params.push(startOfDay, endOfDay);
}
query += ' ORDER BY created_at DESC';
if (filters.limit) {
query += ' LIMIT ?';
params.push(filters.limit);
}
const rows = this.db.prepare(query).all(...params);
return rows.map(row => ({
id: row.order_id,
botId: row.bot_id,
status: row.status,
localStatus: row.local_status,
order: JSON.parse(row.order_data),
customer: JSON.parse(row.customer_data),
totalAmount: row.total_amount,
createdAt: row.created_at,
updatedAt: row.updated_at,
printedAt: row.printed_at,
syncedAt: row.synced_at
}));
}
getOrderStats() {
const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000);
const stats = {
total: 0,
new: 0,
preparing: 0,
ready: 0
};
const rows = this.db.prepare(`
SELECT local_status, COUNT(*) as count
FROM orders
WHERE created_at >= ?
GROUP BY local_status
`).all(today);
for (const row of rows) {
if (row.local_status === 'new') stats.new = row.count;
else if (row.local_status === 'preparing') stats.preparing = row.count;
else if (row.local_status === 'ready') stats.ready = row.count;
stats.total += row.count;
}
return stats;
}
// Print queue operations
addToPrintQueue(orderId, printType) {
const now = Math.floor(Date.now() / 1000);
const recentWindowSeconds = 300; // 5 minutes window to prevent duplicates
const cutoff = now - recentWindowSeconds;
// Enhanced idempotency: check for any recent job (pending, processing, or recently completed)
// Apply time window to pending/processing as well to avoid stale blocks
const existing = this.db.prepare(`
SELECT id, status, created_at FROM print_queue
WHERE order_id = ? AND print_type = ?
AND ((status IN ('pending','processing') AND created_at > ?) OR (status = 'completed' AND created_at > ?))
ORDER BY id DESC LIMIT 1
`).get(orderId, printType, cutoff, cutoff);
if (existing && existing.id) {
// If there's a pending or processing job, reuse it
if (existing.status === 'pending' || existing.status === 'processing') {
console.log(`[DB] Reusing existing ${existing.status} job ${existing.id} for order #${orderId} (${printType})`);
return existing.id;
}
// If there's a recently completed job, allow creating a new job for audit
if (existing.status === 'completed') {
console.log(`[DB] Recent completed job ${existing.id} exists for order #${orderId} (${printType}); creating a new queued job.`);
}
}
console.log(`[DB] Creating new print job for order #${orderId} (${printType})`);
const info = this.db.prepare(`
INSERT INTO print_queue (order_id, print_type, status, created_at)
VALUES (?, ?, 'pending', ?)
`).run(orderId, printType, now);
return info && info.lastInsertRowid ? info.lastInsertRowid : null;
}
getPendingPrintJobs() {
return this.db.prepare(`
SELECT * FROM print_queue WHERE status = 'pending' ORDER BY created_at ASC
`).all();
}
markPrintJobCompleted(id) {
const now = Math.floor(Date.now() / 1000);
return this.db.prepare(`
UPDATE print_queue SET status = 'completed', printed_at = ? WHERE id = ?
`).run(now, id);
}
markPrintJobFailed(id) {
return this.db.prepare(`
UPDATE print_queue SET status = 'failed' WHERE id = ?
`).run(id);
}
// Transition a job to processing to prevent the worker from picking it up concurrently
markPrintJobProcessing(id) {
return this.db.prepare(`
UPDATE print_queue SET status = 'processing' WHERE id = ?
`).run(id);
}
// Revert a job back to pending (e.g., if immediate print failed) so the worker can retry
markPrintJobPending(id) {
return this.db.prepare(`
UPDATE print_queue SET status = 'pending' WHERE id = ?
`).run(id);
}
hasPrintedCancellation(orderId) {
const row = this.db.prepare(`
SELECT 1 FROM print_queue
WHERE order_id = ? AND print_type = 'canceled' AND status = 'completed'
LIMIT 1
`).get(orderId);
return !!row;
}
// Cleanup other jobs for the same order+type when one succeeds
// This prevents duplicate prints from multiple pending jobs
cleanupDuplicateJobs(successfulJobId, orderId, printType) {
const result = this.db.prepare(`
UPDATE print_queue
SET status = 'completed', printed_at = ?
WHERE order_id = ? AND print_type = ?
AND id != ?
AND status IN ('pending', 'processing')
`).run(Math.floor(Date.now() / 1000), orderId, printType, successfulJobId);
if (result.changes > 0) {
console.log(`[DB] Cleaned up ${result.changes} duplicate job(s) for order #${orderId} (${printType})`);
}
return result.changes;
}
// Check if there's any active or recently completed job for order+type
hasActiveOrRecentJob(orderId, printType, windowSeconds = 60) {
const cutoff = Math.floor(Date.now() / 1000) - windowSeconds;
const row = this.db.prepare(`
SELECT id, status FROM print_queue
WHERE order_id = ? AND print_type = ?
AND ((status IN ('processing', 'pending') AND created_at > ?) OR (status = 'completed' AND printed_at > ?))
ORDER BY id DESC LIMIT 1
`).get(orderId, printType, cutoff, cutoff);
return row ? { hasActive: true, jobId: row.id, status: row.status } : { hasActive: false };
}
// Reset stale 'processing' jobs to 'pending' so the worker can pick them up
// Useful when immediate prints failed (e.g., printer was offline) and jobs got stuck
resetStuckProcessingJobs(maxAgeSeconds = 120) {
const cutoff = Math.floor(Date.now() / 1000) - maxAgeSeconds;
const result = this.db.prepare(`
UPDATE print_queue
SET status = 'pending'
WHERE status = 'processing' AND created_at < ?
`).run(cutoff);
if (result && result.changes > 0) {
console.log(`[DB] Recovered ${result.changes} stuck processing job(s) to pending`);
}
return result && result.changes ? result.changes : 0;
}
// Get count of recently completed jobs for debugging
getRecentlyCompletedJobCount(orderId, printType, windowSeconds = 300) {
const cutoff = Math.floor(Date.now() / 1000) - windowSeconds;
const row = this.db.prepare(`
SELECT COUNT(*) as count FROM print_queue
WHERE order_id = ? AND print_type = ?
AND status = 'completed'
AND printed_at > ?
`).get(orderId, printType, cutoff);
return row ? row.count : 0;
}
// Printer CRUD operations
addPrinter(config) {
const now = Math.floor(Date.now() / 1000);
const stmt = this.db.prepare(`
INSERT INTO printers (
name, type, interface, printer_type, paper_width, paper_format,
is_default, is_enabled, font_size, line_style,
qr_code_enabled, qr_code_size, qr_code_correction, qr_code_content_template,
header_text, footer_text,
business_name, business_address, business_phone, business_website, business_email,
business_contact_size, show_customer_info, show_order_items, show_prices, show_timestamps,
logo_path, logo_max_width_dots, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
const info = stmt.run(
config.name || 'Unnamed Printer',
config.type || 'usb',
config.interface || '',
config.printer_type || 'epson',
config.paper_width || 48,
config.paper_format || '80mm',
config.is_default ? 1 : 0,
config.is_enabled !== false ? 1 : 0,
config.font_size || 'normal',
config.line_style || 'single',
config.qr_code_enabled !== false ? 1 : 0,
config.qr_code_size || 3,
config.qr_code_correction || 'M',
config.qr_code_content_template || 'ORDER-{id}',
(typeof config.header_text !== 'undefined' ? config.header_text : 'KITCHEN ORDER'),
(typeof config.footer_text !== 'undefined' ? config.footer_text : 'Thank you!'),
config.business_name || '',
config.business_address || '',
config.business_phone || '',
config.business_website || '',
config.business_email || '',
config.business_contact_size || 'normal',
config.show_customer_info !== false ? 1 : 0,
config.show_order_items !== false ? 1 : 0,
config.show_prices !== false ? 1 : 0,
config.show_timestamps !== false ? 1 : 0,
config.logo_path || null,
config.logo_max_width_dots || null,
now,
now
);
// If this is set as default, unset other defaults
if (config.is_default) {
this.db.prepare('UPDATE printers SET is_default = 0 WHERE id != ?').run(info.lastInsertRowid);
}
return info.lastInsertRowid;
}
updatePrinter(id, config) {
const now = Math.floor(Date.now() / 1000);
const stmt = this.db.prepare(`
UPDATE printers SET
name = ?, type = ?, interface = ?, printer_type = ?, paper_width = ?, paper_format = ?,
is_default = ?, is_enabled = ?, font_size = ?, line_style = ?,
qr_code_enabled = ?, qr_code_size = ?, qr_code_correction = ?, qr_code_content_template = ?,
header_text = ?, footer_text = ?,
business_name = ?, business_address = ?, business_phone = ?, business_website = ?, business_email = ?,
business_contact_size = ?, show_customer_info = ?, show_order_items = ?, show_prices = ?, show_timestamps = ?,
logo_path = ?, logo_max_width_dots = ?, updated_at = ?
WHERE id = ?
`);
const result = stmt.run(
config.name || 'Unnamed Printer',
config.type || 'usb',
config.interface || '',
config.printer_type || 'epson',
config.paper_width || 48,
config.paper_format || '80mm',
config.is_default ? 1 : 0,
config.is_enabled !== false ? 1 : 0,
config.font_size || 'normal',
config.line_style || 'single',
config.qr_code_enabled !== false ? 1 : 0,
config.qr_code_size || 3,
config.qr_code_correction || 'M',
config.qr_code_content_template || 'ORDER-{id}',
(typeof config.header_text !== 'undefined' ? config.header_text : 'KITCHEN ORDER'),
(typeof config.footer_text !== 'undefined' ? config.footer_text : 'Thank you!'),
config.business_name || '',
config.business_address || '',
config.business_phone || '',
config.business_website || '',
config.business_email || '',
config.business_contact_size || 'normal',
config.show_customer_info !== false ? 1 : 0,
config.show_order_items !== false ? 1 : 0,
config.show_prices !== false ? 1 : 0,
config.show_timestamps !== false ? 1 : 0,
config.logo_path || null,
config.logo_max_width_dots || null,
now,
id
);
// If this is set as default, unset other defaults
if (config.is_default) {
this.db.prepare('UPDATE printers SET is_default = 0 WHERE id != ?').run(id);
}
return result;
}
deletePrinter(id) {
return this.db.prepare('DELETE FROM printers WHERE id = ?').run(id);
}
getPrinter(id) {
const row = this.db.prepare('SELECT * FROM printers WHERE id = ?').get(id);
return row ? this.mapPrinterRow(row) : null;
}
getAllPrinters() {
const rows = this.db.prepare('SELECT * FROM printers ORDER BY is_default DESC, name ASC').all();
return rows.map(row => this.mapPrinterRow(row));
}
getEnabledPrinters() {
const rows = this.db.prepare('SELECT * FROM printers WHERE is_enabled = 1 ORDER BY is_default DESC, name ASC').all();
return rows.map(row => this.mapPrinterRow(row));
}
getDefaultPrinter() {
const row = this.db.prepare('SELECT * FROM printers WHERE is_default = 1 LIMIT 1').get();
return row ? this.mapPrinterRow(row) : null;
}
setDefaultPrinter(id) {
const transaction = this.db.transaction(() => {
this.db.prepare('UPDATE printers SET is_default = 0').run();
this.db.prepare('UPDATE printers SET is_default = 1 WHERE id = ?').run(id);
});
transaction();
}
togglePrinterEnabled(id) {
const printer = this.getPrinter(id);
if (!printer) return null;
const newEnabled = printer.is_enabled ? 0 : 1;
this.db.prepare('UPDATE printers SET is_enabled = ? WHERE id = ?').run(newEnabled, id);
return { is_enabled: newEnabled === 1 };
}
mapPrinterRow(row) {
return {
id: row.id,
name: row.name,
type: row.type,
interface: row.interface,
printer_type: row.printer_type,
paper_width: row.paper_width,
paper_format: row.paper_format,
is_default: row.is_default === 1,
is_enabled: row.is_enabled === 1,
font_size: row.font_size,
line_style: row.line_style,
qr_code_enabled: row.qr_code_enabled === 1,
qr_code_size: row.qr_code_size,
qr_code_correction: row.qr_code_correction,
qr_code_content_template: row.qr_code_content_template,
header_text: row.header_text,
footer_text: row.footer_text,
business_name: row.business_name,
business_address: row.business_address,
business_phone: row.business_phone,
business_website: row.business_website,
business_email: row.business_email,
business_contact_size: row.business_contact_size,
show_customer_info: row.show_customer_info === 1,
show_order_items: row.show_order_items === 1,
show_prices: row.show_prices === 1,
show_timestamps: row.show_timestamps === 1,
logo_path: row.logo_path,
logo_max_width_dots: row.logo_max_width_dots,
created_at: row.created_at,
updated_at: row.updated_at
};
}
// Migration from old config system to new printers table
migrateOldPrinterConfig() {
try {
// Check if we already have printers
const existingPrinters = this.db.prepare('SELECT COUNT(*) as count FROM printers').get();
if (existingPrinters && existingPrinters.count > 0) {
return; // Already migrated
}
// Get old config
const oldConfig = this.getConfig();
// Only migrate if old printer config exists
if (!oldConfig.printerInterface && !oldConfig.printerPath) {
return; // No old config to migrate
}
console.log('Migrating old printer configuration to new printers table...');
// Create a printer from old config
const printerConfig = {
name: 'Default Printer (Migrated)',
type: oldConfig.printerInterface === 'serial' ? 'com' : (oldConfig.printerInterface || 'usb'),
interface: oldConfig.printerPath || '/dev/usb/lp0',
printer_type: oldConfig.printerType || 'epson',
paper_width: parseInt(oldConfig.printerWidth, 10) || 48,
paper_format: (parseInt(oldConfig.printerWidth, 10) || 48) >= 48 ? '80mm' : '58mm',
is_default: true,
is_enabled: true,
font_size: oldConfig.fontSize || 'normal',
line_style: oldConfig.lineStyle || 'single',
qr_code_enabled: oldConfig.qrCodeEnabled !== 'false',
qr_code_size: parseInt(oldConfig.qrCodeSize, 10) || 3,
qr_code_correction: oldConfig.qrCodeCorrection || 'M',
qr_code_content_template: oldConfig.qrCodeContentTemplate || 'ORDER-{id}',
header_text: oldConfig.headerText || 'KITCHEN ORDER',
footer_text: oldConfig.footerText || 'Thank you!',
business_name: oldConfig.businessName || '',
business_address: oldConfig.businessAddress || '',
business_phone: oldConfig.businessPhone || '',
business_website: oldConfig.businessWebsite || '',
business_email: oldConfig.businessEmail || '',
business_contact_size: oldConfig.businessContactSize || 'normal',
show_customer_info: oldConfig.showCustomerInfo !== 'false',
show_order_items: oldConfig.showOrderItems !== 'false',
show_prices: oldConfig.showPrices !== 'false',
show_timestamps: oldConfig.showTimestamps !== 'false',
logo_path: oldConfig.logoPath || null,
logo_max_width_dots: oldConfig.logoMaxWidthDots ? parseInt(oldConfig.logoMaxWidthDots, 10) : null
};
this.addPrinter(printerConfig);
console.log('Migration complete: Old printer configuration transferred to new system');
} catch (error) {
console.error('Error migrating old printer config:', error.message);
}
}
close() {
if (this.db) {
this.db.close();
}
}
}
module.exports = new DatabaseManager();

17
ecosystem.config.js Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
apps: [{
name: 'kitchen-agent',
script: './server.js',
instances: 1,
exec_mode: 'cluster',
kill_timeout: 8000,
listen_timeout: 8000,
env: {
REPO_URL: 'https://repo.cloud.thinklink.ai/thinklink/kitchen-agent',
REPO_BRANCH: 'main',
PM2_APP: 'kitchen-agent',
UPDATE_CHECK_INTERVAL_MS: 300000
// GITEA_TOKEN: ''
}
}]
};

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "kitchen-agent",
"version": "1.0.5",
"description": "Kitchen Agent for ThinkLink Food Order Management",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node run-tests.js"
},
"keywords": ["kitchen", "food-order", "thermal-printer", "thinklink"],
"author": "",
"license": "MIT",
"dependencies": {
"fastify": "^4.26.0",
"@fastify/view": "^8.2.0",
"@fastify/static": "^6.12.0",
"@fastify/cookie": "^9.3.1",
"@fastify/formbody": "^7.4.0",
"@fastify/multipart": "^8.1.0",
"ejs": "^3.1.9",
"node-thermal-printer": "^4.4.5",
"better-sqlite3": "^9.4.0",
"node-fetch": "^2.7.0",
"dotenv": "^16.3.1",
"pdf-to-printer": "^5.4.0",
"pdfkit": "^0.15.0",
"serialport": "^12.0.0",
"auto-git-update": "^1.1.1"
}
}

157
print-worker.js Normal file
View File

@@ -0,0 +1,157 @@
const config = require('./config');
const database = require('./database');
const printer = require('./printer');
class PrintQueueWorker {
constructor(db = database, cfg = config, printerManager = printer) {
this.db = db;
this.cfg = cfg;
this.printer = printerManager;
this.intervalId = null;
this.isRunning = false;
this.isProcessing = false;
}
start(intervalMs = 10000) {
if (this.isRunning) return;
this.isRunning = true;
const configured = parseInt(this.cfg.get('printWorkerInterval'), 10);
const tickMs = Number.isFinite(configured) && configured > 0 ? configured : intervalMs;
console.log(`[PrintQueueWorker] Starting with interval ${tickMs} ms`);
this._schedule(tickMs);
}
stop() {
this.isRunning = false;
if (this.intervalId) {
clearTimeout(this.intervalId);
this.intervalId = null;
}
console.log('[PrintQueueWorker] Stopped');
}
_schedule(intervalMs) {
if (!this.isRunning) return;
this.intervalId = setTimeout(async () => {
try {
await this._tick();
} catch (err) {
console.error('[PrintQueueWorker] Tick error:', err.message);
} finally {
this._schedule(intervalMs);
}
}, intervalMs);
}
async _tick() {
if (this.isProcessing) {
return; // avoid overlapping runs
}
this.isProcessing = true;
try {
// Skip if no printers likely reachable to avoid hammering
try {
const printerConfigs = this.db.getEnabledPrinters();
const anyReachable = await this.printer.anyConfiguredPrinterReachable(printerConfigs);
if (!anyReachable) {
console.log('[PrintQueueWorker] No reachable printers detected yet, will retry later');
return;
}
} catch (_) {
// If reachability check fails, proceed and let print attempt decide
}
// Recover stuck processing jobs older than 2 minutes
try { this.db.resetStuckProcessingJobs(120); } catch (_) {}
const pending = this.db.getPendingPrintJobs();
if (!pending || pending.length === 0) {
return;
}
console.log(`[PrintQueueWorker] Processing ${pending.length} pending print job(s)`);
for (const job of pending) {
try {
const order = this.db.getOrderById(job.order_id);
if (!order) {
console.warn(`[PrintQueueWorker] Order ${job.order_id} not found, marking job ${job.id} failed`);
this.db.markPrintJobFailed(job.id);
continue;
}
// Check 1: If the order has been printed after the job was created, mark job completed to avoid duplicates
if (order.printedAt && job.created_at && Number(order.printedAt) >= Number(job.created_at)) {
console.log(`[PrintQueueWorker] Skipping job ${job.id}, order ${order.id} already printed at ${order.printedAt}`);
this.db.markPrintJobCompleted(job.id);
continue;
}
// Check 2: Verify there's no active job (pending/processing) for this order+type (within last 60 seconds)
// Do NOT block reprints because of a recently COMPLETED job; users may intentionally reprint.
const activeCheck = this.db.hasActiveOrRecentJob(job.order_id, job.print_type, 60);
if (activeCheck.hasActive && activeCheck.jobId !== job.id) {
// Skip if another active pending/processing job exists
if (activeCheck.status === 'pending' || activeCheck.status === 'processing') {
console.log(`[PrintQueueWorker] Skipping job ${job.id}, another active job ${activeCheck.jobId} (${activeCheck.status}) exists for order ${job.order_id} (${job.print_type})`);
this.db.markPrintJobCompleted(job.id);
continue;
}
// If the recent job is COMPLETED, only block for non-reprint types (avoid duplicate cancellations/new)
if (activeCheck.status === 'completed' && job.print_type !== 'reprint') {
console.log(`[PrintQueueWorker] Skipping job ${job.id}, recent completed job ${activeCheck.jobId} exists for order ${job.order_id} (${job.print_type})`);
this.db.markPrintJobCompleted(job.id);
continue;
}
}
// Check 3: For cancellations, verify we haven't already printed one
if (job.print_type === 'canceled' && this.db.hasPrintedCancellation(job.order_id)) {
console.log(`[PrintQueueWorker] Skipping job ${job.id}, cancellation already printed for order ${job.order_id}`);
this.db.markPrintJobCompleted(job.id);
continue;
}
let type = job.print_type;
if (type === 'reprint') {
type = order.localStatus === 'canceled' ? 'canceled' : 'new';
}
const printerConfigs = this.db.getEnabledPrinters();
const cancelReason = (order && (order.cancellationReason || (order.order && order.order.cancellationReason))) || 'Order canceled';
const options = type === 'canceled' ? { reason: cancelReason } : {};
let result;
if (printerConfigs && printerConfigs.length > 0) {
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, type, options);
} else {
const appConfig = this.cfg.getAll();
if (!this.printer.printer || !this.printer.config) {
try { this.printer.initializePrinter(appConfig); } catch (_) {}
}
result = await this.printer.printOrderReceipt(order, type, options);
}
if (result && result.success) {
this.db.markOrderPrinted(order.id);
this.db.markPrintJobCompleted(job.id);
// Cleanup any other pending/processing jobs for this order+type to prevent duplicates
this.db.cleanupDuplicateJobs(job.id, job.order_id, job.print_type);
console.log(`[PrintQueueWorker] ✓ Printed order #${order.id} (job ${job.id})`);
} else {
// Keep as pending for retry on next tick
const errMsg = result && result.error ? result.error : 'Unknown print error';
console.warn(`[PrintQueueWorker] ✗ Print failed for order #${order.id} (job ${job.id}): ${errMsg}`);
}
} catch (errJob) {
console.error(`[PrintQueueWorker] Job ${job.id} error:`, errJob.message);
}
}
} finally {
this.isProcessing = false;
}
}
}
module.exports = PrintQueueWorker;

1757
printer.js Normal file

File diff suppressed because it is too large Load Diff

1604
public/css/style.css Normal file

File diff suppressed because it is too large Load Diff

2
public/images/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# This file ensures the images directory is created

59
public/js/common.js Normal file
View File

@@ -0,0 +1,59 @@
// Common utilities and functions
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
if (!toast) return;
toast.textContent = message;
toast.className = 'toast visible ' + type;
setTimeout(() => {
toast.classList.remove('visible');
}, 3000);
}
function formatTime(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
function formatDateTime(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatCurrency(amount) {
return '$' + (amount || 0).toFixed(2);
}
// Tab switching functionality
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', function() {
const targetTab = this.dataset.tab;
// Remove active class from all buttons and contents
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button and corresponding content
this.classList.add('active');
const targetContent = document.getElementById(targetTab + '-tab');
if (targetContent) {
targetContent.classList.add('active');
}
});
});
});

953
public/js/dashboard.js Normal file
View File

@@ -0,0 +1,953 @@
// Dashboard functionality
let currentFilter = 'all';
let currentOrderIdForCancel = null;
let pendingStatusChange = {
orderId: null,
newStatus: null,
currentStatus: null,
orderData: null
};
// Track orders for notification detection
let previousOrderIds = new Set();
let previousCanceledOrderIds = new Set();
let isFirstLoad = true;
// Connection status tracking
const connectionStatus = {
local: {
status: 'checking',
lastCheck: null,
consecutiveFailures: 0
},
api: {
status: 'checking',
lastCheck: null,
consecutiveFailures: 0,
responseTime: null
}
};
// Audio notification system
const audioNotification = {
newOrderSound: null,
canceledOrderSound: null,
enabled: true,
init: function() {
// Load notification settings from server
fetch('/api/notification-settings')
.then(response => response.json())
.then(data => {
if (!data.error) {
this.enabled = data.soundNotificationsEnabled !== 'false';
const newOrderPath = data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3';
const canceledOrderPath = data.canceledOrderSoundPath || '/public/sounds/new-order-notification.mp3';
// Preload audio files
this.newOrderSound = new Audio(newOrderPath);
this.canceledOrderSound = new Audio(canceledOrderPath);
// Set volume if specified
if (data.soundVolume) {
const volume = parseInt(data.soundVolume, 10) / 100;
this.newOrderSound.volume = volume;
this.canceledOrderSound.volume = volume;
}
}
})
.catch(error => {
console.warn('Failed to load notification settings:', error);
// Use default sound
this.newOrderSound = new Audio('/public/sounds/new-order-notification.mp3');
this.canceledOrderSound = new Audio('/public/sounds/new-order-notification.mp3');
});
},
playNewOrderSound: function() {
if (this.enabled && this.newOrderSound) {
this.newOrderSound.currentTime = 0;
this.newOrderSound.play().catch(error => {
console.warn('Failed to play new order sound:', error);
});
}
},
playCanceledOrderSound: function() {
if (this.enabled && this.canceledOrderSound) {
this.canceledOrderSound.currentTime = 0;
this.canceledOrderSound.play().catch(error => {
console.warn('Failed to play canceled order sound:', error);
});
}
}
};
// Initialize dashboard
document.addEventListener('DOMContentLoaded', function() {
setupFilterButtons();
audioNotification.init();
refreshOrders();
// Set up auto-refresh
setInterval(refreshOrders, config.dashboardRefreshInterval || 10000);
// Initialize connection monitoring
checkConnectionStatus();
// Check connection status every 15 seconds
setInterval(checkConnectionStatus, 15000);
});
function setupFilterButtons() {
const filterButtons = document.querySelectorAll('.filter-btn');
filterButtons.forEach(button => {
button.addEventListener('click', function() {
// Remove active class from all buttons
filterButtons.forEach(btn => btn.classList.remove('active'));
// Add active class to clicked button
this.classList.add('active');
// Update filter and refresh
currentFilter = this.dataset.filter;
refreshOrders();
});
});
}
function refreshOrders() {
const syncButton = document.getElementById('syncButton');
const syncText = syncButton ? syncButton.querySelector('.sync-text') : null;
if (syncButton) {
syncButton.classList.add('loading');
syncButton.disabled = true;
if (syncText) {
syncText.textContent = 'Syncing...';
}
}
const statusParam = currentFilter === 'all' ? '' : currentFilter;
const url = '/api/orders' + (statusParam ? '?status=' + statusParam : '');
fetch(url)
.then(response => response.json())
.then(data => {
if (!data.error) {
updateOrderCards(data.orders);
updateStats(data.stats);
// Connection successful - reset local connection failure counter
if (connectionStatus.local.consecutiveFailures > 0) {
connectionStatus.local.consecutiveFailures = 0;
connectionStatus.local.status = 'online';
updateConnectionUI('local', 'online', 'Connected');
}
}
})
.catch(error => {
console.error('Failed to refresh orders:', error);
// Mark local connection as potentially offline
connectionStatus.local.consecutiveFailures++;
if (connectionStatus.local.consecutiveFailures >= 2) {
connectionStatus.local.status = 'offline';
updateConnectionUI('local', 'offline', 'Disconnected');
}
})
.finally(() => {
if (syncButton) {
syncButton.classList.remove('loading');
syncButton.disabled = false;
if (syncText) {
syncText.textContent = 'Sync Now';
}
}
});
}
function updateStats(stats) {
if (!stats) return;
const totalEl = document.getElementById('stat-total');
const newEl = document.getElementById('stat-new');
const preparingEl = document.getElementById('stat-preparing');
const readyEl = document.getElementById('stat-ready');
if (totalEl) totalEl.textContent = stats.total || 0;
if (newEl) newEl.textContent = stats.new || 0;
if (preparingEl) preparingEl.textContent = stats.preparing || 0;
if (readyEl) readyEl.textContent = stats.ready || 0;
}
function updateOrderCards(orders) {
const container = document.getElementById('ordersContainer');
if (!orders || orders.length === 0) {
container.innerHTML = '<div class="no-orders">No orders to display</div>';
return;
}
// Detect new orders and canceled orders for sound notifications
if (!isFirstLoad) {
const currentOrderIds = new Set();
const currentCanceledOrderIds = new Set();
orders.forEach(order => {
const orderId = order.id;
const status = order.localStatus || order.status || 'new';
currentOrderIds.add(orderId);
if (status === 'canceled') {
currentCanceledOrderIds.add(orderId);
}
// Check for new orders (not in previous set)
if (!previousOrderIds.has(orderId) && status === 'new') {
console.log('New order detected:', orderId);
audioNotification.playNewOrderSound();
}
// Check for newly canceled orders
if (!previousCanceledOrderIds.has(orderId) && status === 'canceled') {
console.log('Order canceled:', orderId);
audioNotification.playCanceledOrderSound();
}
});
// Update tracking sets
previousOrderIds = currentOrderIds;
previousCanceledOrderIds = currentCanceledOrderIds;
} else {
// On first load, just populate the tracking sets without playing sounds
orders.forEach(order => {
previousOrderIds.add(order.id);
const status = order.localStatus || order.status || 'new';
if (status === 'canceled') {
previousCanceledOrderIds.add(order.id);
}
});
isFirstLoad = false;
}
container.innerHTML = orders.map(order => createOrderCard(order)).join('');
}
function createOrderCard(order) {
const status = order.localStatus || order.status || 'new';
const statusBadge = getStatusBadge(status);
const orderType = order.order.type || 'pickup';
const typeIcon = orderType === 'delivery' ? '🚚' : '🛍️';
// Determine which actions to show based on status
let actions = '';
if (status === 'new') {
actions = `
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'preparing')">Start Preparing</button>
`;
} else if (status === 'preparing') {
actions = `
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'ready')">Mark Ready</button>
`;
} else if (status === 'ready') {
actions = `
<button class="btn btn-success" onclick="changeStatus(${order.id}, 'completed')">Complete</button>
`;
}
// Add cancel and reprint buttons for non-completed orders
if (status !== 'canceled' && status !== 'completed') {
actions += `
<button class="btn btn-danger" onclick="openCancelModal(${order.id})">Cancel</button>
`;
}
actions += `
<button class="btn btn-secondary" onclick="reprintOrder(${order.id})">Reprint</button>
<button class="btn btn-secondary" onclick="showOrderDetails(${order.id})">Details</button>
`;
// Build detailed items list with addons and excludes
let itemsDetailedHtml = '';
if (order.order.items && order.order.items.length > 0) {
// Sort items alphabetically by name for better kitchen organization
const sortedItems = [...order.order.items].sort((a, b) => {
const nameA = (a.itemName || a.name || 'Item').toLowerCase();
const nameB = (b.itemName || b.name || 'Item').toLowerCase();
return nameA.localeCompare(nameB);
});
itemsDetailedHtml = sortedItems.map(item => {
let modifiersHtml = '';
// Add addons
if (item.addons && item.addons.length > 0) {
const addonsList = item.addons.map(a => a.name || a).join(', ');
modifiersHtml += `<div class="item-modifier item-addon">+ Add: ${addonsList}</div>`;
}
// Add excludes
if (item.exclude && item.exclude.length > 0) {
const excludeList = item.exclude.map(e => e.name || e).join(', ');
modifiersHtml += `<div class="item-modifier item-exclude"> NO: ${excludeList}</div>`;
}
return `
<div class="order-item">
<div class="item-qty-name">
<span class="item-qty">${item.qty}x</span>
<span class="item-name">${item.itemName || item.name || 'Item'}</span>
</div>
${modifiersHtml}
</div>
`;
}).join('');
}
// Food allergy warning (prominent)
let allergyWarning = '';
if (order.order.foodAllergy) {
const allergyNotes = order.order.foodAllergyNotes || 'Customer has food allergies';
allergyWarning = `
<div class="allergy-warning">
<div class="allergy-icon">⚠️</div>
<div class="allergy-content">
<div class="allergy-title">FOOD ALLERGY ALERT</div>
<div class="allergy-notes">${allergyNotes}</div>
</div>
</div>
`;
}
// Special instructions
let specialInstructionsHtml = '';
if (order.order.specialInstructions) {
specialInstructionsHtml = `
<div class="special-instructions">
<div class="special-instructions-label">📝 Special Instructions:</div>
<div class="special-instructions-text">${order.order.specialInstructions}</div>
</div>
`;
}
return `
<div class="order-card status-${status}">
<div class="order-header">
<div class="order-number">#${order.id}</div>
<div class="order-badge badge-${status}">${statusBadge}</div>
</div>
${allergyWarning}
<div class="order-info">
<div class="order-info-row">
<span class="order-info-label">Time:</span>
<span class="order-info-value">${formatTime(order.createdAt)}</span>
</div>
<div class="order-info-row">
<span class="order-info-label">Customer:</span>
<span class="order-info-value">${order.customer.name || 'N/A'}</span>
</div>
<div class="order-info-row">
<span class="order-info-label">Type:</span>
<span class="order-info-value"><span class="order-type-icon">${typeIcon}</span>${orderType}</span>
</div>
${order.order.deliveryAddress ? `<div class="order-info-row"><span class="order-info-label">Address:</span><span class="order-info-value">${order.order.deliveryAddress}</span></div>` : ''}
</div>
<div class="order-items-section">
<div class="order-items-title">Order Items:</div>
${itemsDetailedHtml}
</div>
${specialInstructionsHtml}
<div class="order-total">
<span class="order-total-label">Total:</span>
<span class="order-total-value">${formatCurrency(order.totalAmount)}</span>
</div>
<div class="order-actions">
${actions}
</div>
</div>
`;
}
function getStatusBadge(status) {
const badges = {
'new': 'New',
'preparing': 'Preparing',
'ready': 'Ready',
'completed': 'Completed',
'canceled': 'Canceled'
};
return badges[status] || status;
}
function changeStatus(orderId, newStatus) {
// Fetch the order data first to show in the confirmation modal
fetch(`/api/orders/${orderId}`)
.then(response => response.json())
.then(data => {
if (!data.error && data.order) {
openStatusChangeModal(orderId, newStatus, data.order);
} else {
showToast('Failed to fetch order details', 'error');
}
})
.catch(error => {
console.error('Error fetching order:', error);
showToast('Failed to fetch order details', 'error');
});
}
function openStatusChangeModal(orderId, newStatus, orderData) {
const currentStatus = orderData.localStatus || orderData.status || 'new';
// Store pending status change
pendingStatusChange = {
orderId: orderId,
newStatus: newStatus,
currentStatus: currentStatus,
orderData: orderData
};
// Get status information
const statusInfo = getStatusInfo(newStatus);
// Update modal icon
const iconEl = document.getElementById('statusModalIcon');
iconEl.textContent = statusInfo.icon;
iconEl.className = 'status-modal-icon ' + newStatus;
// Update modal title
document.getElementById('statusModalTitle').textContent = statusInfo.title;
// Update order details
document.getElementById('statusModalOrderId').textContent = orderId;
document.getElementById('statusModalCustomer').textContent = orderData.customer.name || 'N/A';
// Update status badges
const fromBadge = document.getElementById('statusModalFrom');
const toBadge = document.getElementById('statusModalTo');
fromBadge.textContent = getStatusBadge(currentStatus);
fromBadge.className = 'status-badge badge-' + currentStatus;
toBadge.textContent = getStatusBadge(newStatus);
toBadge.className = 'status-badge badge-' + newStatus;
// Update message
document.getElementById('statusModalMessage').textContent = statusInfo.message;
// Update confirm button
const confirmBtn = document.getElementById('statusConfirmBtn');
confirmBtn.textContent = statusInfo.buttonText;
// Show modal
document.getElementById('statusChangeModal').classList.add('visible');
}
function getStatusInfo(status) {
const statusMap = {
'preparing': {
icon: '👨‍🍳',
title: 'Start Preparing Order?',
message: 'This will mark the order as being prepared in the kitchen.',
buttonText: 'Start Preparing'
},
'ready': {
icon: '✅',
title: 'Mark Order as Ready?',
message: 'This will notify that the order is ready for pickup or delivery.',
buttonText: 'Mark Ready'
},
'completed': {
icon: '🎉',
title: 'Complete Order?',
message: 'This will mark the order as completed and it will be archived.',
buttonText: 'Complete Order'
}
};
return statusMap[status] || {
icon: '📋',
title: 'Change Order Status?',
message: 'This will update the order status.',
buttonText: 'Confirm'
};
}
function closeStatusChangeModal() {
document.getElementById('statusChangeModal').classList.remove('visible');
pendingStatusChange = {
orderId: null,
newStatus: null,
currentStatus: null,
orderData: null
};
}
function confirmStatusChange() {
if (!pendingStatusChange.orderId || !pendingStatusChange.newStatus) {
return;
}
const orderId = pendingStatusChange.orderId;
const newStatus = pendingStatusChange.newStatus;
// Close modal immediately for better UX
closeStatusChangeModal();
// Make the API call
fetch(`/api/orders/${orderId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
})
.then(response => response.json())
.then(data => {
if (!data.error) {
showToast(`Order #${orderId} updated to ${newStatus}`, 'success');
refreshOrders();
} else {
showToast(data.message || 'Failed to update order', 'error');
}
})
.catch(error => {
console.error('Error updating order:', error);
showToast('Failed to update order', 'error');
});
}
function openCancelModal(orderId) {
currentOrderIdForCancel = orderId;
document.getElementById('cancelReason').value = '';
document.getElementById('cancelModal').classList.add('visible');
}
function closeCancelModal() {
currentOrderIdForCancel = null;
document.getElementById('cancelModal').classList.remove('visible');
}
function confirmCancel() {
if (!currentOrderIdForCancel) return;
const reason = document.getElementById('cancelReason').value;
fetch(`/api/orders/${currentOrderIdForCancel}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || 'Canceled by kitchen' })
})
.then(response => response.json())
.then(data => {
if (!data.error) {
showToast(`Order #${currentOrderIdForCancel} canceled`, 'success');
closeCancelModal();
refreshOrders();
} else {
showToast(data.message || 'Failed to cancel order', 'error');
}
})
.catch(error => {
console.error('Error canceling order:', error);
showToast('Failed to cancel order', 'error');
});
}
let reprintInProgressByOrder = {};
function reprintOrder(orderId) {
if (reprintInProgressByOrder[orderId]) {
return; // guard against rapid double clicks
}
reprintInProgressByOrder[orderId] = true;
fetch(`/api/orders/${orderId}/reprint`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (!data.error) {
showToast((data && data.message) ? data.message : 'Receipt sent to printer', 'success');
} else {
showToast(data.message || 'Failed to print receipt', 'error');
}
})
.catch(error => {
console.error('Error reprinting order:', error);
showToast('Failed to print receipt', 'error');
})
.finally(() => {
reprintInProgressByOrder[orderId] = false;
});
}
function showOrderDetails(orderId) {
fetch(`/api/orders/${orderId}`)
.then(response => response.json())
.then(data => {
if (!data.error && data.order) {
displayOrderDetails(data.order);
}
})
.catch(error => {
console.error('Error fetching order details:', error);
});
}
function displayOrderDetails(order) {
const modal = document.getElementById('detailsModal');
const content = document.getElementById('orderDetailsContent');
let itemsHtml = '';
if (order.order.items && order.order.items.length > 0) {
itemsHtml = order.order.items.map(item => {
let modifiersHtml = '';
// Add addons with prices if available
if (item.addons && item.addons.length > 0) {
item.addons.forEach(addon => {
const addonName = addon.name || addon;
const addonPrice = typeof addon === 'object' && addon.price != null ? addon.price : null;
if (addonPrice != null && addonPrice > 0) {
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName} <span style="color: #666;">(+${formatCurrency(addonPrice)})</span></small>`;
} else if (addonPrice === 0) {
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName} <span style="color: #666;">(Free)</span></small>`;
} else {
modifiersHtml += `<br><small style="color: #28a745; margin-left: 20px;">+ ${addonName}</small>`;
}
});
}
// Add excludes (no price)
if (item.exclude && item.exclude.length > 0) {
item.exclude.forEach(exc => {
const excName = exc.name || exc;
modifiersHtml += `<br><small style="color: #dc3545; margin-left: 20px;"> NO ${excName}</small>`;
});
}
return `
<tr>
<td style="vertical-align: top; padding: 8px;">${item.qty}x</td>
<td style="padding: 8px;">
<strong>${item.itemName || item.name}</strong>
${modifiersHtml}
</td>
<td style="text-align: right; vertical-align: top; padding: 8px;">${formatCurrency(item.price * item.qty)}</td>
</tr>
`;
}).join('');
}
content.innerHTML = `
<div class="order-details">
<div class="detail-section">
<h3>Order Information</h3>
<p><strong>Order ID:</strong> ${order.id}</p>
<p><strong>Status:</strong> ${getStatusBadge(order.localStatus || order.status)}</p>
<p><strong>Time:</strong> ${formatDateTime(order.createdAt)}</p>
<p><strong>Type:</strong> ${order.order.type || 'N/A'}</p>
</div>
<div class="detail-section">
<h3>Customer Information</h3>
<p><strong>Name:</strong> ${order.customer.name || 'N/A'}</p>
<p><strong>Phone:</strong> ${order.customer.phoneNumber || 'N/A'}</p>
${order.customer.email ? '<p><strong>Email:</strong> ' + order.customer.email + '</p>' : ''}
${order.order.deliveryAddress ? '<p><strong>Address:</strong> ' + order.order.deliveryAddress + '</p>' : ''}
</div>
<div class="detail-section">
<h3>Order Items</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 2px solid #ddd;">
<th style="text-align: left; padding: 8px;">Qty</th>
<th style="text-align: left; padding: 8px;">Item</th>
<th style="text-align: right; padding: 8px;">Price</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
<tfoot>
<tr style="border-top: 2px solid #ddd;">
<td colspan="2" style="text-align: right; padding: 8px;"><strong>Subtotal:</strong></td>
<td style="text-align: right; padding: 8px;"><strong>${formatCurrency(order.order.amount)}</strong></td>
</tr>
${order.order.taxAmount ? `
<tr>
<td colspan="2" style="text-align: right; padding: 8px;">Tax (${order.order.taxRate || 0}%):</td>
<td style="text-align: right; padding: 8px;">${formatCurrency(order.order.taxAmount)}</td>
</tr>` : ''}
${order.order.deliveryFee ? `
<tr>
<td colspan="2" style="text-align: right; padding: 8px;">Delivery Fee:</td>
<td style="text-align: right; padding: 8px;">${formatCurrency(order.order.deliveryFee)}</td>
</tr>` : ''}
<tr style="border-top: 2px solid #333;">
<td colspan="2" style="text-align: right; padding: 8px;"><strong>TOTAL:</strong></td>
<td style="text-align: right; padding: 8px;"><strong>${formatCurrency(order.totalAmount)}</strong></td>
</tr>
</tfoot>
</table>
</div>
${order.order.specialInstructions ? `
<div class="detail-section">
<h3>Special Instructions</h3>
<p>${order.order.specialInstructions}</p>
</div>` : ''}
${order.order.deliveryInstructions ? `
<div class="detail-section">
<h3>Delivery Instructions</h3>
<p>${order.order.deliveryInstructions}</p>
</div>` : ''}
${order.order.foodAllergy ? `
<div class="detail-section" style="background: #ffe6e6; padding: 15px; border-radius: 4px;">
<h3 style="color: #dc3545;">⚠ Food Allergy Warning</h3>
<p><strong>${order.order.foodAllergyNotes || 'Customer has food allergies'}</strong></p>
</div>` : ''}
</div>
`;
modal.classList.add('visible');
}
function closeDetailsModal() {
document.getElementById('detailsModal').classList.remove('visible');
}
// Close modals when clicking outside
window.addEventListener('click', function(event) {
const statusChangeModal = document.getElementById('statusChangeModal');
const cancelModal = document.getElementById('cancelModal');
const detailsModal = document.getElementById('detailsModal');
if (event.target === statusChangeModal) {
closeStatusChangeModal();
}
if (event.target === cancelModal) {
closeCancelModal();
}
if (event.target === detailsModal) {
closeDetailsModal();
}
});
// Close modals with Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeStatusChangeModal();
closeCancelModal();
closeDetailsModal();
}
});
// Connection status monitoring
function checkConnectionStatus() {
// Check local connection (dashboard server)
checkLocalConnection();
// Check external API connection
checkAPIConnection();
}
function checkLocalConnection() {
const startTime = Date.now();
fetch('/api/health/local')
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Server returned error status');
}
})
.then(data => {
connectionStatus.local.status = 'online';
connectionStatus.local.lastCheck = new Date();
connectionStatus.local.consecutiveFailures = 0;
updateConnectionUI('local', 'online', 'Connected');
})
.catch(error => {
console.error('Local connection check failed:', error);
connectionStatus.local.consecutiveFailures++;
if (connectionStatus.local.consecutiveFailures >= 2) {
connectionStatus.local.status = 'offline';
updateConnectionUI('local', 'offline', 'Disconnected');
// Show warning toast on first detection of offline
if (connectionStatus.local.consecutiveFailures === 2) {
showToast('Dashboard server connection lost', 'error');
}
}
});
}
function checkAPIConnection() {
const startTime = Date.now();
fetch('/api/health/external')
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Server returned error status');
}
})
.then(data => {
connectionStatus.api.lastCheck = new Date();
if (data.status === 'online') {
// Was offline, now online - notify user
if (connectionStatus.api.status === 'offline' && connectionStatus.api.consecutiveFailures >= 2) {
showToast('API server connection restored', 'success');
}
connectionStatus.api.status = 'online';
connectionStatus.api.consecutiveFailures = 0;
connectionStatus.api.responseTime = data.responseTime;
let label = 'Connected';
if (data.responseTime) {
label = `${data.responseTime}ms`;
}
updateConnectionUI('api', 'online', label);
} else if (data.status === 'unconfigured') {
connectionStatus.api.status = 'unconfigured';
connectionStatus.api.consecutiveFailures = 0;
updateConnectionUI('api', 'unconfigured', 'Not Configured');
} else {
connectionStatus.api.consecutiveFailures++;
if (connectionStatus.api.consecutiveFailures >= 2) {
connectionStatus.api.status = 'offline';
updateConnectionUI('api', 'offline', data.message || 'Disconnected');
// Show warning toast on first detection of offline
if (connectionStatus.api.consecutiveFailures === 2) {
showToast('API server connection lost: ' + (data.message || 'Disconnected'), 'error');
}
}
}
})
.catch(error => {
console.error('API connection check failed:', error);
connectionStatus.api.consecutiveFailures++;
if (connectionStatus.api.consecutiveFailures >= 2) {
connectionStatus.api.status = 'offline';
updateConnectionUI('api', 'offline', 'Check Failed');
}
});
}
function updateConnectionUI(type, status, label) {
let statusItem, statusIndicator, statusLabel;
if (type === 'local') {
statusItem = document.getElementById('localConnectionStatus');
statusLabel = document.getElementById('localStatusLabel');
} else if (type === 'api') {
statusItem = document.getElementById('apiConnectionStatus');
statusLabel = document.getElementById('apiStatusLabel');
}
if (!statusItem || !statusLabel) return;
statusIndicator = statusItem.querySelector('.status-indicator');
// Remove all status classes
statusIndicator.classList.remove('status-online', 'status-offline', 'status-checking', 'status-unconfigured');
// Add new status class
statusIndicator.classList.add('status-' + status);
// Update label text
statusLabel.textContent = label;
// Update title attribute for tooltip
let tooltipText = '';
if (type === 'local') {
tooltipText = 'Dashboard Server: ';
} else {
tooltipText = 'API Server (api.thinklink.ai): ';
}
tooltipText += label;
if (connectionStatus[type].lastCheck) {
const lastCheckTime = connectionStatus[type].lastCheck.toLocaleTimeString();
tooltipText += ' (Last checked: ' + lastCheckTime + ')';
}
statusItem.title = tooltipText;
}
// Manual sync trigger
function manualSync() {
const syncButton = document.getElementById('syncButton');
const syncText = syncButton ? syncButton.querySelector('.sync-text') : null;
// Prevent multiple simultaneous sync requests
if (syncButton && syncButton.classList.contains('loading')) {
return;
}
if (syncButton) {
syncButton.classList.add('loading');
syncButton.disabled = true;
if (syncText) {
syncText.textContent = 'Syncing...';
}
}
fetch('/api/sync-now', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (!data.error) {
showToast('Checking for new orders...', 'success');
// Refresh after a short delay to allow sync to complete
setTimeout(() => {
refreshOrders();
}, 2000);
} else {
showToast(data.message || 'Sync failed', 'error');
// Remove loading state on error
if (syncButton) {
syncButton.classList.remove('loading');
syncButton.disabled = false;
if (syncText) {
syncText.textContent = 'Sync Now';
}
}
}
})
.catch(error => {
console.error('Manual sync error:', error);
showToast('Sync failed', 'error');
// Remove loading state on error
if (syncButton) {
syncButton.classList.remove('loading');
syncButton.disabled = false;
if (syncText) {
syncText.textContent = 'Sync Now';
}
}
});
}

709
public/js/settings.js Normal file
View File

@@ -0,0 +1,709 @@
// Settings page functionality
// ========== Legacy functions (kept for backward compatibility) ==========
function testPrinter() {
const resultEl = document.getElementById('printerTestResult');
const selectedHidden = document.getElementById('selectedPrintersJson');
resultEl.textContent = 'Testing...';
resultEl.style.color = '#666';
const body = JSON.stringify({
selectedPrintersJson: selectedHidden ? selectedHidden.value : '[]'
});
fetch('/settings/test-printer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
})
.then(response => response.json())
.then(data => {
if (!data.error) {
resultEl.textContent = '✓ ' + data.message;
resultEl.style.color = '#28a745';
} else {
resultEl.textContent = '✗ ' + (data.message || 'Test failed');
resultEl.style.color = '#dc3545';
}
})
.catch(error => {
console.error('Test print error:', error);
resultEl.textContent = '✗ Network error';
resultEl.style.color = '#dc3545';
});
}
async function uploadLogo() {
const fileInput = document.getElementById('logoUpload');
const file = fileInput.files[0];
if (!file) {
alert('Please select a file first');
return;
}
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
if (!validTypes.includes(file.type)) {
alert('Please select a valid image file (PNG, JPG, or GIF)');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('File size must be less than 5MB');
return;
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/settings/upload-logo', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!data.error) {
alert('Logo uploaded successfully!');
location.reload();
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Logo upload error:', error);
alert('Upload failed: Network error');
}
}
// ========== New Printer Management Functions ==========
let currentPrinterId = null;
// Load and display printers
async function loadPrinters() {
const container = document.getElementById('printer-cards-container');
if (!container) return;
try {
const response = await fetch('/api/printers/list');
const data = await response.json();
if (data.error || !data.printers || data.printers.length === 0) {
container.innerHTML = `
<div class="no-printers">
<p>No printers configured yet.</p>
<p>Click "Add Printer" to configure your first printer.</p>
</div>
`;
return;
}
// Render printer cards
const cardsHTML = data.printers.map(printer => createPrinterCard(printer)).join('');
container.innerHTML = cardsHTML;
} catch (error) {
console.error('Failed to load printers:', error);
container.innerHTML = `<div class="alert alert-error">Failed to load printers: ${error.message}</div>`;
}
}
// Create HTML for a printer card
function createPrinterCard(printer) {
const defaultBadge = printer.is_default ? '<span class="printer-badge badge-default">DEFAULT</span>' : '';
const enabledBadge = printer.is_enabled
? '<span class="printer-badge badge-enabled">ENABLED</span>'
: '<span class="printer-badge badge-disabled">DISABLED</span>';
const typeLabel = {
'network': 'Network',
'com': 'Serial/COM',
'usb': 'USB',
'system': 'System Printer'
}[printer.type] || printer.type;
return `
<div class="printer-card ${!printer.is_enabled ? 'disabled' : ''}">
<div class="printer-card-header">
<div>
<h4>${escapeHtml(printer.name)}</h4>
<div class="printer-badges">
${defaultBadge}
${enabledBadge}
</div>
</div>
</div>
<div class="printer-card-body">
<div class="printer-info-row">
<span class="printer-info-label">Connection:</span>
<span class="printer-info-value">${typeLabel}: ${escapeHtml(printer.interface)}</span>
</div>
<div class="printer-info-row">
<span class="printer-info-label">Paper:</span>
<span class="printer-info-value">${printer.paper_format} (${printer.paper_width} chars) | ${printer.printer_type}</span>
</div>
</div>
<div class="printer-card-actions">
<button type="button" class="btn btn-secondary btn-sm" onclick="editPrinter(${printer.id})">Edit</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="testPrinterById(${printer.id})">Test</button>
${!printer.is_default ? `<button type="button" class="btn btn-secondary btn-sm" onclick="setDefaultPrinter(${printer.id})">Set Default</button>` : ''}
<button type="button" class="btn btn-secondary btn-sm" onclick="togglePrinterEnabled(${printer.id})">${printer.is_enabled ? 'Disable' : 'Enable'}</button>
${!printer.is_default ? `<button type="button" class="btn btn-danger btn-sm" onclick="deletePrinter(${printer.id})">Delete</button>` : ''}
</div>
</div>
`;
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Open add printer modal
function openAddPrinterModal() {
currentPrinterId = null;
document.getElementById('printerModalTitle').textContent = 'Add Printer';
document.getElementById('printerConfigForm').reset();
document.getElementById('printer_id').value = '';
// Set defaults
document.getElementById('paper_width').value = 48;
document.getElementById('paper_format').value = '80mm';
document.getElementById('printer_type_model').value = 'epson';
document.getElementById('font_size').value = 'normal';
document.getElementById('line_style').value = 'single';
document.getElementById('qr_code_size').value = 3;
document.getElementById('qr_code_correction').value = 'M';
document.getElementById('qr_code_content_template').value = 'ORDER-{id}';
document.getElementById('header_text').value = '';
document.getElementById('footer_text').value = '';
document.getElementById('business_contact_size').value = 'normal';
// Pre-fill Business Information from Receipt Template tab
try {
const tplBusinessName = document.getElementById('businessName');
const tplBusinessAddress = document.getElementById('businessAddress');
const tplBusinessPhone = document.getElementById('businessPhone');
const tplBusinessWebsite = document.getElementById('businessWebsite');
const tplBusinessEmail = document.getElementById('businessEmail');
const tplBusinessContactSize = document.getElementById('businessContactSize');
if (tplBusinessName) document.getElementById('business_name').value = tplBusinessName.value || '';
if (tplBusinessAddress) document.getElementById('business_address').value = tplBusinessAddress.value || '';
if (tplBusinessPhone) document.getElementById('business_phone').value = tplBusinessPhone.value || '';
if (tplBusinessWebsite) document.getElementById('business_website').value = tplBusinessWebsite.value || '';
if (tplBusinessEmail) document.getElementById('business_email').value = tplBusinessEmail.value || '';
if (tplBusinessContactSize) document.getElementById('business_contact_size').value = tplBusinessContactSize.value || 'normal';
} catch (_) {}
// Set checkboxes
document.getElementById('show_customer_info').checked = true;
document.getElementById('show_order_items').checked = true;
document.getElementById('show_prices').checked = true;
document.getElementById('show_timestamps').checked = true;
document.getElementById('qr_code_enabled').checked = true;
document.getElementById('is_enabled').checked = true;
document.getElementById('is_default').checked = false;
showModal('printerConfigModal');
switchPrinterModalTab('connection');
}
// Edit printer
async function editPrinter(id) {
try {
const response = await fetch(`/api/printers/${id}`);
const data = await response.json();
if (data.error) {
alert('Failed to load printer: ' + data.message);
return;
}
const printer = data.printer;
currentPrinterId = id;
document.getElementById('printerModalTitle').textContent = 'Edit Printer';
document.getElementById('printer_id').value = id;
// Fill form with printer data
document.getElementById('printer_name').value = printer.name || '';
document.getElementById('printer_type_select').value = printer.type || 'network';
document.getElementById('printer_interface').value = printer.interface || '';
document.getElementById('paper_format').value = printer.paper_format || '80mm';
document.getElementById('paper_width').value = printer.paper_width || 48;
document.getElementById('printer_type_model').value = printer.printer_type || 'epson';
document.getElementById('font_size').value = printer.font_size || 'normal';
document.getElementById('line_style').value = printer.line_style || 'single';
document.getElementById('header_text').value = printer.header_text || '';
document.getElementById('footer_text').value = printer.footer_text || '';
document.getElementById('business_name').value = printer.business_name || '';
document.getElementById('business_address').value = printer.business_address || '';
document.getElementById('business_phone').value = printer.business_phone || '';
document.getElementById('business_website').value = printer.business_website || '';
document.getElementById('business_email').value = printer.business_email || '';
document.getElementById('business_contact_size').value = printer.business_contact_size || 'normal';
document.getElementById('logo_path').value = printer.logo_path || '';
document.getElementById('logo_max_width_dots').value = printer.logo_max_width_dots || '';
if (printer.logo_path) {
document.getElementById('logo_preview').innerHTML = `<small>Current logo: ${printer.logo_path}</small>`;
} else {
document.getElementById('logo_preview').innerHTML = '';
}
document.getElementById('qr_code_size').value = printer.qr_code_size || 3;
document.getElementById('qr_code_correction').value = printer.qr_code_correction || 'M';
document.getElementById('qr_code_content_template').value = printer.qr_code_content_template || 'ORDER-{id}';
document.getElementById('show_customer_info').checked = printer.show_customer_info !== false;
document.getElementById('show_order_items').checked = printer.show_order_items !== false;
document.getElementById('show_prices').checked = printer.show_prices !== false;
document.getElementById('show_timestamps').checked = printer.show_timestamps !== false;
document.getElementById('qr_code_enabled').checked = printer.qr_code_enabled !== false;
document.getElementById('is_default').checked = printer.is_default || false;
document.getElementById('is_enabled').checked = printer.is_enabled !== false;
showModal('printerConfigModal');
switchPrinterModalTab('connection');
} catch (error) {
console.error('Failed to load printer:', error);
alert('Failed to load printer: ' + error.message);
}
}
// Save printer configuration
async function savePrinterConfig() {
const form = document.getElementById('printerConfigForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
const config = {};
for (const [key, value] of formData.entries()) {
if (key === 'id' && !value) continue; // Skip empty id
// Handle checkboxes
if (['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].includes(key)) {
config[key] = document.getElementById(key).checked;
} else if (key === 'paper_width' || key === 'qr_code_size' || key === 'logo_max_width_dots') {
const val = parseInt(value, 10);
if (!isNaN(val) && val > 0) config[key] = val;
} else {
config[key] = value;
}
}
// Ensure checkbox fields are always present in payload (unchecked boxes are omitted from FormData by default)
['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].forEach((key) => {
const el = document.getElementById(key);
if (el) {
config[key] = !!el.checked;
}
});
try {
const printerId = document.getElementById('printer_id').value;
const url = printerId ? `/api/printers/${printerId}` : '/api/printers/create';
const method = printerId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await response.json();
if (data.error) {
alert('Failed to save printer: ' + data.message);
return;
}
alert(data.message || 'Printer saved successfully');
closePrinterModal();
loadPrinters();
} catch (error) {
console.error('Failed to save printer:', error);
alert('Failed to save printer: ' + error.message);
}
}
// Delete printer
async function deletePrinter(id) {
if (!confirm('Are you sure you want to delete this printer?')) {
return;
}
try {
const response = await fetch(`/api/printers/${id}`, { method: 'DELETE' });
const data = await response.json();
if (data.error) {
alert('Failed to delete printer: ' + data.message);
return;
}
alert('Printer deleted successfully');
loadPrinters();
} catch (error) {
console.error('Failed to delete printer:', error);
alert('Failed to delete printer: ' + error.message);
}
}
// Test printer
async function testPrinterById(id) {
try {
const response = await fetch(`/api/printers/${id}/test`, { method: 'POST' });
const data = await response.json();
if (data.error) {
alert('Test failed: ' + data.message);
} else {
alert(data.message || 'Test print sent successfully');
}
} catch (error) {
console.error('Test print error:', error);
alert('Test failed: ' + error.message);
}
}
// Set default printer
async function setDefaultPrinter(id) {
try {
const response = await fetch(`/api/printers/${id}/set-default`, { method: 'POST' });
const data = await response.json();
if (data.error) {
alert('Failed to set default: ' + data.message);
} else {
loadPrinters();
}
} catch (error) {
console.error('Failed to set default:', error);
alert('Failed to set default: ' + error.message);
}
}
// Toggle printer enabled
async function togglePrinterEnabled(id) {
try {
const response = await fetch(`/api/printers/${id}/toggle-enabled`, { method: 'POST' });
const data = await response.json();
if (data.error) {
alert('Failed to toggle printer: ' + data.message);
} else {
loadPrinters();
}
} catch (error) {
console.error('Failed to toggle printer:', error);
alert('Failed to toggle printer: ' + error.message);
}
}
// Upload logo for specific printer
async function uploadLogoForPrinter() {
const fileInput = document.getElementById('logo_upload_modal');
const file = fileInput.files[0];
if (!file) {
alert('Please select a file first');
return;
}
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
if (!validTypes.includes(file.type)) {
alert('Please select a valid image file (PNG, JPG, or GIF)');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('File size must be less than 5MB');
return;
}
const formData = new FormData();
formData.append('file', file);
// If editing existing printer, include printer_id
const printerId = document.getElementById('printer_id').value;
if (printerId) {
formData.append('printer_id', printerId);
}
try {
const response = await fetch('/settings/upload-logo', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!data.error) {
alert('Logo uploaded successfully');
document.getElementById('logo_path').value = data.filepath;
document.getElementById('logo_preview').innerHTML = `<small>Logo uploaded: ${data.filepath}</small>`;
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Logo upload error:', error);
alert('Upload failed: Network error');
}
}
// Detect printers for modal
async function detectPrintersForModal() {
const listEl = document.getElementById('detected-printers-list');
listEl.innerHTML = '<p>Detecting printers...</p>';
try {
const response = await fetch('/api/printers/detect');
const data = await response.json();
if (data.error || !data.printers || data.printers.length === 0) {
listEl.innerHTML = '<p>No printers detected.</p>';
return;
}
const items = data.printers.map(p => {
const typeLabel = {
'system': 'System',
'com': 'COM'
}[p.type] || p.type;
return `
<div class="detected-printer-item">
<span><strong>${escapeHtml(p.name)}</strong> (${typeLabel})</span>
<button type="button" class="btn btn-secondary btn-sm" onclick="selectDetectedPrinter('${p.type}', '${escapeHtml(p.interface)}')">Use This</button>
</div>
`;
}).join('');
listEl.innerHTML = items;
} catch (error) {
console.error('Failed to detect printers:', error);
listEl.innerHTML = '<p>Failed to detect printers.</p>';
}
}
// Select a detected printer
function selectDetectedPrinter(type, interface) {
document.getElementById('printer_type_select').value = type;
document.getElementById('printer_interface').value = interface;
updateInterfaceHint();
}
// Update interface hint based on connection type
function updateInterfaceHint() {
const type = document.getElementById('printer_type_select').value;
const hintEl = document.getElementById('interface_hint');
const hints = {
'network': 'Enter IP:Port for network printers (e.g., 192.168.1.100:9100)',
'com': 'Enter COM port (e.g., COM1, COM3)',
'usb': 'Enter USB device path (e.g., /dev/usb/lp0)',
'system': 'Enter the exact printer name from Windows'
};
hintEl.textContent = hints[type] || 'Enter connection address';
}
// Auto-update paper width when format changes
function updatePaperWidthFromFormat() {
const format = document.getElementById('paper_format').value;
const widthInput = document.getElementById('paper_width');
const widthMap = {
'58mm': 32,
'80mm': 48,
'letter': 80
};
if (widthMap[format]) {
widthInput.value = widthMap[format];
}
}
// Modal tab switching
function switchPrinterModalTab(tabName) {
// Update tab buttons
document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.getAttribute('data-tab') === tabName) {
btn.classList.add('active');
}
});
// Update tab content
document.querySelectorAll('.printer-modal-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(tabName + '-tab-content').classList.add('active');
}
// Close printer modal
function closePrinterModal() {
hideModal('printerConfigModal');
currentPrinterId = null;
}
// Show modal
function showModal(modalId) {
document.getElementById(modalId).classList.add('visible');
}
// Hide modal
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('visible');
}
// ========== Sound Notification Functions ==========
// Upload sound file
async function uploadSound(soundType) {
const fileInputId = soundType === 'newOrder' ? 'newOrderSoundUpload' : 'canceledOrderSoundUpload';
const fileInput = document.getElementById(fileInputId);
const file = fileInput.files[0];
if (!file) {
alert('Please select a file first');
return;
}
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'];
if (!validTypes.includes(file.type)) {
alert('Please select a valid audio file (MP3, WAV, or OGG)');
return;
}
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB');
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('soundType', soundType);
try {
const response = await fetch('/settings/upload-sound', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!data.error) {
alert('Sound uploaded successfully! Please save settings to apply changes.');
location.reload();
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Sound upload error:', error);
alert('Upload failed: Network error');
}
}
// Test sound playback
async function testSound(soundType) {
try {
// Get current settings
const response = await fetch('/api/notification-settings');
const data = await response.json();
if (data.error) {
alert('Failed to load sound settings');
return;
}
const soundPath = soundType === 'newOrder'
? (data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3')
: (data.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3');
const volumeInput = document.getElementById('soundVolume');
const volume = volumeInput ? parseInt(volumeInput.value, 10) / 100 : 0.8;
const audio = new Audio(soundPath);
audio.volume = volume;
audio.play().catch(error => {
console.error('Failed to play sound:', error);
alert('Failed to play sound. Make sure the file exists and is a valid audio file.');
});
} catch (error) {
console.error('Test sound error:', error);
alert('Failed to test sound: ' + error.message);
}
}
// Update volume display
function updateVolumeDisplay() {
const volumeInput = document.getElementById('soundVolume');
const volumeValue = document.getElementById('volumeValue');
if (volumeInput && volumeValue) {
volumeValue.textContent = volumeInput.value;
}
}
// ========== Event Listeners ==========
document.addEventListener('DOMContentLoaded', function() {
// Load printers on settings page
if (document.getElementById('printer-cards-container')) {
loadPrinters();
}
// Printer modal tab switching
document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
switchPrinterModalTab(this.getAttribute('data-tab'));
});
});
// Connection type change handler
const typeSelect = document.getElementById('printer_type_select');
if (typeSelect) {
typeSelect.addEventListener('change', updateInterfaceHint);
}
// Paper format change handler
const formatSelect = document.getElementById('paper_format');
if (formatSelect) {
formatSelect.addEventListener('change', updatePaperWidthFromFormat);
}
// Volume slider handler
const volumeInput = document.getElementById('soundVolume');
if (volumeInput) {
volumeInput.addEventListener('input', updateVolumeDisplay);
}
// Update interface placeholder based on interface type (legacy support)
const interfaceSelect = document.getElementById('printerInterface');
const pathInput = document.getElementById('printerPath');
if (interfaceSelect && pathInput) {
interfaceSelect.addEventListener('change', function() {
const placeholders = {
'usb': '/dev/usb/lp0 (Linux) or COM1 (Windows)',
'network': '192.168.1.100:9100',
'serial': 'COM1 (Windows) or /dev/ttyS0 (Linux)'
};
pathInput.placeholder = placeholders[this.value] || 'Enter printer path';
});
}
});

Binary file not shown.

Binary file not shown.

2
public/uploads/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# This file ensures the uploads directory is created for logo uploads

Binary file not shown.

Binary file not shown.

117
routes/auth.js Normal file
View File

@@ -0,0 +1,117 @@
const config = require('../config');
const apiClient = require('../api-client');
async function authRoutes(fastify, options) {
// Root route - redirect based on signed session cookie validity
fastify.get('/', async (req, reply) => {
try {
const raw = req.cookies && req.cookies.kitchen_session;
if (raw) {
const { valid, value } = req.unsignCookie(raw || '');
if (valid) {
const token = config.get('authToken');
const expiry = config.get('tokenExpiry');
if (token && !apiClient.isTokenExpired(expiry) && value === token) {
return reply.redirect('/dashboard');
}
}
}
} catch (_) {}
return reply.redirect('/login');
});
// Login page
fastify.get('/login', async (req, reply) => {
try {
const raw = req.cookies && req.cookies.kitchen_session;
if (raw) {
const { valid, value } = req.unsignCookie(raw || '');
if (valid) {
const token = config.get('authToken');
const expiry = config.get('tokenExpiry');
if (token && !apiClient.isTokenExpired(expiry) && value === token) {
return reply.redirect('/dashboard');
}
}
}
} catch (_) {}
return reply.view('login', {
error: req.query.error || null,
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
});
});
// Login handler
fastify.post('/auth/login', async (req, reply) => {
const { login, password, 'g-recaptcha-response': recaptchaToken } = req.body;
// Validate inputs
if (!login || !password) {
return reply.view('login', {
error: 'Please provide email and password',
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
});
}
if (!recaptchaToken) {
return reply.view('login', {
error: 'reCAPTCHA verification required',
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
});
}
try {
// Call API to authenticate
const result = await apiClient.login(login, password, recaptchaToken);
if (result.error) {
return reply.view('login', {
error: result.message || 'Login failed',
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
});
}
// Store auth token and expiration
config.set('authToken', result.token);
config.set('tokenExpiry', result.expirationDate);
config.set('userEmail', login);
// Set signed cookie for session; secure only on HTTPS
const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https');
reply.setCookie('kitchen_session', result.token, {
signed: true,
httpOnly: true,
secure: isHttps,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60,
path: '/'
});
return reply.redirect('/dashboard');
} catch (error) {
console.error('Login error:', error.message);
return reply.view('login', {
error: 'An error occurred during login. Please try again.',
recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || ''
});
}
});
// Logout handler
fastify.post('/auth/logout', async (req, reply) => {
// Allow multiple sessions: only clear the browser cookie
reply.clearCookie('kitchen_session');
return reply.redirect('/login');
});
fastify.get('/auth/logout', async (req, reply) => {
// Allow multiple sessions: only clear the browser cookie
reply.clearCookie('kitchen_session');
return reply.redirect('/login');
});
}
module.exports = authRoutes;

35
routes/dashboard.js Normal file
View File

@@ -0,0 +1,35 @@
const config = require('../config');
const database = require('../database');
// Middleware to check authentication via signed cookie
async function requireAuth(req, reply) {
const raw = req.cookies && req.cookies.kitchen_session;
if (!raw) { reply.redirect('/login'); return; }
const { valid, value } = req.unsignCookie(raw || '');
if (!valid) { reply.redirect('/login'); return; }
const token = config.get('authToken');
const expiry = config.get('tokenExpiry');
const apiClient = require('../api-client');
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
reply.redirect('/login');
return;
}
}
async function dashboardRoutes(fastify, options) {
// Dashboard page
fastify.get('/dashboard', { preHandler: requireAuth }, async (req, reply) => {
const appConfig = config.getAll();
const stats = database.getOrderStats();
return reply.view('dashboard', {
config: appConfig,
stats: stats,
showStats: appConfig.showOrderStats !== 'false'
});
});
}
module.exports = dashboardRoutes;

343
routes/orders.js Normal file
View File

@@ -0,0 +1,343 @@
const config = require('../config');
const database = require('../database');
const apiClient = require('../api-client');
const printer = require('../printer');
// Middleware to check authentication via signed cookie (JSON response)
async function requireAuth(req, reply) {
const raw = req.cookies && req.cookies.kitchen_session;
if (!raw) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); }
const { valid, value } = req.unsignCookie(raw || '');
if (!valid) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); }
const token = config.get('authToken');
const expiry = config.get('tokenExpiry');
const apiClient = require('../api-client');
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
return reply.code(401).send({ error: true, message: 'Not authenticated' });
}
}
async function ordersRoutes(fastify, options) {
// Get orders with filters
fastify.get('/api/orders', { preHandler: requireAuth }, async (req, reply) => {
const filters = {
status: req.query.status,
limit: parseInt(req.query.limit, 10) || 50
};
// Get today's date for stats
const today = new Date();
filters.date = today;
const orders = database.getOrders(filters);
const stats = database.getOrderStats();
return { error: false, orders, stats };
});
// Update order status
fastify.post('/api/orders/:id/status', { preHandler: requireAuth }, async (req, reply) => {
const orderId = parseInt(req.params.id, 10);
const { status } = req.body;
if (!status) {
return { error: true, message: 'Status is required' };
}
// Valid local statuses
const validStatuses = ['new', 'preparing', 'ready', 'completed'];
if (!validStatuses.includes(status)) {
return { error: true, message: 'Invalid status' };
}
try {
// Update local database
database.updateOrderStatus(orderId, status);
// Sync to backend if status is completed (maps to finished)
if (status === 'completed') {
const appConfig = config.getAll();
const order = database.getOrderById(orderId);
if (order && appConfig.authToken && appConfig.selectedBotId) {
// Determine the action based on order type
let action = 'finished';
if (order.order.type === 'delivery') {
action = 'delivered';
} else if (order.order.type === 'pickup') {
action = 'picked_up';
}
const result = await apiClient.modifyOrder(
appConfig.authToken,
appConfig.selectedBotId,
orderId,
action
);
if (result.error) {
console.error('Failed to sync order status to backend:', result.message);
}
}
}
return { error: false };
} catch (error) {
console.error('Failed to update order status:', error.message);
return { error: true, message: 'Failed to update order status' };
}
});
// Cancel order
fastify.post('/api/orders/:id/cancel', { preHandler: requireAuth }, async (req, reply) => {
const orderId = parseInt(req.params.id, 10);
const { reason } = req.body;
try {
// Check if cancellation already printed to prevent duplicates
if (database.hasPrintedCancellation(orderId)) {
console.log(`[API] Cancellation already printed for order #${orderId}, skipping duplicate`);
database.updateOrderStatus(orderId, 'canceled');
return { error: false, message: 'Order already canceled' };
}
// Update local database
database.updateOrderStatus(orderId, 'canceled');
// Sync to backend
const appConfig = config.getAll();
if (appConfig.authToken && appConfig.selectedBotId) {
const result = await apiClient.modifyOrder(
appConfig.authToken,
appConfig.selectedBotId,
orderId,
'cancel',
reason || 'Canceled by kitchen'
);
if (result.error) {
console.error('Failed to sync cancellation to backend:', result.message);
}
}
// Print cancellation receipt
const order = database.getOrderById(orderId);
if (order) {
try {
// Add to print queue with deduplication check
const jobId = database.addToPrintQueue(orderId, 'canceled');
if (!jobId) {
console.log(`[API] Cancellation print job not created (duplicate prevention) for order #${orderId}`);
return { error: false, message: 'Cancellation recorded' };
}
database.markPrintJobProcessing(jobId);
// Get enabled printers from database
const printerConfigs = database.getEnabledPrinters();
let result;
if (printerConfigs && printerConfigs.length > 0) {
// Use new per-printer config system
result = await printer.printOrderReceiptWithPrinterConfigs(
order,
printerConfigs,
'canceled',
{ reason: reason || 'Canceled by kitchen' }
);
} else {
// Fallback to legacy system if no printer configs
if (!printer.printer) {
printer.initializePrinter(appConfig);
}
result = await printer.printOrderReceipt(order, 'canceled', { reason: reason || 'Canceled by kitchen' });
}
if (result && result.success) {
database.markOrderPrinted(orderId);
database.markPrintJobCompleted(jobId);
// Cleanup any other pending jobs for this order+type
database.cleanupDuplicateJobs(jobId, orderId, 'canceled');
} else {
// Mark as pending for worker retry
database.markPrintJobPending(jobId);
}
} catch (error) {
console.error('Failed to print cancellation receipt:', error.message);
// Let the worker retry - find the job and mark it pending
try {
const lastJobIdRow = database.db.prepare("SELECT id FROM print_queue WHERE order_id = ? AND print_type = 'canceled' ORDER BY id DESC LIMIT 1").get(orderId);
if (lastJobIdRow && lastJobIdRow.id) { database.markPrintJobPending(lastJobIdRow.id); }
} catch (_) {}
}
}
return { error: false };
} catch (error) {
console.error('Failed to cancel order:', error.message);
return { error: true, message: 'Failed to cancel order' };
}
});
// Reprint order
fastify.post('/api/orders/:id/reprint', { preHandler: requireAuth }, async (req, reply) => {
const orderId = parseInt(req.params.id, 10);
try {
const order = database.getOrderById(orderId);
if (!order) {
return { error: true, message: 'Order not found' };
}
const printType = order.localStatus === 'canceled' ? 'canceled' : 'new';
// Check for recent ACTIVE job to prevent double-enqueue while in-flight
const activeCheck = database.hasActiveOrRecentJob(orderId, 'reprint', 10);
if (activeCheck.hasActive && (activeCheck.status === 'pending' || activeCheck.status === 'processing')) {
console.log(`[API] Reprint request for order #${orderId} blocked - active job ${activeCheck.jobId} (${activeCheck.status}) exists`);
return { error: false, message: 'Print already in progress' };
}
// Add to print queue with deduplication
const jobId = database.addToPrintQueue(orderId, 'reprint');
if (!jobId) {
console.log(`[API] Reprint job not created (duplicate prevention) for order #${orderId}`);
return { error: false, message: 'Print recently completed, skipping duplicate' };
}
database.markPrintJobProcessing(jobId);
// Print receipt using per-printer configs
const printerConfigs = database.getEnabledPrinters();
let result;
if (printerConfigs && printerConfigs.length > 0) {
// Use new per-printer config system
result = await printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, printType, { cooldownMs: 2000 });
} else {
// Fallback to legacy system
const appConfig = config.getAll();
printer.initializePrinter(appConfig);
result = await printer.printOrderReceipt(order, printType, { cooldownMs: 2000 });
}
if (result && result.success) {
database.markOrderPrinted(orderId);
database.markPrintJobCompleted(jobId);
// Cleanup any other pending jobs for this order+type
database.cleanupDuplicateJobs(jobId, orderId, 'reprint');
return { error: false, message: (result && result.message) ? result.message : 'Receipt sent to printer' };
} else {
// Mark as pending so the worker can retry when printer is online
database.markPrintJobPending(jobId);
return { error: true, message: result.error || 'Print failed' };
}
} catch (error) {
console.error('Failed to reprint order:', error.message);
try { database.resetStuckProcessingJobs(60); } catch (_) {}
return { error: true, message: 'Failed to reprint order' };
}
});
// Get single order details
fastify.get('/api/orders/:id', { preHandler: requireAuth }, async (req, reply) => {
const orderId = parseInt(req.params.id, 10);
const order = database.getOrderById(orderId);
if (!order) {
return { error: true, message: 'Order not found' };
}
return { error: false, order };
});
// Manual sync trigger (for testing/debugging)
fastify.post('/api/sync-now', { preHandler: requireAuth }, async (req, reply) => {
try {
// Trigger the poller manually
if (fastify.orderPoller) {
fastify.orderPoller.poll();
return { error: false, message: 'Manual sync triggered' };
} else {
return { error: true, message: 'Order poller not available' };
}
} catch (error) {
console.error('Manual sync error:', error.message);
return { error: true, message: error.message };
}
});
// Health check for external API server
fastify.get('/api/health/external', { preHandler: requireAuth }, async (req, reply) => {
try {
const appConfig = config.getAll();
const token = appConfig.authToken;
const botId = appConfig.selectedBotId;
if (!token || !botId) {
return {
error: false,
status: 'unconfigured',
message: 'API not configured',
timestamp: new Date().toISOString()
};
}
// Check if token is expired
if (apiClient.isTokenExpired(appConfig.tokenExpiry)) {
return {
error: false,
status: 'offline',
message: 'Token expired',
timestamp: new Date().toISOString()
};
}
// Try to fetch bots list as a lightweight health check
const startTime = Date.now();
const result = await apiClient.getBots(token);
const responseTime = Date.now() - startTime;
if (result.error) {
return {
error: false,
status: 'offline',
message: result.message || 'API server unreachable',
responseTime: responseTime,
timestamp: new Date().toISOString()
};
}
return {
error: false,
status: 'online',
message: 'API server connected',
responseTime: responseTime,
timestamp: new Date().toISOString()
};
} catch (error) {
console.error('External API health check error:', error.message);
return {
error: false,
status: 'offline',
message: error.message || 'Health check failed',
timestamp: new Date().toISOString()
};
}
});
// Health check for local dashboard server
fastify.get('/api/health/local', { preHandler: requireAuth }, async (req, reply) => {
return {
error: false,
status: 'online',
message: 'Local server connected',
timestamp: new Date().toISOString()
};
});
}
module.exports = ordersRoutes;

459
routes/settings.js Normal file
View File

@@ -0,0 +1,459 @@
const config = require('../config');
const database = require('../database');
const apiClient = require('../api-client');
const printer = require('../printer');
const path = require('path');
const fs = require('fs');
const { pipeline } = require('stream/promises');
// Middleware to check authentication via signed cookie
async function requireAuth(req, reply) {
const raw = req.cookies && req.cookies.kitchen_session;
if (!raw) { reply.redirect('/login'); return; }
const { valid, value } = req.unsignCookie(raw || '');
if (!valid) { reply.redirect('/login'); return; }
const token = config.get('authToken');
const expiry = config.get('tokenExpiry');
const apiClient = require('../api-client');
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
reply.redirect('/login');
return;
}
}
async function settingsRoutes(fastify, options) {
// Settings page
fastify.get('/settings', { preHandler: requireAuth }, async (req, reply) => {
const appConfig = config.getAll();
// Fetch available bots
let bots = [];
if (appConfig.authToken) {
const botsResult = await apiClient.getBots(appConfig.authToken);
if (!botsResult.error && botsResult.bots) {
bots = botsResult.bots;
}
}
return reply.view('settings', {
config: appConfig,
bots: bots,
message: req.query.message || null,
error: req.query.error || null
});
});
// Save settings
fastify.post('/settings/save', { preHandler: requireAuth }, async (req, reply) => {
try {
const {
selectedBotId,
pollingInterval,
dashboardRefreshInterval,
showOrderStats,
soundNotificationsEnabled,
soundVolume,
printerType,
printerInterface,
printerPath,
printerWidth,
fontSize,
lineStyle,
qrCodeEnabled,
qrCodeSize,
qrCodeCorrection,
qrCodeContentTemplate,
headerText,
footerText,
businessName,
businessAddress,
businessPhone,
businessWebsite,
businessEmail,
businessContactSize,
showCustomerInfo,
showOrderItems,
showPrices,
showTimestamps,
selectedPrintersJson
} = req.body;
// Validate and save configuration
const configToSave = {};
if (selectedBotId) configToSave.selectedBotId = selectedBotId;
if (pollingInterval) configToSave.pollingInterval = pollingInterval;
if (dashboardRefreshInterval) configToSave.dashboardRefreshInterval = dashboardRefreshInterval;
configToSave.showOrderStats = showOrderStats === 'on' ? 'true' : 'false';
// Sound notification settings
configToSave.soundNotificationsEnabled = soundNotificationsEnabled === 'on' ? 'true' : 'false';
if (soundVolume !== undefined) configToSave.soundVolume = soundVolume;
if (printerType) configToSave.printerType = printerType;
if (printerInterface) configToSave.printerInterface = printerInterface;
if (printerPath) configToSave.printerPath = printerPath;
if (printerWidth) configToSave.printerWidth = printerWidth;
if (fontSize) configToSave.fontSize = fontSize;
if (lineStyle) configToSave.lineStyle = lineStyle;
configToSave.qrCodeEnabled = qrCodeEnabled === 'on' ? 'true' : 'false';
if (qrCodeSize) configToSave.qrCodeSize = parseInt(qrCodeSize, 10);
if (qrCodeCorrection) configToSave.qrCodeCorrection = qrCodeCorrection;
if (qrCodeContentTemplate !== undefined) configToSave.qrCodeContentTemplate = qrCodeContentTemplate;
if (headerText !== undefined) configToSave.headerText = headerText;
if (footerText !== undefined) configToSave.footerText = footerText;
if (businessName !== undefined) configToSave.businessName = businessName;
if (businessAddress !== undefined) configToSave.businessAddress = businessAddress;
if (businessPhone !== undefined) configToSave.businessPhone = businessPhone;
if (businessWebsite !== undefined) configToSave.businessWebsite = businessWebsite;
if (businessEmail !== undefined) configToSave.businessEmail = businessEmail;
if (businessContactSize) configToSave.businessContactSize = businessContactSize;
configToSave.showCustomerInfo = showCustomerInfo === 'on' ? 'true' : 'false';
configToSave.showOrderItems = showOrderItems === 'on' ? 'true' : 'false';
configToSave.showPrices = showPrices === 'on' ? 'true' : 'false';
configToSave.showTimestamps = showTimestamps === 'on' ? 'true' : 'false';
// Multi-printer selection (stored raw JSON string)
// Smart sync: If multi-printer list is empty and main printer is configured,
// auto-populate multi-printer with main printer to avoid confusion
if (selectedPrintersJson !== undefined) {
try {
let parsed = JSON.parse(selectedPrintersJson || '[]');
if (!Array.isArray(parsed)) parsed = [];
// If multi-printer list is empty but main printer is configured,
// auto-add the main printer to the multi-printer list
if (parsed.length === 0 && printerInterface && printerPath) {
const mainType = printerInterface === 'serial' ? 'com' : printerInterface;
parsed.push({
type: mainType,
interface: printerPath
});
console.log('[Settings] Auto-populated multi-printer list with main printer:', mainType, printerPath);
}
configToSave.selectedPrintersJson = JSON.stringify(parsed);
} catch (e) {
console.warn('[Settings] Failed to process selectedPrintersJson:', e.message);
// ignore malformed input; keep previous config
}
}
// Save to database
config.setMultiple(configToSave);
// Reinitialize printer with new config
const updatedConfig = config.getAll();
printer.initializePrinter(updatedConfig);
return reply.redirect('/settings?message=Settings saved successfully');
} catch (error) {
console.error('Failed to save settings:', error.message);
return reply.redirect('/settings?error=Failed to save settings');
}
});
// Test printer
fastify.post('/settings/test-printer', { preHandler: requireAuth }, async (req, reply) => {
try {
const appConfig = config.getAll();
// Allow ad-hoc test using current unsaved selections from UI
if (req.body && typeof req.body.selectedPrintersJson !== 'undefined') {
try {
const parsed = JSON.parse(req.body.selectedPrintersJson || '[]');
if (Array.isArray(parsed)) {
appConfig.selectedPrintersJson = JSON.stringify(parsed);
}
} catch (_) {
// Ignore bad input for test; fall back to saved config
}
}
// Always initialize to ensure latest selections are used
printer.initializePrinter(appConfig);
const result = await printer.testPrint();
if (result.success) {
return { error: false, message: 'Test print successful' };
} else {
return { error: true, message: result.error || 'Test print failed' };
}
} catch (error) {
console.error('Test print error:', error.message);
return { error: true, message: error.message };
}
});
// Detect available printers and COM ports (hardware detection)
fastify.get('/api/printers/detect', { preHandler: requireAuth }, async (req, reply) => {
try {
const list = await printer.getAvailablePrinters();
return { error: false, printers: list };
} catch (error) {
console.error('List printers error:', error.message);
return { error: true, message: error.message, printers: [] };
}
});
// Get all configured printers from database
fastify.get('/api/printers/list', { preHandler: requireAuth }, async (req, reply) => {
try {
const printers = database.getAllPrinters();
return { error: false, printers };
} catch (error) {
console.error('Get printers error:', error.message);
return { error: true, message: error.message, printers: [] };
}
});
// Get single printer configuration
fastify.get('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => {
try {
const printerId = parseInt(req.params.id, 10);
const printerConfig = database.getPrinter(printerId);
if (!printerConfig) {
return { error: true, message: 'Printer not found' };
}
return { error: false, printer: printerConfig };
} catch (error) {
console.error('Get printer error:', error.message);
return { error: true, message: error.message };
}
});
// Create new printer configuration
fastify.post('/api/printers/create', { preHandler: requireAuth }, async (req, reply) => {
try {
const printerId = database.addPrinter(req.body);
const newPrinter = database.getPrinter(printerId);
return { error: false, message: 'Printer created successfully', printer: newPrinter };
} catch (error) {
console.error('Create printer error:', error.message);
return { error: true, message: error.message };
}
});
// Update printer configuration
fastify.put('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => {
try {
const printerId = parseInt(req.params.id, 10);
database.updatePrinter(printerId, req.body);
const updatedPrinter = database.getPrinter(printerId);
return { error: false, message: 'Printer updated successfully', printer: updatedPrinter };
} catch (error) {
console.error('Update printer error:', error.message);
return { error: true, message: error.message };
}
});
// Delete printer configuration
fastify.delete('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => {
try {
const printerId = parseInt(req.params.id, 10);
database.deletePrinter(printerId);
return { error: false, message: 'Printer deleted successfully' };
} catch (error) {
console.error('Delete printer error:', error.message);
return { error: true, message: error.message };
}
});
// Set printer as default
fastify.post('/api/printers/:id/set-default', { preHandler: requireAuth }, async (req, reply) => {
try {
const printerId = parseInt(req.params.id, 10);
database.setDefaultPrinter(printerId);
return { error: false, message: 'Default printer updated' };
} catch (error) {
console.error('Set default printer error:', error.message);
return { error: true, message: error.message };
}
});
// Toggle printer enabled/disabled
fastify.post('/api/printers/:id/toggle-enabled', { preHandler: requireAuth }, async (req, reply) => {
try {
const printerId = parseInt(req.params.id, 10);
const result = database.togglePrinterEnabled(printerId);
return { error: false, message: 'Printer status updated', is_enabled: result.is_enabled };
} catch (error) {
console.error('Toggle printer error:', error.message);
return { error: true, message: error.message };
}
});
// Test specific printer
fastify.post('/api/printers/:id/test', { preHandler: requireAuth }, async (req, reply) => {
try {
const printerId = parseInt(req.params.id, 10);
const printerConfig = database.getPrinter(printerId);
if (!printerConfig) {
return { error: true, message: 'Printer not found' };
}
const result = await printer.testPrintWithConfig(printerConfig);
if (result.success) {
return { error: false, message: result.message || 'Test print successful' };
} else {
return { error: true, message: result.error || 'Test print failed' };
}
} catch (error) {
console.error('Test printer error:', error.message);
return { error: true, message: error.message };
}
});
// Upload logo - requires @fastify/multipart (now supports per-printer logos)
fastify.post('/settings/upload-logo', { preHandler: requireAuth }, async (req, reply) => {
try {
// Get multipart data
const data = await req.file();
if (!data) {
return { error: true, message: 'No file uploaded' };
}
// Ensure uploads directory exists
const uploadsDir = path.join(__dirname, '..', 'public', 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Save file
const filename = `logo-${Date.now()}${path.extname(data.filename)}`;
const filepath = path.join(uploadsDir, filename);
// Write file using stream
await pipeline(data.file, fs.createWriteStream(filepath));
// If printer_id is provided in fields, update that printer's logo
// Otherwise update global config (backward compatibility)
const fields = data.fields;
const printerId = fields && fields.printer_id ? parseInt(fields.printer_id.value, 10) : null;
if (printerId) {
// Update specific printer's logo
const printerConfig = database.getPrinter(printerId);
if (printerConfig) {
database.updatePrinter(printerId, { ...printerConfig, logo_path: filepath });
}
} else {
// Update global config (backward compatibility)
config.set('logoPath', filepath);
try {
const updated = config.getAll();
printer.initializePrinter(updated);
} catch (e) {
// proceed even if reinit fails
}
}
return { error: false, message: 'Logo uploaded successfully', path: `/public/uploads/${filename}`, filepath };
} catch (error) {
console.error('Logo upload error:', error.message);
return { error: true, message: error.message };
}
});
// Get available bots
fastify.get('/api/bots', { preHandler: requireAuth }, async (req, reply) => {
const appConfig = config.getAll();
if (!appConfig.authToken) {
return { error: true, message: 'Not authenticated' };
}
const result = await apiClient.getBots(appConfig.authToken);
if (result.error) {
return { error: true, message: result.message };
}
return { error: false, bots: result.bots || [] };
});
// Get notification settings (for dashboard)
fastify.get('/api/notification-settings', async (req, reply) => {
try {
const appConfig = config.getAll();
return {
error: false,
soundNotificationsEnabled: appConfig.soundNotificationsEnabled || 'true',
soundVolume: appConfig.soundVolume || '80',
newOrderSoundPath: appConfig.newOrderSoundPath || '/public/sounds/new-order-notification.mp3',
canceledOrderSoundPath: appConfig.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3'
};
} catch (error) {
console.error('Failed to get notification settings:', error.message);
return { error: true, message: error.message };
}
});
// Upload sound file for notifications
fastify.post('/settings/upload-sound', { preHandler: requireAuth }, async (req, reply) => {
try {
// Get multipart data
const data = await req.file();
if (!data) {
return { error: true, message: 'No file uploaded' };
}
// Ensure sounds directory exists
const soundsDir = path.join(__dirname, '..', 'public', 'sounds');
if (!fs.existsSync(soundsDir)) {
fs.mkdirSync(soundsDir, { recursive: true });
}
// Validate file type
const validExtensions = ['.mp3', '.wav', '.ogg'];
const fileExt = path.extname(data.filename).toLowerCase();
if (!validExtensions.includes(fileExt)) {
return { error: true, message: 'Invalid file type. Please upload MP3, WAV, or OGG file.' };
}
// Save file
const filename = `notification-${Date.now()}${fileExt}`;
const filepath = path.join(soundsDir, filename);
// Write file using stream
await pipeline(data.file, fs.createWriteStream(filepath));
// Get the sound type from fields (newOrder or canceled)
const fields = data.fields;
const soundType = fields && fields.soundType ? fields.soundType.value : null;
const publicPath = `/public/sounds/${filename}`;
// Update config based on sound type
if (soundType === 'newOrder') {
config.set('newOrderSoundPath', publicPath);
} else if (soundType === 'canceled') {
config.set('canceledOrderSoundPath', publicPath);
}
return {
error: false,
message: 'Sound uploaded successfully',
path: publicPath,
soundType: soundType
};
} catch (error) {
console.error('Sound upload error:', error.message);
return { error: true, message: error.message };
}
});
}
module.exports = settingsRoutes;

442
server.js Normal file
View File

@@ -0,0 +1,442 @@
// Load environment variables
require('dotenv').config();
const { checkAndUpdate } = require('./updater');
const Fastify = require('fastify');
const path = require('path');
const database = require('./database');
const config = require('./config');
const apiClient = require('./api-client');
const printer = require('./printer');
const PrintQueueWorker = require('./print-worker');
const fastify = Fastify({
logger: true
});
const isDev = false;
// Order Poller Class
class OrderPoller {
constructor(apiClient, database, printer) {
this.apiClient = apiClient;
this.db = database;
this.printer = printer;
this.intervalId = null;
this.isPolling = false;
}
async start() {
console.log('Starting order poller...');
// Initial poll
this.poll();
// Schedule recurring polls
this.scheduleNextPoll();
}
scheduleNextPoll() {
const appConfig = config.getAll();
const interval = parseInt(appConfig.pollingInterval, 10) || 15000;
if (this.intervalId) {
clearTimeout(this.intervalId);
}
this.intervalId = setTimeout(() => {
this.poll();
this.scheduleNextPoll();
}, interval);
}
async poll() {
if (this.isPolling) {
console.log('Poll already in progress, skipping...');
return;
}
this.isPolling = true;
try {
const appConfig = config.getAll();
// Check if configured
if (!appConfig.authToken || !appConfig.selectedBotId) {
console.log('Not configured yet, skipping poll');
this.isPolling = false;
return;
}
// Check if token is expired
if (apiClient.isTokenExpired(appConfig.tokenExpiry)) {
console.log('Token expired, please re-login');
this.isPolling = false;
return;
}
// Get last synced order ID
const lastOrder = this.db.getLastOrder();
const afterId = lastOrder ? lastOrder.order_id : 0;
console.log(`Polling for new orders (afterId: ${afterId})...`);
// Fetch new orders from API
const result = await this.apiClient.getOrders(
appConfig.authToken,
appConfig.selectedBotId,
afterId,
{ includeCanceled: true, limit: 50 }
);
if (result.error) {
console.error('Failed to fetch orders:', result.message);
this.isPolling = false;
return;
}
const orders = result.orders || [];
console.log(`Received ${orders.length} orders from API`);
// Process each order
for (const order of orders) {
const existingOrder = this.db.getOrderById(order.id);
if (!existingOrder) {
// New order - save and print
console.log(`New order detected: #${order.id}`);
this.db.insertOrder(order);
// Initialize printer if needed
if (!this.printer.printer) {
this.printer.initializePrinter(appConfig);
}
// Add to print queue with deduplication check
const newJobId = this.db.addToPrintQueue(order.id, 'new');
if (!newJobId) {
console.log(`Print job not created for order #${order.id} (duplicate prevention)`);
continue;
}
this.db.markPrintJobProcessing(newJobId);
try {
// Use per-printer config system
const printerConfigs = this.db.getEnabledPrinters();
let result;
if (printerConfigs && printerConfigs.length > 0) {
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'new');
} else {
// Fallback to legacy system
if (!this.printer.printer || !this.printer.config) {
const currentConfig = require('./config').getAll();
this.printer.initializePrinter(currentConfig);
}
result = await this.printer.printOrderReceipt(order, 'new');
}
if (result && result.success) {
this.db.markOrderPrinted(order.id);
this.db.markPrintJobCompleted(newJobId);
// Cleanup any other pending jobs for this order+type
this.db.cleanupDuplicateJobs(newJobId, order.id, 'new');
console.log(`✓ Receipt printed for order #${order.id}`);
} else {
console.error(`✗ Print result indicates failure for order #${order.id}:`, result && result.error ? result.error : 'Unknown error');
this.db.markPrintJobPending(newJobId);
}
} catch (error) {
console.error(`✗ Failed to print order #${order.id}:`, error.message);
console.log(' Order saved to database. You can reprint from dashboard.');
this.db.markPrintJobPending(newJobId);
}
} else {
// Check for status changes (cancellation status from backend)
const statusChanged = existingOrder.status !== order.status;
if (statusChanged) {
console.log(`Order #${order.id} status changed: ${existingOrder.status}${order.status}`);
this.db.updateOrder(order);
// Print cancellation receipt if order was canceled
if (order.status === 'canceled' && existingOrder.status !== 'canceled') {
console.log(`Order #${order.id} was canceled - printing cancellation receipt...`);
// Update local status to 'canceled' so it shows on the dashboard
this.db.updateOrderStatus(order.id, 'canceled');
// Check if cancellation already printed to prevent duplicates
if (this.db.hasPrintedCancellation(order.id)) {
console.log(`Skipping duplicate cancellation print for order #${order.id}`);
} else {
// Add to print queue with deduplication check
const jobId = this.db.addToPrintQueue(order.id, 'canceled');
if (!jobId) {
console.log(`Cancellation job not created for order #${order.id} (duplicate prevention)`);
} else {
this.db.markPrintJobProcessing(jobId);
try {
const cancelReason = order.cancellationReason || order.order?.cancellationReason || 'Order canceled';
// Use per-printer config system
const printerConfigs = this.db.getEnabledPrinters();
let result;
if (printerConfigs && printerConfigs.length > 0) {
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'canceled', { reason: cancelReason });
} else {
// Fallback to legacy system
if (!this.printer.printer || !this.printer.config) {
const currentConfig = require('./config').getAll();
this.printer.initializePrinter(currentConfig);
}
result = await this.printer.printOrderReceipt(order, 'canceled', { reason: cancelReason });
}
if (result && result.success) {
this.db.markOrderPrinted(order.id);
this.db.markPrintJobCompleted(jobId);
// Cleanup any other pending jobs for this order+type
this.db.cleanupDuplicateJobs(jobId, order.id, 'canceled');
console.log(`✓ Cancellation receipt printed for order #${order.id}`);
} else {
console.error(`✗ Cancellation print failed for order #${order.id}:`, result && result.error ? result.error : 'Unknown error');
this.db.markPrintJobPending(jobId);
}
} catch (error) {
console.error(`✗ Failed to print cancellation for order #${order.id}:`, error.message);
console.log(' Order updated in database. You can reprint from dashboard.');
this.db.markPrintJobPending(jobId);
}
}
}
}
}
}
}
// Reconciliation fetch: scan recent orders window to catch updates (e.g., cancellations)
try {
const recentResult = await this.apiClient.getOrders(
appConfig.authToken,
appConfig.selectedBotId,
0,
{ includeCanceled: true, limit: 200 }
);
if (!recentResult.error) {
const recentOrders = recentResult.orders || [];
for (const order of recentOrders) {
const existingOrder = this.db.getOrderById(order.id);
if (!existingOrder) {
// Skip new orders here; they are handled by the main afterId fetch
continue;
}
// Check for status changes (cancellation status from backend)
const statusChanged = existingOrder.status !== order.status;
if (statusChanged) {
console.log(`(reconcile) Order #${order.id} status changed: ${existingOrder.status}${order.status}`);
this.db.updateOrder(order);
// Print cancellation receipt if order was canceled
if (order.status === 'canceled' && existingOrder.status !== 'canceled') {
console.log(`(reconcile) Order #${order.id} was canceled - printing cancellation receipt...`);
// Update local status to 'canceled' so it shows on the dashboard
this.db.updateOrderStatus(order.id, 'canceled');
// Check if cancellation already printed to prevent duplicates
if (this.db.hasPrintedCancellation(order.id)) {
console.log(`(reconcile) Skipping duplicate cancellation print for order #${order.id}`);
} else {
// Add to print queue with deduplication check
const jobId = this.db.addToPrintQueue(order.id, 'canceled');
if (!jobId) {
console.log(`(reconcile) Cancellation job not created for order #${order.id} (duplicate prevention)`);
} else {
this.db.markPrintJobProcessing(jobId);
try {
const cancelReason = order.cancellationReason || order.order?.cancellationReason || 'Order canceled';
// Use per-printer config system
const printerConfigs = this.db.getEnabledPrinters();
let result;
if (printerConfigs && printerConfigs.length > 0) {
result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'canceled', { reason: cancelReason });
} else {
// Fallback to legacy system
if (!this.printer.printer || !this.printer.config) {
const currentConfig = require('./config').getAll();
this.printer.initializePrinter(currentConfig);
}
result = await this.printer.printOrderReceipt(order, 'canceled', { reason: cancelReason });
}
if (result && result.success) {
this.db.markOrderPrinted(order.id);
this.db.markPrintJobCompleted(jobId);
// Cleanup any other pending jobs for this order+type
this.db.cleanupDuplicateJobs(jobId, order.id, 'canceled');
console.log(`✓ Cancellation receipt printed for order #${order.id}`);
} else {
console.error(`✗ Cancellation print failed for order #${order.id}:`, result && result.error ? result.error : 'Unknown error');
this.db.markPrintJobPending(jobId);
}
} catch (error) {
console.error(`✗ Failed to print cancellation for order #${order.id}:`, error.message);
console.log(' Order updated in database. You can reprint from dashboard.');
this.db.markPrintJobPending(jobId);
}
}
}
}
}
}
}
} catch (reconcileErr) {
console.error('Reconciliation fetch error:', reconcileErr.message);
}
} catch (error) {
console.error('Poll error:', error.message);
}
this.isPolling = false;
}
stop() {
if (this.intervalId) {
clearTimeout(this.intervalId);
this.intervalId = null;
}
console.log('Order poller stopped');
}
restart() {
this.stop();
this.start();
}
}
// Main initialization
async function main() {
try {
// Initialize database
database.init();
console.log('Database initialized');
// Register plugins
await fastify.register(require('@fastify/view'), {
engine: {
ejs: require('ejs')
},
root: path.join(__dirname, 'views')
});
await fastify.register(require('@fastify/static'), {
root: path.join(__dirname, 'public'),
prefix: '/public/'
});
await fastify.register(require('@fastify/cookie'), {
secret: process.env.COOKIE_SECRET || 'kitchen-agent-secret-key-change-in-production',
hook: 'onRequest'
});
await fastify.register(require('@fastify/formbody'));
await fastify.register(require('@fastify/multipart'), {
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit for logo uploads
}
});
// Register routes
await fastify.register(require('./routes/auth'));
await fastify.register(require('./routes/dashboard'));
await fastify.register(require('./routes/settings'));
await fastify.register(require('./routes/orders'));
// Initialize printer with config
const appConfig = config.getAll();
if (appConfig.printerType && appConfig.printerPath) {
try {
printer.initializePrinter(appConfig);
console.log('Printer initialized successfully');
} catch (error) {
console.error('Failed to initialize printer:', error.message);
console.log('Printer can be configured later in Settings');
}
} else {
console.log('Printer not configured - configure in Settings');
}
// Start order poller
const poller = new OrderPoller(apiClient, database, printer);
const printWorker = new PrintQueueWorker(database, config, printer);
// Make poller available globally for restart after settings change
fastify.decorate('orderPoller', poller);
fastify.decorate('printWorker', printWorker);
// Start server
const port = parseInt(process.env.PORT, 10) || 3000;
const host = process.env.HOST || '0.0.0.0';
await fastify.listen({ port, host });
const addresses = fastify.server.address();
console.log('\n=================================================');
console.log('Think Link AI Kitchen Agent is running!');
console.log(`Access at: http://localhost:${port}`);
console.log(`Or use your computer's IP address from other devices`);
console.log('=================================================\n');
// Kick off auto-update check (at boot and on interval)
if (isDev) {
console.log('Dev mode detected: skipping auto-update checks');
} else {
try { checkAndUpdate(); } catch (_) {}
const envIntervalRaw = process.env.UPDATE_CHECK_INTERVAL_MS;
const envIntervalMs = envIntervalRaw ? parseInt(envIntervalRaw, 10) : NaN;
const updateIntervalMs = (!Number.isNaN(envIntervalMs) && envIntervalMs > 0) ? envIntervalMs : (5 * 60 * 1000);
setInterval(() => { try { checkAndUpdate(); } catch (e) { fastify.log.error(e); } }, updateIntervalMs);
}
// Start polling after server is up
poller.start();
// Start print queue worker
printWorker.start();
// Handle shutdown gracefully (PM2 reload-friendly)
const gracefulShutdown = async () => {
console.log('\nShutting down gracefully...');
poller.stop();
try { printWorker.stop(); } catch (_) {}
try { database.close(); } catch (_) {}
try { await fastify.close(); } catch (_) {}
process.exit(0);
};
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
process.on('message', (msg) => { if (msg === 'shutdown') gracefulShutdown(); });
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
// Start the application
main();

128
test-startup.js Normal file
View File

@@ -0,0 +1,128 @@
/**
* Kitchen Agent - Startup Test
* Tests the complete server startup and shutdown
*/
console.log('Testing Kitchen Agent startup...\n');
// Load environment
require('dotenv').config();
async function testStartup() {
let server = null;
try {
console.log('Step 1: Initializing database...');
const database = require('./database');
database.init();
console.log(' ✓ Database ready\n');
console.log('Step 2: Loading modules...');
const config = require('./config');
const apiClient = require('./api-client');
const printer = require('./printer');
console.log(' ✓ All modules loaded\n');
console.log('Step 3: Starting Fastify server...');
const Fastify = require('fastify');
const path = require('path');
const fastify = Fastify({ logger: false });
// Register plugins
await fastify.register(require('@fastify/view'), {
engine: { ejs: require('ejs') },
root: path.join(__dirname, 'views')
});
await fastify.register(require('@fastify/static'), {
root: path.join(__dirname, 'public'),
prefix: '/public/'
});
await fastify.register(require('@fastify/cookie'), {
secret: process.env.COOKIE_SECRET || 'test-secret'
});
await fastify.register(require('@fastify/formbody'));
await fastify.register(require('@fastify/multipart'), {
limits: { fileSize: 5 * 1024 * 1024 }
});
console.log(' ✓ Plugins registered\n');
console.log('Step 4: Registering routes...');
await fastify.register(require('./routes/auth'));
await fastify.register(require('./routes/dashboard'));
await fastify.register(require('./routes/settings'));
await fastify.register(require('./routes/orders'));
console.log(' ✓ All routes registered\n');
console.log('Step 5: Starting server on port 3000...');
await fastify.listen({ port: 3000, host: '127.0.0.1' });
console.log(' ✓ Server started successfully\n');
server = fastify;
console.log('Step 6: Testing routes...');
// Test root route
const response = await fastify.inject({
method: 'GET',
url: '/'
});
if (response.statusCode === 302) {
console.log(' ✓ Root route works (redirects)');
} else {
console.log(` ✗ Root route returned ${response.statusCode}`);
hasErrors = true;
}
// Test login page
const loginResponse = await fastify.inject({
method: 'GET',
url: '/login'
});
if (loginResponse.statusCode === 200) {
console.log(' ✓ Login page loads');
} else {
console.log(` ✗ Login page returned ${loginResponse.statusCode}`);
hasErrors = true;
}
console.log('\nStep 7: Shutting down server...');
await fastify.close();
console.log(' ✓ Server stopped gracefully\n');
console.log('='.repeat(60));
console.log('✅ STARTUP TEST PASSED');
console.log('='.repeat(60));
console.log('\nThe Kitchen Agent is ready to run!');
console.log('Start with: npm start\n');
database.close();
process.exit(0);
} catch (error) {
console.error('\n❌ STARTUP TEST FAILED');
console.error('Error:', error.message);
console.error('\nStack trace:');
console.error(error.stack);
if (server) {
try {
await server.close();
} catch (e) {
// ignore
}
}
process.exit(1);
}
}
testStartup();

184
test-utils.js Normal file
View File

@@ -0,0 +1,184 @@
/**
* Test utilities for Kitchen Agent
* Use these functions to manually test the system
*/
const database = require('./database');
// Initialize database first
database.init();
/**
* Insert a mock order for testing UI
*/
function insertMockOrder(orderId = null) {
const id = orderId || Math.floor(Math.random() * 10000);
const now = Math.floor(Date.now() / 1000);
const mockOrder = {
id: id,
botId: 1,
orderStatus: 'new',
order: {
type: Math.random() > 0.5 ? 'delivery' : 'pickup',
items: [
{
id: 1,
itemName: 'Cheeseburger',
qty: 2,
price: 12.99,
addons: [{ name: 'Extra Cheese', price: 1.00 }],
exclude: [{ name: 'Onions', price: 0 }]
},
{
id: 2,
itemName: 'French Fries',
qty: 1,
price: 4.99,
addons: [],
exclude: []
},
{
id: 3,
itemName: 'Coca-Cola',
qty: 2,
price: 2.50,
addons: [],
exclude: []
}
],
amount: 35.97,
taxRate: 8.5,
taxAmount: 3.06,
deliveryFee: 5.00,
totalAmount: 44.03,
deliveryAddress: '123 Main Street, Anytown, USA 12345',
deliveryInstructions: 'Ring doorbell, leave at door',
specialInstructions: 'Extra napkins please, no ketchup',
foodAllergy: Math.random() > 0.7,
foodAllergyNotes: 'Severe peanut allergy - please ensure no cross-contamination'
},
customer: {
id: 1,
name: 'John Doe',
phoneNumber: '+15551234567',
email: 'john.doe@example.com'
},
totalAmount: 44.03,
createdAt: now,
updatedAt: now
};
try {
database.insertOrder(mockOrder);
console.log(`✓ Mock order #${id} inserted successfully`);
return mockOrder;
} catch (error) {
console.error('Failed to insert mock order:', error.message);
return null;
}
}
/**
* Insert multiple mock orders
*/
function insertMultipleMockOrders(count = 5) {
console.log(`Inserting ${count} mock orders...`);
for (let i = 0; i < count; i++) {
const baseId = 10000 + i;
insertMockOrder(baseId);
}
console.log(`${count} mock orders inserted`);
}
/**
* Clear all orders from database (for testing)
*/
function clearAllOrders() {
try {
database.db.exec('DELETE FROM orders');
database.db.exec('DELETE FROM print_queue');
console.log('✓ All orders cleared');
} catch (error) {
console.error('Failed to clear orders:', error.message);
}
}
/**
* View current configuration
*/
function viewConfig() {
const config = database.getConfig();
console.log('\nCurrent Configuration:');
console.log('=====================');
for (const [key, value] of Object.entries(config)) {
if (key === 'authToken' && value) {
console.log(`${key}: [ENCRYPTED]`);
} else {
console.log(`${key}: ${value}`);
}
}
console.log('=====================\n');
}
/**
* View order statistics
*/
function viewStats() {
const stats = database.getOrderStats();
console.log('\nOrder Statistics:');
console.log('=================');
console.log(`Total Today: ${stats.total}`);
console.log(`New: ${stats.new}`);
console.log(`Preparing: ${stats.preparing}`);
console.log(`Ready: ${stats.ready}`);
console.log('=================\n');
}
/**
* List all orders
*/
function listOrders(limit = 10) {
const orders = database.getOrders({ limit });
console.log(`\nRecent Orders (${orders.length}):`);
console.log('===================');
orders.forEach(order => {
console.log(`#${order.id} | ${order.customer.name} | ${order.localStatus} | $${order.totalAmount.toFixed(2)}`);
});
console.log('===================\n');
}
// Export functions
module.exports = {
insertMockOrder,
insertMultipleMockOrders,
clearAllOrders,
viewConfig,
viewStats,
listOrders
};
// If run directly, show help
if (require.main === module) {
console.log('\nKitchen Agent Test Utilities');
console.log('============================\n');
console.log('Usage:');
console.log(' node test-utils.js\n');
console.log('Available in Node REPL:');
console.log(' const test = require("./test-utils");\n');
console.log(' test.insertMockOrder(); // Insert one mock order');
console.log(' test.insertMultipleMockOrders(5); // Insert 5 mock orders');
console.log(' test.listOrders(); // List recent orders');
console.log(' test.viewStats(); // View order statistics');
console.log(' test.viewConfig(); // View configuration');
console.log(' test.clearAllOrders(); // Clear all orders (careful!)\n');
// Show current stats
viewStats();
listOrders(5);
}

94
updater.js Normal file
View File

@@ -0,0 +1,94 @@
const path = require('path');
const os = require('os');
const fetch = require('node-fetch');
const {
GITEA_TOKEN,
REPO_URL = 'https://repo.cloud.thinklink.ai/thinklink/kitchen-agent.git',
REPO_BRANCH = 'main',
PM2_APP = 'kitchen-agent'
} = process.env;
const repositoryNormalized = REPO_URL.endsWith('.git') ? REPO_URL : `${REPO_URL}.git`;
const updaterOptions = {
repository: repositoryNormalized,
branch: REPO_BRANCH,
token: GITEA_TOKEN,
// place updates in OS temp dir to avoid permission/self-copy issues
tempLocation: path.join(os.tmpdir(), 'kitchen-agent-updates'),
// only include files that actually exist in the upstream repo; module uses unlinkSync
ignoreFiles: ['.env'],
executeOnComplete: process.platform === 'win32'
? `IF EXIST package-lock.json (npm ci --omit=dev) ELSE (npm install --omit=dev) & pm2 reload ${PM2_APP}`
: `[ -f package-lock.json ] && npm ci --omit=dev || npm install --omit=dev; pm2 reload ${PM2_APP}`,
exitOnComplete: false
};
let updaterInstance = null;
async function getUpdater() {
if (updaterInstance) { return updaterInstance; }
const mod = await import('auto-git-update');
const AutoGitUpdate = mod && mod.default ? mod.default : mod;
updaterInstance = new AutoGitUpdate(updaterOptions);
return updaterInstance;
}
async function checkAndUpdate() {
try {
const updater = await getUpdater();
const isGithub = /github\.com/i.test(repositoryNormalized);
let needsUpdate = false;
if (!isGithub) {
// Use Gitea-aware version check to avoid library's GitHub-only path
const localVersion = (() => {
try { return require('./package.json').version || null; } catch (_) { return null; }
})();
const base = repositoryNormalized.replace(/\.git$/i, '');
const rawUrl = `${base}/raw/branch/${REPO_BRANCH}/package.json`;
async function tryFetch(url) {
const headers = GITEA_TOKEN ? { Authorization: `token ${GITEA_TOKEN}` } : undefined;
const r = await fetch(url, { headers });
if (r.ok) return r.json();
return null;
}
let remotePkg = await tryFetch(rawUrl);
if (!remotePkg && GITEA_TOKEN) {
remotePkg = (await tryFetch(`${rawUrl}?token=${GITEA_TOKEN}`))
|| (await tryFetch(`${rawUrl}?access_token=${GITEA_TOKEN}`));
}
const remoteVersion = remotePkg && remotePkg.version ? String(remotePkg.version) : null;
if (!remoteVersion) {
console.error('[auto-update] Unable to read remote version from Gitea raw endpoint');
} else if (!localVersion || remoteVersion !== String(localVersion)) {
console.log(`[auto-update] Update available: ${localVersion || 'unknown'} -> ${remoteVersion}`);
needsUpdate = true;
}
if (needsUpdate) {
await updater.forceUpdate();
} else {
console.log('[auto-update] Up to date');
}
return;
}
// GitHub-based repos: use library's compareVersions, then forceUpdate to avoid double compare
const res = await updater.compareVersions();
if (res && res.upToDate === false) {
await updater.forceUpdate();
} else {
console.log('[auto-update] Up to date');
}
} catch (err) {
console.error('[auto-update] failed:', err);
}
}
module.exports = { checkAndUpdate };

145
views/dashboard.ejs Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kitchen Display - Kitchen Agent</title>
<link rel="stylesheet" href="/public/css/style.css">
</head>
<body class="dashboard-page">
<header class="main-header">
<div class="header-content">
<h1>Kitchen Display</h1>
<div class="connection-status-bar" id="connectionStatusBar">
<div class="connection-status-item" id="localConnectionStatus" title="Connection to local dashboard server">
<span class="status-indicator status-checking"></span>
<span class="status-text">Dashboard</span>
<span class="status-label" id="localStatusLabel">Checking...</span>
</div>
<div class="connection-status-item" id="apiConnectionStatus" title="Connection to api.thinklink.ai">
<span class="status-indicator status-checking"></span>
<span class="status-text">API Server</span>
<span class="status-label" id="apiStatusLabel">Checking...</span>
</div>
</div>
<div class="header-actions">
<a href="/settings" class="btn btn-secondary">Settings</a>
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
</div>
</div>
</header>
<% if (showStats) { %>
<div class="stats-bar" id="statsBar">
<div class="stat-card">
<div class="stat-label">Total Today</div>
<div class="stat-value" id="stat-total"><%= stats.total %></div>
</div>
<div class="stat-card stat-new">
<div class="stat-label">New</div>
<div class="stat-value" id="stat-new"><%= stats.new %></div>
</div>
<div class="stat-card stat-preparing">
<div class="stat-label">Preparing</div>
<div class="stat-value" id="stat-preparing"><%= stats.preparing %></div>
</div>
<div class="stat-card stat-ready">
<div class="stat-label">Ready</div>
<div class="stat-value" id="stat-ready"><%= stats.ready %></div>
</div>
</div>
<% } %>
<div class="dashboard-controls">
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">All Orders</button>
<button class="filter-btn" data-filter="new">New</button>
<button class="filter-btn" data-filter="preparing">Preparing</button>
<button class="filter-btn" data-filter="ready">Ready</button>
</div>
<button class="btn btn-secondary sync-button" id="syncButton" onclick="manualSync()" title="Check for new orders now">
<span class="sync-spinner"></span>
<span class="sync-text">Sync Now</span>
</button>
</div>
<div class="orders-container" id="ordersContainer">
<div class="loading">Loading orders...</div>
</div>
<!-- Status Change Confirmation Modal -->
<div class="modal" id="statusChangeModal">
<div class="modal-content status-modal-content">
<div class="status-modal-icon" id="statusModalIcon">
<!-- Icon will be set dynamically -->
</div>
<h2 id="statusModalTitle">Confirm Status Change</h2>
<div class="status-modal-details">
<div class="status-modal-row">
<span class="status-modal-label">Order #:</span>
<span class="status-modal-value" id="statusModalOrderId">-</span>
</div>
<div class="status-modal-row">
<span class="status-modal-label">Customer:</span>
<span class="status-modal-value" id="statusModalCustomer">-</span>
</div>
<div class="status-modal-row status-transition-row">
<div class="status-transition">
<span class="status-badge" id="statusModalFrom">-</span>
<span class="status-arrow">→</span>
<span class="status-badge" id="statusModalTo">-</span>
</div>
</div>
</div>
<p class="status-modal-message" id="statusModalMessage">
Are you sure you want to change this order's status?
</p>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeStatusChangeModal()">Cancel</button>
<button class="btn btn-success status-confirm-btn" id="statusConfirmBtn" onclick="confirmStatusChange()">Confirm</button>
</div>
</div>
</div>
<!-- Cancel Modal -->
<div class="modal" id="cancelModal">
<div class="modal-content">
<h2>Cancel Order</h2>
<p>Are you sure you want to cancel this order?</p>
<div class="form-group">
<label for="cancelReason">Reason (optional):</label>
<textarea id="cancelReason" rows="3" placeholder="Enter cancellation reason..."></textarea>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeCancelModal()">Cancel</button>
<button class="btn btn-danger" onclick="confirmCancel()">Confirm Cancel</button>
</div>
</div>
</div>
<!-- Order Details Modal -->
<div class="modal" id="detailsModal">
<div class="modal-content modal-large">
<div class="modal-header">
<h2>Order Details</h2>
<button class="close-btn" onclick="closeDetailsModal()">&times;</button>
</div>
<div id="orderDetailsContent">
<!-- Content will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Toast Notification -->
<div class="toast" id="toast"></div>
<script>
const config = {
dashboardRefreshInterval: <%= config.dashboardRefreshInterval || 10000 %>
};
</script>
<script src="/public/js/common.js"></script>
<script src="/public/js/dashboard.js"></script>
</body>
</html>

80
views/login.ejs Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Kitchen Agent</title>
<link rel="stylesheet" href="/public/css/style.css">
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body class="login-page">
<div class="login-container">
<div class="login-card">
<h1>Kitchen Agent</h1>
<h2>Login</h2>
<% if (error) { %>
<div class="alert alert-error">
<%= error %>
</div>
<% } %>
<form action="/auth/login" method="POST" id="loginForm">
<div class="form-group">
<label for="login">Email</label>
<input type="email" id="login" name="login" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-block" id="submitBtn">
Login
</button>
<!-- Invisible reCAPTCHA -->
<div class="g-recaptcha"
data-sitekey="<%= recaptchaSiteKey %>"
data-callback="onRecaptchaSuccess"
data-size="invisible">
</div>
</form>
<div class="login-footer">
<p>ThinkLink Kitchen Agent System</p>
</div>
</div>
</div>
<script>
const form = document.getElementById('loginForm');
const submitBtn = document.getElementById('submitBtn');
form.addEventListener('submit', function(e) {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = 'Logging in...';
// Execute reCAPTCHA
grecaptcha.execute();
});
function onRecaptchaSuccess(token) {
// reCAPTCHA token obtained, submit form
form.submit();
}
// Reset button if reCAPTCHA fails
window.addEventListener('load', function() {
if (window.grecaptcha) {
grecaptcha.ready(function() {
console.log('reCAPTCHA ready');
});
}
});
</script>
</body>
</html>

523
views/settings.ejs Normal file
View File

@@ -0,0 +1,523 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Kitchen Agent</title>
<link rel="stylesheet" href="/public/css/style.css">
</head>
<body class="settings-page">
<header class="main-header">
<div class="header-content">
<h1>Settings</h1>
<div class="header-actions">
<a href="/dashboard" class="btn btn-secondary">Back to Dashboard</a>
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
</div>
</div>
</header>
<div class="settings-container">
<% if (message) { %>
<div class="alert alert-success">
<%= message %>
</div>
<% } %>
<% if (error) { %>
<div class="alert alert-error">
<%= error %>
</div>
<% } %>
<div class="tabs">
<button class="tab-btn active" data-tab="general">General</button>
<button class="tab-btn" data-tab="printer">Printer</button>
<button class="tab-btn" data-tab="template">Receipt Template</button>
</div>
<form action="/settings/save" method="POST" id="settingsForm">
<!-- General Settings Tab -->
<div class="tab-content active" id="general-tab">
<h2>General Settings</h2>
<div class="form-group">
<label for="selectedBotId">Select Bot/Assistant</label>
<select id="selectedBotId" name="selectedBotId" required>
<option value="">-- Select Bot --</option>
<% bots.forEach(bot => { %>
<% const botConfig = JSON.parse(bot.bot_config || '{}'); %>
<option value="<%= bot.id %>" <%= config.selectedBotId == bot.id ? 'selected' : '' %>>
<%= botConfig.name || 'Bot #' + bot.id %>
</option>
<% }); %>
</select>
<small>Select which bot's orders to display</small>
</div>
<div class="form-group">
<label for="pollingInterval">Order Polling Interval (milliseconds)</label>
<input type="number" id="pollingInterval" name="pollingInterval"
value="<%= config.pollingInterval || 15000 %>" min="5000" max="60000" step="1000">
<small>How often to check for new orders (default: 15000 = 15 seconds)</small>
</div>
<div class="form-group">
<label for="dashboardRefreshInterval">Dashboard Refresh Interval (milliseconds)</label>
<input type="number" id="dashboardRefreshInterval" name="dashboardRefreshInterval"
value="<%= config.dashboardRefreshInterval || 10000 %>" min="5000" max="60000" step="1000">
<small>How often to refresh the dashboard display (default: 10000 = 10 seconds)</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="showOrderStats" <%= config.showOrderStats !== 'false' ? 'checked' : '' %>>
Show Order Statistics Bar
</label>
</div>
<h3 style="margin-top: 30px; margin-bottom: 15px; border-top: 2px solid #e0e0e0; padding-top: 20px;">Sound Notifications</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="soundNotificationsEnabled" name="soundNotificationsEnabled" <%= config.soundNotificationsEnabled !== 'false' ? 'checked' : '' %>>
Enable Sound Notifications
</label>
<small>Play sounds when new orders arrive or orders are canceled</small>
</div>
<div class="form-group">
<label for="soundVolume">Notification Volume</label>
<input type="range" id="soundVolume" name="soundVolume" min="0" max="100" value="<%= config.soundVolume || 80 %>" style="width: 100%;">
<small>Volume: <span id="volumeValue"><%= config.soundVolume || 80 %></span>%</small>
</div>
<div class="settings-section" style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-top: 20px;">
<h4 style="margin-top: 0;">New Order Sound</h4>
<div class="form-group">
<label>Current Sound File:</label>
<p style="font-size: 14px; color: #666;">
<%= config.newOrderSoundPath || '/public/sounds/new-order-notification.mp3' %>
</p>
</div>
<div class="form-group">
<label>Upload Custom Sound (optional)</label>
<input type="file" id="newOrderSoundUpload" accept="audio/*">
<button type="button" class="btn btn-secondary" onclick="uploadSound('newOrder')">Upload New Order Sound</button>
<button type="button" class="btn btn-secondary" onclick="testSound('newOrder')">🔊 Test Sound</button>
<small>Supported formats: MP3, WAV, OGG</small>
</div>
</div>
<div class="settings-section" style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-top: 20px;">
<h4 style="margin-top: 0;">Canceled Order Sound</h4>
<div class="form-group">
<label>Current Sound File:</label>
<p style="font-size: 14px; color: #666;">
<%= config.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3' %>
</p>
</div>
<div class="form-group">
<label>Upload Custom Sound (optional)</label>
<input type="file" id="canceledOrderSoundUpload" accept="audio/*">
<button type="button" class="btn btn-secondary" onclick="uploadSound('canceled')">Upload Canceled Sound</button>
<button type="button" class="btn btn-secondary" onclick="testSound('canceled')">🔊 Test Sound</button>
<small>Supported formats: MP3, WAV, OGG</small>
</div>
</div>
</div>
<!-- Printer Management Tab -->
<div class="tab-content" id="printer-tab">
<h2>Printer Management</h2>
<p class="section-description">Configure individual printers with specific paper sizes, templates, and settings.</p>
<div class="settings-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0;">Configured Printers</h3>
<button type="button" class="btn btn-primary" onclick="openAddPrinterModal()">+ Add Printer</button>
</div>
<div id="printer-cards-container" class="printer-cards-container">
<!-- Printer cards will be loaded here dynamically -->
<div class="loading">Loading printers...</div>
</div>
</div>
</div>
<!-- Receipt Template Tab -->
<div class="tab-content" id="template-tab">
<h2>Receipt Template</h2>
<div class="form-group">
<label for="headerText">Header Text</label>
<textarea id="headerText" name="headerText" rows="2" placeholder="KITCHEN ORDER"><%= typeof config.headerText !== 'undefined' ? config.headerText : '' %></textarea>
</div>
<div class="form-group">
<label for="footerText">Footer Text</label>
<textarea id="footerText" name="footerText" rows="2" placeholder="Thank you!"><%= typeof config.footerText !== 'undefined' ? config.footerText : '' %></textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="qrCodeEnabled" <%= config.qrCodeEnabled !== 'false' ? 'checked' : '' %>>
Print QR Code on Receipt
</label>
</div>
<div class="form-group">
<label for="qrCodeSize">QR Code Size</label>
<input type="number" id="qrCodeSize" name="qrCodeSize" min="2" max="8" value="<%= config.qrCodeSize || 3 %>">
<small>Cell size (28). Larger = bigger QR.</small>
</div>
<div class="form-group">
<label for="qrCodeCorrection">QR Error Correction</label>
<select id="qrCodeCorrection" name="qrCodeCorrection">
<option value="L" <%= (config.qrCodeCorrection || 'M') === 'L' ? 'selected' : '' %>>L (Low)</option>
<option value="M" <%= (config.qrCodeCorrection || 'M') === 'M' ? 'selected' : '' %>>M (Medium)</option>
<option value="Q" <%= (config.qrCodeCorrection || 'M') === 'Q' ? 'selected' : '' %>>Q (Quartile)</option>
<option value="H" <%= (config.qrCodeCorrection || 'M') === 'H' ? 'selected' : '' %>>H (High)</option>
</select>
</div>
<div class="form-group">
<label for="qrCodeContentTemplate">QR Content Template</label>
<input type="text" id="qrCodeContentTemplate" name="qrCodeContentTemplate" value="<%= config.qrCodeContentTemplate || 'ORDER-{id}' %>">
<small>Use placeholders: {id}, {total}, {type}, {createdAt}</small>
</div>
<h3>Show/Hide Sections</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="showCustomerInfo" <%= config.showCustomerInfo !== 'false' ? 'checked' : '' %>>
Show Customer Information
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="showOrderItems" <%= config.showOrderItems !== 'false' ? 'checked' : '' %>>
Show Order Items
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="showPrices" <%= config.showPrices !== 'false' ? 'checked' : '' %>>
Show Prices
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="showTimestamps" <%= config.showTimestamps !== 'false' ? 'checked' : '' %>>
Show Timestamps
</label>
</div>
<div class="form-group">
<label>Logo Image (optional)</label>
<input type="file" id="logoUpload" accept="image/*">
<button type="button" class="btn btn-secondary" onclick="uploadLogo()">Upload Logo</button>
<small>Upload a logo image to print at the top of receipts</small>
<% if (config.logoPath) { %>
<div class="current-logo">
<p>Current logo: <%= config.logoPath %></p>
</div>
<% } %>
</div>
<h3>Business Information</h3>
<div class="form-group">
<label for="businessName">Business Name</label>
<input type="text" id="businessName" name="businessName" value="<%= config.businessName || '' %>">
</div>
<div class="form-group">
<label for="businessAddress">Address</label>
<input type="text" id="businessAddress" name="businessAddress" value="<%= config.businessAddress || '' %>">
</div>
<div class="form-group">
<label for="businessPhone">Phone</label>
<input type="text" id="businessPhone" name="businessPhone" value="<%= config.businessPhone || '' %>">
</div>
<div class="form-group">
<label for="businessWebsite">Website</label>
<input type="text" id="businessWebsite" name="businessWebsite" value="<%= config.businessWebsite || '' %>">
</div>
<div class="form-group">
<label for="businessEmail">Email</label>
<input type="text" id="businessEmail" name="businessEmail" value="<%= config.businessEmail || '' %>">
</div>
<div class="form-group">
<label for="businessContactSize">Contact Font Size</label>
<select id="businessContactSize" name="businessContactSize">
<option value="normal" <%= (config.businessContactSize || 'normal') === 'normal' ? 'selected' : '' %>>Normal</option>
<option value="large" <%= (config.businessContactSize || 'normal') === 'large' ? 'selected' : '' %>>Large</option>
</select>
<small>Controls the size of address/phone/website/email in header.</small>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-large">Save All Settings</button>
</div>
</form>
</div>
<!-- Printer Configuration Modal -->
<div class="modal" id="printerConfigModal">
<div class="modal-content modal-large">
<div class="modal-header">
<h2 id="printerModalTitle">Add Printer</h2>
<button class="close-btn" onclick="closePrinterModal()">&times;</button>
</div>
<div class="printer-modal-body">
<div class="printer-modal-tabs">
<button class="printer-modal-tab-btn active" data-tab="connection">Connection</button>
<button class="printer-modal-tab-btn" data-tab="paper">Paper & Format</button>
<button class="printer-modal-tab-btn" data-tab="template">Template</button>
<button class="printer-modal-tab-btn" data-tab="business">Business Info</button>
<button class="printer-modal-tab-btn" data-tab="logo">Logo & Branding</button>
<button class="printer-modal-tab-btn" data-tab="qrcode">QR Code</button>
</div>
<form id="printerConfigForm" onsubmit="return false;">
<input type="hidden" id="printer_id" name="id">
<!-- Connection Tab -->
<div class="printer-modal-tab-content active" id="connection-tab-content">
<div class="form-group">
<label for="printer_name">Printer Name *</label>
<input type="text" id="printer_name" name="name" required placeholder="e.g., Kitchen Printer - 80mm">
<small>Give this printer a descriptive name</small>
</div>
<div class="form-group">
<label for="printer_type_select">Connection Type *</label>
<select id="printer_type_select" name="type" required>
<option value="network">Network (TCP/IP)</option>
<option value="com">Serial Port (COM)</option>
<option value="usb">USB</option>
<option value="system">Windows System Printer</option>
</select>
</div>
<div class="form-group">
<label for="printer_interface">Connection Address *</label>
<input type="text" id="printer_interface" name="interface" required placeholder="e.g., 192.168.1.100:9100, COM1, /dev/usb/lp0">
<small id="interface_hint">Enter IP:Port for network printers</small>
</div>
<div class="form-group">
<button type="button" class="btn btn-secondary" onclick="detectPrintersForModal()">Detect Available Printers</button>
<div id="detected-printers-list" style="margin-top: 10px;"></div>
</div>
</div>
<!-- Paper & Format Tab -->
<div class="printer-modal-tab-content" id="paper-tab-content">
<div class="form-group">
<label for="paper_format">Paper Format *</label>
<select id="paper_format" name="paper_format" required>
<option value="58mm">58mm Thermal</option>
<option value="80mm" selected>80mm Thermal</option>
<option value="letter">Letter Size (8.5 x 11)</option>
</select>
</div>
<div class="form-group">
<label for="paper_width">Character Width *</label>
<input type="number" id="paper_width" name="paper_width" value="48" min="20" max="100" required>
<small>58mm: 32 chars | 80mm: 48 chars | Letter: 80+ chars</small>
</div>
<div class="form-group">
<label for="printer_type_model">Printer Model/Brand *</label>
<select id="printer_type_model" name="printer_type" required>
<option value="epson" selected>Epson</option>
<option value="star">Star</option>
<option value="tanca">Tanca</option>
<option value="brother">Brother</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="form-group">
<label for="font_size">Font Size</label>
<select id="font_size" name="font_size">
<option value="small">Small</option>
<option value="normal" selected>Normal</option>
<option value="large">Large</option>
</select>
</div>
<div class="form-group">
<label for="line_style">Line Style</label>
<select id="line_style" name="line_style">
<option value="single" selected>Single (─)</option>
<option value="double">Double (═)</option>
<option value="dashed">Dashed (- -)</option>
</select>
</div>
</div>
<!-- Template Tab -->
<div class="printer-modal-tab-content" id="template-tab-content">
<div class="form-group">
<label for="header_text">Header Text</label>
<textarea id="header_text" name="header_text" rows="2" placeholder="KITCHEN ORDER"></textarea>
</div>
<div class="form-group">
<label for="footer_text">Footer Text</label>
<textarea id="footer_text" name="footer_text" rows="2" placeholder="Thank you!"></textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="show_customer_info" name="show_customer_info" checked>
Show Customer Information
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="show_order_items" name="show_order_items" checked>
Show Order Items
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="show_prices" name="show_prices" checked>
Show Prices
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="show_timestamps" name="show_timestamps" checked>
Show Timestamps
</label>
</div>
</div>
<!-- Business Info Tab -->
<div class="printer-modal-tab-content" id="business-tab-content">
<div class="form-group">
<label for="business_name">Business Name</label>
<input type="text" id="business_name" name="business_name" placeholder="Your Business Name">
</div>
<div class="form-group">
<label for="business_address">Address</label>
<input type="text" id="business_address" name="business_address" placeholder="123 Main St, City, State ZIP">
</div>
<div class="form-group">
<label for="business_phone">Phone</label>
<input type="text" id="business_phone" name="business_phone" placeholder="(555) 123-4567">
</div>
<div class="form-group">
<label for="business_website">Website</label>
<input type="text" id="business_website" name="business_website" placeholder="www.example.com">
</div>
<div class="form-group">
<label for="business_email">Email</label>
<input type="email" id="business_email" name="business_email" placeholder="contact@example.com">
</div>
<div class="form-group">
<label for="business_contact_size">Contact Info Size</label>
<select id="business_contact_size" name="business_contact_size">
<option value="normal" selected>Normal</option>
<option value="large">Large</option>
</select>
</div>
</div>
<!-- Logo & Branding Tab -->
<div class="printer-modal-tab-content" id="logo-tab-content">
<div class="form-group">
<label>Logo Image</label>
<input type="file" id="logo_upload_modal" accept="image/*">
<button type="button" class="btn btn-secondary" onclick="uploadLogoForPrinter()" style="margin-top: 10px;">Upload Logo</button>
<small>Upload a logo for this specific printer</small>
<div id="logo_preview" style="margin-top: 10px;"></div>
<input type="hidden" id="logo_path" name="logo_path">
</div>
<div class="form-group">
<label for="logo_max_width_dots">Logo Max Width (dots)</label>
<input type="number" id="logo_max_width_dots" name="logo_max_width_dots" min="100" max="800" placeholder="Auto-detect based on paper width">
<small>58mm: ~384 dots | 80mm: ~576 dots | Leave empty for auto</small>
</div>
</div>
<!-- QR Code Tab -->
<div class="printer-modal-tab-content" id="qrcode-tab-content">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="qr_code_enabled" name="qr_code_enabled" checked>
Print QR Code on Receipt
</label>
</div>
<div class="form-group">
<label for="qr_code_size">QR Code Size</label>
<input type="number" id="qr_code_size" name="qr_code_size" value="3" min="2" max="8">
<small>Cell size (2-8). Larger = bigger QR code</small>
</div>
<div class="form-group">
<label for="qr_code_correction">Error Correction Level</label>
<select id="qr_code_correction" name="qr_code_correction">
<option value="L">L (Low - 7% recovery)</option>
<option value="M" selected>M (Medium - 15% recovery)</option>
<option value="Q">Q (Quartile - 25% recovery)</option>
<option value="H">H (High - 30% recovery)</option>
</select>
</div>
<div class="form-group">
<label for="qr_code_content_template">QR Content Template</label>
<input type="text" id="qr_code_content_template" name="qr_code_content_template" value="ORDER-{id}" placeholder="ORDER-{id}">
<small>Use: {id}, {total}, {type}, {createdAt}</small>
</div>
</div>
<div class="modal-actions" style="margin-top: 30px; padding-top: 20px; border-top: 2px solid #e0e0e0;">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="is_default" name="is_default">
Set as Default Printer
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="is_enabled" name="is_enabled" checked>
Enabled (Print to this printer)
</label>
</div>
<button type="button" class="btn btn-secondary" onclick="closePrinterModal()">Cancel</button>
<button type="submit" class="btn btn-primary" onclick="savePrinterConfig()">Save Printer</button>
</div>
</form>
</div>
</div>
</div>
<script src="/public/js/common.js"></script>
<script src="/public/js/settings.js"></script>
</body>
</html>