Compare commits

20 Commits
Author SHA1 Message Date
Thanakorn 9512d440dd Merge fix/switch-branch 2026-09-14 17:19:02 +07:00
Thanakorn 45331948c1 Use absolute URLs in invitation emails 2026-09-14 17:18:42 +07:00
Thanakorn ba73456185 Send the chosen company when switching branch 2026-09-14 17:17:40 +07:00
Thanakorn 501c70050f Merge fix/untrack-node-logs 2026-09-14 17:06:16 +07:00
Thanakorn f6909b08eb Stop tracking PM2 log files 2026-09-14 17:05:57 +07:00
Thanakorn 5846c8b282 Merge fix/app-registry-default 2026-09-14 16:58:00 +07:00
Thanakorn 6a271c1f7d Default the app registry when config.php does not define it 2026-09-14 16:57:36 +07:00
Thanakorn 2ef2f32107 Merge fix/retrieve-bin-endpoint 2026-09-14 16:29:56 +07:00
Thanakorn 1f72633cb6 Install libpng, libjpeg and freetype for the gd extension 2026-09-14 16:29:38 +07:00
Thanakorn 4efab9f7c9 Rename retrieve_rack.php to retrieve_bin.php 2026-09-14 16:27:53 +07:00
Thanakorn 44c66c49a5 Merge feature/otp-off-by-default 2026-09-14 15:38:53 +07:00
Thanakorn 6b3a590aa9 Make email OTP login off by default 2026-09-14 15:38:36 +07:00
Thanakorn 21148bf50c Merge feature/onboarding-optional-smtp 2026-09-14 15:27:46 +07:00
Thanakorn cf8106771b Make onboarding SMTP optional when OTP is off 2026-09-14 15:27:01 +07:00
Thanakorn d7203583b7 Merge feature/otp-login-toggle 2026-09-14 15:11:45 +07:00
Thanakorn 9afcf072b0 Add OTP_REQUIRED switch for email OTP login 2026-09-14 15:03:16 +07:00
Thanakorn 2f290ddb26 Merge fix/api-json-notices 2026-09-14 13:33:59 +07:00
Thanakorn 5d021d7683 Accept uppercase channel names in onboarding and company settings 2026-09-14 13:31:26 +07:00
Thanakorn 5ee0c8d41b Keep PHP notices out of login API JSON responses 2026-09-14 12:46:13 +07:00
Thanakorn c3113bc70d Fix login redirect and hide PHP errors on pages 2026-09-14 12:46:13 +07:00
29 changed files with 346 additions and 422 deletions
+6
View File
@@ -13,5 +13,11 @@ EMIT_SECRET=
SMTP_USERNAME=
SMTP_PASSWORD=
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
# and that needs working SMTP. While off, sign-in is password only (logged as
# OTP_BYPASSED, shown on the login page and top bar).
# Applied to app/config.php by the php container on every start.
OTP_REQUIRED=false
# Port to expose the web app on (default 80)
HTTP_PORT=80
+3
View File
@@ -6,6 +6,9 @@ app/uploads
node_modules/
nodejs/.env
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
nodejs/logs/*.log
# Docker deploy secrets
/.env
+22
View File
@@ -0,0 +1,22 @@
<?php
// app/assets/utils/app_registry.php
//
// The apps a user can be given access to (user.app_access and
// company_map_user.app_access), with the label, icon and badge colour the
// Users Access page shows for each.
//
// config.php may define its own $app_registry; this file only fills it in when
// it is missing or empty — which is every Docker-generated config.php written
// before the setting was documented. Without it the Add User dialog breaks
// (Object.entries(null) in setting/users.php) and inviting a user fails
// (array_keys(null) in setting/api/engine/manage_users.php).
//
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
// ('all' is implied and never listed here).
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
}
@@ -99,7 +99,7 @@ class CompanyProfileManager
public function saveProfile(array $data, string $company_logo, string $company_seal): void
{
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
$sth = $this->pdo->prepare(
"UPDATE company_list SET
+36
View File
@@ -0,0 +1,36 @@
<?php
// app/assets/utils/otp_policy.php
//
// Email OTP login policy, set by OTP_REQUIRED in config.php.
//
// OFF BY DEFAULT: the OTP step runs only when the constant is defined and is
// exactly the boolean true. A missing constant (any config.php written before
// this switch existed), 1, 'true' or a typo all leave it off, so sign-in is
// password only and no SMTP is needed to log in.
//
// While it is off, every sign-in that skips the OTP because of it is logged as
// OTP_BYPASSED, and the login page and top bar both say so on screen — a
// password-only sign-in must never be invisible to whoever is using it.
//
// Only the login OTP is affected. When it is on, the staff/viewer and no-SMTP
// skips in login_otp.php still apply; password-reset OTPs (PasswordResetManager)
// are a separate flow that stays on regardless.
if (!function_exists('otp_required')) {
function otp_required(): bool {
return defined('OTP_REQUIRED') && OTP_REQUIRED === true;
}
}
if (!function_exists('otp_log_bypass')) {
// There is no auth log table in this app, so bypasses go to the PHP error
// log (the container's Apache log) under a fixed, greppable tag.
function otp_log_bypass($user_id, string $where): void {
error_log(sprintf(
'[auth] OTP_BYPASSED user_id=%d ip=%s where=%s -- OTP_REQUIRED is not true in config.php',
(int)$user_id,
$_SERVER['REMOTE_ADDR'] ?? '-',
$where
));
}
}
+18
View File
@@ -38,6 +38,24 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Email OTP on sign-in. OFF BY DEFAULT: only the boolean true turns it on —
// anything else, the constant being absent included, leaves sign-in password
// only (logged as OTP_BYPASSED, shown on the login page and top bar). Turn it
// on only with working SMTP. Password-reset OTPs are not affected.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', false);
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to, as shown on Setting → Users Access. Keys
// must match the user.app_access enum ('wms', 'accounting'). If this is left
// out, assets/utils/app_registry.php supplies the same default.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
// enforce daily/weekly action limits and which features lock once exceeded.
+5
View File
@@ -27,6 +27,11 @@ class db_statement extends PDOStatement {
$this->pdo = $pdo;
}
// PDOStatement::execute() is declared ?array $params = null : bool. This
// override deliberately accepts a looser signature so callers may pass
// positional arguments (see func_get_args() below), so the tightened return
// type is opted out of rather than the call sites being changed.
#[\ReturnTypeWillChange]
public function execute($args = null) {
// Perform logging here. PDO object is accessible
// from $this->pdo.
+19
View File
@@ -1,4 +1,23 @@
<?php
// Output buffering must be active before the first byte of HTML below, so that
// header() calls made later in the page still work — notably the
// not-logged-in redirect in include_topbar.php, which runs *after* this file
// has already emitted <!DOCTYPE html>. Without a buffer that redirect depends
// entirely on php.ini's output_buffering: it is on for the dev stack but off
// in production, where every protected page answered 200 with a half-rendered
// body instead of sending the browser to the login form. session.php starts a
// buffer for the same reason.
if (ob_get_level() === 0) {
ob_start();
}
// Never render PHP notices/warnings into the page: they leak absolute server
// paths to anonymous visitors and corrupt the markup. Errors still reach the
// server log. This mirrors the policy db_auth.php already applies to the JSON
// API routes, and keeps the app safe even where php.ini has display_errors on.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Security headers — emitted before any HTML output.
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
+21 -1
View File
@@ -3,11 +3,17 @@
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/dbconn.php';
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/assets/utils/otp_policy.php';
// Redirect to login if the user has not completed full authentication.
// login_company_id is only written by login_confirm.php after OTP is verified —
// using it (not "otp") ensures half-logged-in sessions are also redirected.
if(empty($_SESSION["login_company_id"])){
// Discard the markup include_header.php has already buffered so the browser
// receives a clean redirect rather than a partially rendered page body.
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Location: '.$server_url.'login/index.php');
exit;
}
@@ -193,6 +199,18 @@ $_usage_full = $_usage_max_pct >= 100;
</li>
<?php endif; ?>
<!-- Email OTP off (the default): a password-only sign-in must never be invisible to whoever is using it -->
<?php if (!otp_required()): ?>
<li class="d-none d-md-block">
<span class="badge bg-warning text-dark d-flex align-items-center gap-1 px-2 py-1"
style="font-size:11px; cursor:default;"
title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-shield-off"></i>
OTP off
</span>
</li>
<?php endif; ?>
<!-- Usage limit warning -->
<?php if ($_usage_full || $_usage_warn): ?>
<li>
@@ -402,7 +420,9 @@ function do_switch_branch(company_id) {
autoPrepare: true,
checkRequired: 0,
action: 'update',
company_id: company_id,
// Sent under its own key: prepare_form_data() always fills company_id with
// the CURRENT company, and only options.data reaches the payload.
data: { target_company_id: company_id },
onSuccess: function(res) {
window.location.reload();
}
+9 -2
View File
@@ -58,6 +58,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Load session state written by login_otp.php ───────────────────────
$data["username"] = $_SESSION["login_data"]['username'];
@@ -100,9 +101,15 @@ $_SESSION["diff"] = $otp_diff_minutes;
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
// so they never receive or enter an OTP. Admin/owner always go through this check.
// so they never receive or enter an OTP. Admin/owner always go through this check,
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
// on the OTP screen when the switch was turned off.
if (empty($_SESSION['skip_otp'])) {
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
if (!otp_required()) {
if (!empty($user_id)) {
otp_log_bypass($user_id, 'login_confirm');
}
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
+9 -4
View File
@@ -62,6 +62,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
@@ -253,11 +254,15 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
exit(json_encode($answer));
}
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
// Owners always require 2FA. Invited users (license='user') require 2FA only
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
// logs the sign-in as a bypass (see assets/utils/otp_policy.php).
// Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
// if their role in this company is admin or owner; staff/viewer go straight in.
$requires_otp = true;
if (($r['license'] ?? 'owner') !== 'owner') {
$requires_otp = otp_required();
if (!$requires_otp) {
otp_log_bypass($user_id, 'login_otp');
} elseif (($r['license'] ?? 'owner') !== 'owner') {
$sth_role = $pdo1->prepare(
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
);
+79 -64
View File
@@ -19,8 +19,10 @@
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
* 3. Decode and sanitise input fields.
* 4. Required field validation — company_name and channel_name must be non-empty.
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
* must all be provided (company SMTP is mandatory for WMS email delivery).
* 5. SMTP validation — smtp_host, smtp_username, smtp_password must all be
* provided while email OTP is on (company SMTP delivers the OTP). With
* OTP_REQUIRED=false they are optional but all-or-nothing: left blank,
* steps 6-8 and 13 are skipped and the company is created without SMTP.
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
@@ -52,6 +54,7 @@ require_once '../../../session.php';
require_once '../../../config.php';
require_once '../../../dbconn.php';
require_once '../../../assets/utils/db_helpers.php';
require_once '../../../assets/utils/otp_policy.php';
header('Content-Type: application/json; charset=utf-8');
@@ -97,9 +100,9 @@ try {
$company_name = trim($data['company_name'] ?? '');
$company_name2 = trim($data['company_name2'] ?? '');
// channel_name is the URL slug / identifier — strip everything except
// lowercase letters, digits, hyphens, and underscores.
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
// channel_name is the URL slug / identifier — lowercase first, then strip
// everything except lowercase letters, digits, hyphens, and underscores.
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
$branch_no = trim($data['branch_no'] ?? '00000');
@@ -114,59 +117,67 @@ try {
}
// ── Step 5: SMTP field validation ────────────────────────────────────────
// SMTP is mandatory because the company needs to send OTP emails to users.
// An account without working SMTP would be unable to complete 2FA login.
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
// emails, and an account without working SMTP could not complete 2FA login.
// With OTP_REQUIRED=false in config.php it is optional — all three fields
// left blank means "no SMTP", and the test send (step 8) and the company_smtp
// row (step 13) are skipped. Partly filled is an error either way.
$smtp_host = trim($data['smtp_host'] ?? '');
$smtp_username = trim($data['smtp_username'] ?? '');
$smtp_password = $data['smtp_password'] ?? '';
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
if (!$smtp_host || !$smtp_username || !$smtp_password) {
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
$answer['message'] = otp_required()
? 'SMTP configuration is required. Please fill in all SMTP fields.'
: 'Fill in SMTP host, username and password, or leave all three blank.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if ($smtp_given) {
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
}
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
@@ -226,25 +237,29 @@ try {
// ── Step 13: Save company SMTP settings ──────────────────────────────────
// Stored with the encrypted password so the mailer module can decrypt and
// use it for all outgoing email from this company (OTP, notifications, etc.).
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// Skipped when no SMTP was given (only allowed with OTP_REQUIRED=false); it
// can be added later under Settings → SMTP.
if ($smtp_given) {
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
}
// ── Step 14: Clear onboarding session keys ───────────────────────────────
// These keys are no longer needed and should not persist into the
+18 -2
View File
@@ -1,6 +1,7 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
require '../include_header.php';
// successful login — redirect based on app_access
if(!empty($_SESSION["login_status"])){
@@ -32,6 +33,13 @@
</div>
<form class="needs-validation mt-3" novalidate id="login-form">
<?php if (!otp_required()): ?>
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-alert-triangle me-1"></i>
Email OTP is off — sign-in is password only.
</div>
<?php endif; ?>
<!-- first step login [OTP] -->
<?php if(!isset($_SESSION['login_data'])){?>
<div class="mb-3">
@@ -51,7 +59,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<!-- "Remember me" is intentionally excluded.
This login uses 2FA (OTP via email) on every session.
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
A persistent login would bypass the OTP step and undermine the security model.
Do not add this back. -->
</div>
@@ -62,6 +70,7 @@
</p>
<?php }else{ ?>
<!-- second step login -->
<?php if (otp_required()): ?>
<div class="alert alert-warning small py-2 mb-3">
<i class="ti ti-mail me-1"></i>
OTP is sent via your company's SMTP setting.
@@ -73,13 +82,20 @@
<span>One Time Password</span>
</label>
<input id="otp" type="otp" class="form-control"
placeholder="your otp for reference number <?php echo $_SESSION["reference"]?>" required minlength="6">
placeholder="your otp for reference number <?php echo $_SESSION["reference"] ?? ''?>" required minlength="6">
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
</div>
<?php else: ?>
<!-- OTP_REQUIRED was switched off while this session sat on the OTP step:
login_confirm.php no longer checks the code, so there is nothing to type. -->
<input id="otp" type="hidden" value="">
<?php endif; ?>
<div class="mb-3">
<label for="password" class="form-label d-flex justify-content-between">
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
<?php if (otp_required()): ?>
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
<?php endif; ?>
</label>
</div>
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
+29 -8
View File
@@ -1,6 +1,7 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
// Must come from email verification
if (empty($_SESSION['onboarding_user_id'])) {
@@ -48,7 +49,8 @@
</div>
<div class="col-md-6">
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3">
@@ -77,11 +79,20 @@
<div class="d-flex justify-content-between align-items-start mb-1">
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
<?php if (otp_required()): ?>
<span class="badge bg-label-danger">Required</span>
<?php else: ?>
<span class="badge bg-label-secondary">Optional</span>
<?php endif; ?>
</div>
<p class="text-muted small mb-3">
<?php if (otp_required()): ?>
SMTP is required to send OTP during login.
A verification email will be sent when you finish setup.
<?php else: ?>
Email OTP is turned off, so SMTP is optional. Leave it blank to skip;
you can add it later under Settings → SMTP.
<?php endif; ?>
</p>
<!-- SMTP User Guide (collapsible) -->
@@ -174,11 +185,11 @@
<!-- SMTP Form -->
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
</div>
<div class="col-md-4">
<label class="form-label">Port <span class="text-danger">*</span></label>
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<select class="form-select" id="smtp_port">
<option value="587">587 — TLS</option>
<option value="465">465 — SSL</option>
@@ -186,11 +197,11 @@
</select>
</div>
<div class="col-md-6">
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
</div>
<div class="col-md-6">
<label class="form-label">Password <span class="text-danger">*</span></label>
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<div class="input-group">
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
@@ -251,13 +262,23 @@
return;
}
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
bootbox.alert('SMTP host, username and password are required.');
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
// with OTP_REQUIRED=false it is optional, but the three fields go together.
const smtp_required = <?php echo otp_required() ? 'true' : 'false'; ?>;
const smtp_host = $('#smtp_host').val().trim();
const smtp_user = $('#smtp_username').val().trim();
const smtp_pass = $('#smtp_password').val();
const smtp_given = !!(smtp_host || smtp_user || smtp_pass);
if ((smtp_required || smtp_given) && (!smtp_host || !smtp_user || !smtp_pass)) {
bootbox.alert(smtp_required
? 'SMTP host, username and password are required.'
: 'Fill in SMTP host, username and password, or leave all three blank.');
return;
}
const $btn = $('#btn_finish');
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
const encryption = $('input[name="smtp_encryption"]:checked').val();
+6
View File
@@ -2,6 +2,12 @@
// app/session.php
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
// The buffer is still flushed, so notices would still land in front of the JSON
// body and break the client's parse ("Server error occurred."). The login API
// engines load this file instead of db_auth.php, so apply the same policy here.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
if (session_status() === PHP_SESSION_NONE) {
// Derive cookie path dynamically from the current script location.
+11 -2
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/app_registry.php';
require_once '../../../assets/utils/classes/UserManager.php';
if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
@@ -30,7 +31,11 @@
$result = $um->inviteUser($email, $role, $app_access);
// Both new and existing users require explicit acceptance via email
$invite_url = rtrim($server_url, '/') . ($result['new_user']
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
@@ -88,7 +93,11 @@
$map_id = (int)($data['map_id'] ?? 0);
$result = $um->resendInvite($map_id);
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['is_new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
+6 -2
View File
@@ -3,7 +3,11 @@
* switch_branch.php — Switch the active company for the current session.
*
* action: 'read' → return list of companies the user belongs to
* action: 'update' → switch to the requested company_id
* action: 'update' → switch to target_company_id
*
* The target is read from target_company_id, not company_id: every request
* carries company_id = the CURRENT company (prepare_form_data in custom.js),
* so reading it made a switch silently re-select the company already active.
*/
session_start();
require_once '../../../assets/utils/db_auth.php';
@@ -20,7 +24,7 @@ if ($action === 'read') {
}
if ($action === 'update') {
$target_company_id = (int)($data['company_id'] ?? 0);
$target_company_id = (int)($data['target_company_id'] ?? 0);
if (!$target_company_id) {
$answer['message'] = 'Invalid company.';
+2 -1
View File
@@ -121,7 +121,8 @@
<div class="col-md-6 mb-3">
<label class="form-label">Nickname / Channel Name</label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3 mb-3">
+1
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require '../config.php';
require_once '../assets/utils/app_registry.php';
require '../include_header.php';
?>
+1
View File
@@ -22,6 +22,7 @@ services:
EMIT_SECRET: ${EMIT_SECRET}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
OTP_REQUIRED: ${OTP_REQUIRED:-false}
volumes:
- .:/var/www/html/wms-app
ports:
+1
View File
@@ -55,6 +55,7 @@ PUBLIC_HOST=$public_host
EMIT_SECRET=$emit_secret
SMTP_USERNAME=$smtp_user
SMTP_PASSWORD=$smtp_pass
OTP_REQUIRED=false
HTTP_PORT=$http_port
EOF
chmod 600 "$ENV_FILE"
+2
View File
@@ -2,6 +2,8 @@ FROM php:8.3-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
&& a2enmod rewrite \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
+16
View File
@@ -33,6 +33,22 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start.
// Off by default: anything other than the boolean true leaves the OTP step off.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', ${OTP_REQUIRED});
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to (Setting → Users Access). Keys must match
// the user.app_access enum; assets/utils/app_registry.php supplies this default
// for configs that do not define it.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
$packages = [
'starter' => [
+25 -1
View File
@@ -4,15 +4,39 @@ set -e
APP_DIR=/var/www/html/wms-app
CONFIG=$APP_DIR/app/config.php
# Email OTP on sign-in, OFF BY DEFAULT. Only the exact string "true" turns it
# on; a missing variable or anything else means false.
: "${OTP_REQUIRED:=false}"
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
export OTP_REQUIRED
# Generate app/config.php from template on first run only.
# Restrict envsubst to known placeholders so it never touches the app's own
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
if [ ! -f "$CONFIG" ]; then
echo "[entrypoint] generating app/config.php"
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
< /usr/local/etc/wms/config.php.template > "$CONFIG"
fi
# config.php is never regenerated once it exists, so OTP_REQUIRED is the one
# line reconciled on every start: the .env value always wins, and a config.php
# written before this switch existed gets the line added.
if grep -q "define('OTP_REQUIRED'" "$CONFIG"; then
if ! grep -q "define('OTP_REQUIRED', ${OTP_REQUIRED});" "$CONFIG"; then
sed -i "s/define('OTP_REQUIRED', [A-Za-z]*);/define('OTP_REQUIRED', ${OTP_REQUIRED});/" "$CONFIG"
echo "[entrypoint] OTP_REQUIRED is now ${OTP_REQUIRED}"
fi
else
# Drop a closing ?> on the last line so the appended block stays inside PHP.
sed -i -e '${/^[[:space:]]*?>[[:space:]]*$/d}' "$CONFIG"
printf "\nif (!defined('OTP_REQUIRED')) {\n\tdefine('OTP_REQUIRED', %s);\n}\n" "$OTP_REQUIRED" >> "$CONFIG"
echo "[entrypoint] added OTP_REQUIRED = ${OTP_REQUIRED} to an existing config.php"
fi
if [ "$OTP_REQUIRED" = "false" ]; then
echo "[entrypoint] email OTP is off (OTP_REQUIRED=false); sign-in is password only."
fi
mkdir -p "$APP_DIR/app/uploads"
chown -R www-data:www-data "$APP_DIR/app/uploads"
-21
View File
@@ -1,21 +0,0 @@
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [NODE-CRON] [WARN] missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
-58
View File
@@ -1,58 +0,0 @@
2026-07-20T17:35:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-20T21:00:00: [scheduler] etl_gl slot=21
2026-07-20T21:00:00: [scheduler] etl_gl slot=21 — no companies
2026-07-21T16:06:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-22T13:08:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [NODE-CRON] [WARN] missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-22T15:00:00: [scheduler] etl_gl slot=15
2026-07-22T15:00:00: [scheduler] etl_gl slot=15 — no companies
2026-07-22T15:30:00: [scheduler] etl_stock slot=15
2026-07-22T15:30:00: [scheduler] etl_stock slot=15 — no companies
2026-07-22T16:00:00: [scheduler] etl_gl slot=16
2026-07-22T16:00:00: [scheduler] etl_gl slot=16 — no companies
2026-07-23T12:11:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-23T12:30:00: [scheduler] etl_stock slot=12
2026-07-23T12:30:00: [scheduler] etl_stock slot=12 — no companies
2026-07-23T13:36:04: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T15:30:00: [scheduler] etl_stock slot=15
2026-07-23T15:30:00: [scheduler] etl_stock slot=15 — no companies
2026-07-23T16:00:00: [scheduler] etl_gl slot=16
2026-07-23T16:00:00: [scheduler] etl_gl slot=16 — no companies
2026-07-23T16:30:00: [scheduler] etl_stock slot=16
2026-07-23T16:30:00: [scheduler] etl_stock slot=16 — no companies
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [NODE-CRON] [WARN] missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-23T21:30:00: [scheduler] etl_stock slot=21
2026-07-23T21:30:00: [scheduler] etl_stock slot=21 — no companies
2026-07-25T12:19:55: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-07-25T12:30:00: [scheduler] etl_stock slot=12
2026-07-25T12:30:00: [scheduler] etl_stock slot=12 — no companies
2026-07-25T13:00:00: [scheduler] etl_gl slot=13
2026-07-25T13:00:00: [scheduler] etl_gl slot=13 — no companies
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [NODE-CRON] [WARN] missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T08:52:15: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-08-03T09:00:00: [scheduler] etl_gl slot=9
2026-08-03T09:00:00: [scheduler] alert_overdue_invoices running
2026-08-03T09:00:00: [scheduler] etl_gl slot=9 — no companies
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T13:35:14: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T16:00:00: [scheduler] etl_gl slot=16
2026-08-04T16:00:00: [scheduler] etl_gl slot=16 — no companies
2026-08-04T16:30:00: [scheduler] etl_stock slot=16
2026-08-04T16:30:00: [scheduler] etl_stock slot=16 — no companies
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
-255
View File
@@ -1,255 +0,0 @@
2026-07-20T17:35:51: Node.js real-time server running on port 3000
2026-07-21T16:06:58: Node.js real-time server running on port 3000
2026-07-22T13:08:58: Node.js real-time server running on port 3000
2026-07-22T13:10:29: [connect] socket=0-LquhazY9eKKN7LAAAB company=1 user=1 role=owner
2026-07-22T13:15:09: [disconnect] socket=0-LquhazY9eKKN7LAAAB
2026-07-22T13:15:12: [connect] socket=P4rxlPGuHWMqT35XAAAD company=1 user=1 role=owner
2026-07-22T13:26:21: [disconnect] socket=P4rxlPGuHWMqT35XAAAD
2026-07-22T13:26:23: [connect] socket=EBxi3tj8fNMUmyoyAAAF company=1 user=1 role=owner
2026-07-22T13:30:24: [disconnect] socket=EBxi3tj8fNMUmyoyAAAF
2026-07-22T13:30:25: [connect] socket=SJtRh1L6usnHrWebAAAH company=1 user=1 role=owner
2026-07-22T13:31:59: [disconnect] socket=SJtRh1L6usnHrWebAAAH
2026-07-22T13:31:59: [connect] socket=xvRdZhqqg-soDWr7AAAJ company=1 user=1 role=owner
2026-07-22T13:41:17: [disconnect] socket=xvRdZhqqg-soDWr7AAAJ
2026-07-22T13:41:17: [connect] socket=QZkAkh2pHOGltp-0AAAL company=1 user=1 role=owner
2026-07-22T13:42:28: [disconnect] socket=QZkAkh2pHOGltp-0AAAL
2026-07-22T13:42:28: [connect] socket=0Vb5YTMHDs1gOC47AAAN company=1 user=1 role=owner
2026-07-22T13:42:36: [disconnect] socket=0Vb5YTMHDs1gOC47AAAN
2026-07-22T13:42:36: [connect] socket=idQO9l1OGLiXPyEoAAAP company=1 user=1 role=owner
2026-07-22T13:42:43: [disconnect] socket=idQO9l1OGLiXPyEoAAAP
2026-07-22T13:42:43: [connect] socket=nvfS_4EF_3kF5PGsAAAR company=1 user=1 role=owner
2026-07-22T13:47:25: [disconnect] socket=nvfS_4EF_3kF5PGsAAAR
2026-07-22T13:47:27: [connect] socket=yytX3fzh594f9phBAAAT company=1 user=1 role=owner
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-07-22T14:03:26: [disconnect] socket=yytX3fzh594f9phBAAAT
2026-07-22T14:03:28: [connect] socket=waEM4mo4V099RHhAAAAV company=1 user=1 role=owner
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:11:10: [disconnect] socket=waEM4mo4V099RHhAAAAV
2026-07-22T14:35:14: [connect] socket=ZSmIs38dJh1u4If4AAAX company=1 user=1 role=owner
2026-07-22T14:35:49: [disconnect] socket=ZSmIs38dJh1u4If4AAAX
2026-07-22T14:35:49: [connect] socket=1bcHdQOC7cBTftxyAAAZ company=1 user=1 role=owner
2026-07-22T14:36:37: [disconnect] socket=1bcHdQOC7cBTftxyAAAZ
2026-07-22T14:36:38: [connect] socket=fMazKVGWKhaTm7OUAAAb company=1 user=1 role=owner
2026-07-22T14:36:46: [disconnect] socket=fMazKVGWKhaTm7OUAAAb
2026-07-22T14:36:46: [connect] socket=VfqVaEiOI3NTCA7fAAAd company=1 user=1 role=owner
2026-07-22T14:36:48: [disconnect] socket=VfqVaEiOI3NTCA7fAAAd
2026-07-22T14:36:48: [connect] socket=DAww8irzEgS7j7JLAAAf company=1 user=1 role=owner
2026-07-22T14:36:49: [disconnect] socket=DAww8irzEgS7j7JLAAAf
2026-07-22T14:36:49: [connect] socket=WgF3HArg1rthWkktAAAh company=1 user=1 role=owner
2026-07-22T14:37:05: [disconnect] socket=WgF3HArg1rthWkktAAAh
2026-07-22T14:37:05: [connect] socket=pfMLLNR0x-qoq485AAAj company=1 user=1 role=owner
2026-07-22T14:37:08: [disconnect] socket=pfMLLNR0x-qoq485AAAj
2026-07-22T14:37:09: [connect] socket=7ZtkCaJZqcYXBSZWAAAl company=1 user=1 role=owner
2026-07-22T14:37:14: [disconnect] socket=7ZtkCaJZqcYXBSZWAAAl
2026-07-22T14:37:14: [connect] socket=F6_OvPrmc-vv_KoqAAAn company=1 user=1 role=owner
2026-07-22T14:37:18: [disconnect] socket=F6_OvPrmc-vv_KoqAAAn
2026-07-22T14:37:18: [connect] socket=gdlZxPbkkcdkdE2AAAAp company=1 user=1 role=owner
2026-07-22T14:40:08: [disconnect] socket=gdlZxPbkkcdkdE2AAAAp
2026-07-22T14:40:09: [connect] socket=c8j8ybp-e7MPpa6cAAAr company=1 user=1 role=owner
2026-07-22T14:40:13: [disconnect] socket=c8j8ybp-e7MPpa6cAAAr
2026-07-22T14:40:13: [connect] socket=D36DmJgraENmU5xsAAAt company=1 user=1 role=owner
2026-07-22T14:40:20: [disconnect] socket=D36DmJgraENmU5xsAAAt
2026-07-22T14:40:21: [connect] socket=saY0q6gBioHWgq7RAAAv company=1 user=1 role=owner
2026-07-22T14:40:25: [disconnect] socket=saY0q6gBioHWgq7RAAAv
2026-07-22T14:40:25: [connect] socket=o9h4_9i-KOjDfVmrAAAx company=1 user=1 role=owner
2026-07-22T14:40:27: [disconnect] socket=o9h4_9i-KOjDfVmrAAAx
2026-07-22T14:40:27: [connect] socket=aIM89idl78lyhTbNAAAz company=1 user=1 role=owner
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
2026-07-22T15:21:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'transfer' }
2026-07-22T15:28:23: [disconnect] socket=aIM89idl78lyhTbNAAAz
2026-07-22T15:43:35: [connect] socket=q1N4BECKfiomOEfyAAA1 company=1 user=1 role=owner
2026-07-22T15:43:38: [disconnect] socket=q1N4BECKfiomOEfyAAA1
2026-07-22T15:43:38: [connect] socket=yM_0j72uX0V2b-GuAAA3 company=1 user=1 role=owner
2026-07-22T15:43:43: [disconnect] socket=yM_0j72uX0V2b-GuAAA3
2026-07-22T15:43:43: [connect] socket=dvzUKq0p7lXQMuSLAAA5 company=1 user=1 role=owner
2026-07-22T15:43:45: [disconnect] socket=dvzUKq0p7lXQMuSLAAA5
2026-07-22T15:43:45: [connect] socket=TYvNpQgcOHR5-V1RAAA7 company=1 user=1 role=owner
2026-07-22T15:43:46: [disconnect] socket=TYvNpQgcOHR5-V1RAAA7
2026-07-22T15:43:46: [connect] socket=waNTeqU5sAu8W2jqAAA9 company=1 user=1 role=owner
2026-07-22T16:05:01: [disconnect] socket=waNTeqU5sAu8W2jqAAA9
2026-07-23T12:11:51: Node.js real-time server running on port 3000
2026-07-23T12:13:27: [connect] socket=NYO9Tg4V6Cqp3e4cAAAB company=1 user=1 role=owner
2026-07-23T12:33:05: [disconnect] socket=NYO9Tg4V6Cqp3e4cAAAB
2026-07-23T12:33:06: [connect] socket=pEDe8Dms2dU7gdhCAAAD company=1 user=1 role=owner
2026-07-23T12:34:13: [disconnect] socket=pEDe8Dms2dU7gdhCAAAD
2026-07-23T12:34:17: [connect] socket=vaGUT12vPXkqH_7kAAAF company=1 user=1 role=owner
2026-07-23T12:55:49: [disconnect] socket=vaGUT12vPXkqH_7kAAAF
2026-07-23T13:36:04: Node.js real-time server running on port 3000
2026-07-23T13:36:55: [connect] socket=nVGY71aXPas0zYS_AAAB company=1 user=1 role=owner
2026-07-23T13:51:36: [disconnect] socket=nVGY71aXPas0zYS_AAAB
2026-07-23T13:51:36: [connect] socket=5peGIWbSaRCxnlYBAAAD company=1 user=1 role=owner
2026-07-23T13:51:38: [disconnect] socket=5peGIWbSaRCxnlYBAAAD
2026-07-23T13:51:38: [connect] socket=fGwRmZaYGzedlqqRAAAF company=1 user=1 role=owner
2026-07-23T13:51:40: [disconnect] socket=fGwRmZaYGzedlqqRAAAF
2026-07-23T13:51:40: [connect] socket=YZk7xLKHpmi29ykIAAAH company=1 user=1 role=owner
2026-07-23T13:51:41: [disconnect] socket=YZk7xLKHpmi29ykIAAAH
2026-07-23T13:51:41: [connect] socket=f078x01mtX2eGd2HAAAJ company=1 user=1 role=owner
2026-07-23T13:57:26: [disconnect] socket=f078x01mtX2eGd2HAAAJ
2026-07-23T13:57:28: [connect] socket=vToy_ZVIAk6w9099AAAL company=1 user=1 role=owner
2026-07-23T14:17:58: [disconnect] socket=vToy_ZVIAk6w9099AAAL
2026-07-23T14:18:00: [connect] socket=7yHLdkKxBAAO_nF4AAAN company=1 user=1 role=owner
2026-07-23T16:25:41: [disconnect] socket=7yHLdkKxBAAO_nF4AAAN
2026-07-25T12:19:55: Node.js real-time server running on port 3000
2026-08-03T08:52:15: Node.js real-time server running on port 3000
2026-08-03T10:17:41: [connect] socket=kNyFq-T_JVC3-_WLAAAB company=1 user=1 role=owner
2026-08-03T10:18:50: [disconnect] socket=kNyFq-T_JVC3-_WLAAAB
2026-08-03T10:18:50: [connect] socket=efcou9_hk0YUTnvQAAAD company=1 user=1 role=owner
2026-08-03T10:18:59: [disconnect] socket=efcou9_hk0YUTnvQAAAD
2026-08-03T10:18:59: [connect] socket=PmKuy_3CCIDze4P3AAAF company=1 user=1 role=owner
2026-08-03T10:32:14: [disconnect] socket=PmKuy_3CCIDze4P3AAAF
2026-08-03T10:32:18: [connect] socket=-ZlIPBBmpRazD30gAAAH company=1 user=1 role=owner
2026-08-03T10:49:23: [disconnect] socket=-ZlIPBBmpRazD30gAAAH
2026-08-03T10:49:26: [connect] socket=Azbj9ipTPqb3aOW3AAAJ company=1 user=1 role=owner
2026-08-03T10:54:19: [disconnect] socket=Azbj9ipTPqb3aOW3AAAJ
2026-08-03T10:54:19: [connect] socket=FMnx-fmSk96rxIfwAAAL company=1 user=1 role=owner
2026-08-03T11:13:57: [disconnect] socket=FMnx-fmSk96rxIfwAAAL
2026-08-03T11:13:59: [connect] socket=LgVxBoN_6JuJtxHyAAAN company=1 user=1 role=owner
2026-08-04T13:35:14: Node.js real-time server running on port 3000
2026-08-04T13:36:21: [connect] socket=BOvxSU23giBsnIXWAAAB company=1 user=1 role=owner
2026-08-04T13:36:25: [disconnect] socket=BOvxSU23giBsnIXWAAAB
2026-08-04T13:36:25: [connect] socket=pJXUXD7HNX_V9peAAAAD company=1 user=1 role=owner
2026-08-04T13:36:27: [disconnect] socket=pJXUXD7HNX_V9peAAAAD
2026-08-04T13:36:27: [connect] socket=St7RkiRumWpwc47xAAAF company=1 user=1 role=owner
2026-08-04T13:36:28: [disconnect] socket=St7RkiRumWpwc47xAAAF
2026-08-04T13:36:28: [connect] socket=a9NsNFmUpIL1vW8uAAAH company=1 user=1 role=owner
2026-08-04T13:40:28: [disconnect] socket=a9NsNFmUpIL1vW8uAAAH
2026-08-04T13:40:28: [connect] socket=uYLFddlhCkOxrcJNAAAJ company=1 user=1 role=owner
2026-08-04T13:48:10: [disconnect] socket=uYLFddlhCkOxrcJNAAAJ
2026-08-04T13:48:11: [connect] socket=VekC6UabiJABBbjhAAAL company=1 user=1 role=owner
2026-08-04T13:50:28: [disconnect] socket=VekC6UabiJABBbjhAAAL
2026-08-04T13:50:29: [connect] socket=gj-glld-ZsxWbGQuAAAN company=1 user=1 role=owner
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:11:36: [disconnect] socket=gj-glld-ZsxWbGQuAAAN
2026-08-04T14:11:36: [connect] socket=v97BofsQmYBAEJMmAAAP company=1 user=1 role=owner
2026-08-04T14:13:54: [disconnect] socket=v97BofsQmYBAEJMmAAAP
2026-08-04T14:13:54: [connect] socket=WYJSdI9xVDaTML9xAAAR company=1 user=1 role=owner
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:21:12: [disconnect] socket=WYJSdI9xVDaTML9xAAAR
2026-08-04T14:21:12: [connect] socket=UhIyCllsOjal4UwCAAAT company=1 user=1 role=owner
2026-08-04T14:21:13: [disconnect] socket=UhIyCllsOjal4UwCAAAT
2026-08-04T14:21:13: [connect] socket=6hZ-_fAyDgWzwsgvAAAV company=1 user=1 role=owner
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:29:14: [disconnect] socket=6hZ-_fAyDgWzwsgvAAAV
2026-08-04T14:29:14: [connect] socket=7ocTMfufQlyO36XrAAAX company=1 user=1 role=owner
2026-08-04T14:29:19: [disconnect] socket=7ocTMfufQlyO36XrAAAX
2026-08-04T14:36:58: [connect] socket=kY4WNaECxPvGVj2JAAAZ company=1 user=1 role=owner
2026-08-04T14:37:12: [disconnect] socket=kY4WNaECxPvGVj2JAAAZ
2026-08-04T14:37:12: [connect] socket=OabEymZu5SehwNWnAAAb company=1 user=1 role=owner
2026-08-04T14:37:40: [disconnect] socket=OabEymZu5SehwNWnAAAb
2026-08-04T14:41:55: [connect] socket=kAlZOJl54kd8RXKAAAAd company=1 user=1 role=owner
2026-08-04T14:42:42: [disconnect] socket=kAlZOJl54kd8RXKAAAAd
2026-08-04T14:42:42: [connect] socket=DBSytTfd-o12DxhfAAAf company=1 user=1 role=owner
2026-08-04T14:49:38: [disconnect] socket=DBSytTfd-o12DxhfAAAf
2026-08-04T14:49:39: [connect] socket=sAr1qebAYGyIq8zrAAAh company=1 user=1 role=owner
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:59:17: [disconnect] socket=sAr1qebAYGyIq8zrAAAh
2026-08-04T14:59:17: [connect] socket=a264uVnqws4cIAy-AAAj company=1 user=1 role=owner
2026-08-04T15:21:19: [disconnect] socket=a264uVnqws4cIAy-AAAj
2026-08-04T15:21:19: [connect] socket=JL7kcUwnQI_NExMkAAAl company=1 user=1 role=owner
2026-08-04T15:21:22: [disconnect] socket=JL7kcUwnQI_NExMkAAAl
2026-08-04T15:27:09: [connect] socket=VcbOCpKEShqcqqM0AAAn company=1 user=1 role=owner
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:30:09: [disconnect] socket=VcbOCpKEShqcqqM0AAAn
2026-08-04T15:30:09: [connect] socket=ecBAVshG1Eng4jh5AAAp company=1 user=1 role=owner
2026-08-04T15:33:32: [disconnect] socket=ecBAVshG1Eng4jh5AAAp
2026-08-04T15:33:32: [connect] socket=M43MyEQ7wipYOgd5AAAr company=1 user=1 role=owner
2026-08-04T15:34:32: [disconnect] socket=M43MyEQ7wipYOgd5AAAr
2026-08-04T15:34:32: [connect] socket=TiGDT6OMJsYlixlkAAAt company=1 user=1 role=owner
2026-08-04T15:35:36: [disconnect] socket=TiGDT6OMJsYlixlkAAAt
2026-08-04T15:35:36: [connect] socket=uHRGhZU0TJVvvh_aAAAv company=1 user=1 role=owner
2026-08-04T15:36:24: [disconnect] socket=uHRGhZU0TJVvvh_aAAAv
2026-08-04T15:36:24: [connect] socket=Hsui_73WGENhGdRIAAAx company=1 user=1 role=owner
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:39:55: [disconnect] socket=Hsui_73WGENhGdRIAAAx
2026-08-04T15:39:55: [connect] socket=xSl0-9raxlwU2K9rAAAz company=1 user=1 role=owner
2026-08-04T15:52:53: [disconnect] socket=xSl0-9raxlwU2K9rAAAz
2026-08-04T15:52:53: [connect] socket=n_l9kHYTF1AXNNRYAAA1 company=1 user=1 role=owner
2026-08-04T15:58:37: [disconnect] socket=n_l9kHYTF1AXNNRYAAA1
2026-08-04T15:58:37: [connect] socket=T9mpzRMT3I1qjj0BAAA3 company=1 user=1 role=owner
2026-08-04T15:58:39: [disconnect] socket=T9mpzRMT3I1qjj0BAAA3
2026-08-04T15:58:39: [connect] socket=Q1jGtGwwFc49KKxDAAA5 company=1 user=1 role=owner
2026-08-04T15:58:40: [disconnect] socket=Q1jGtGwwFc49KKxDAAA5
2026-08-04T15:58:41: [connect] socket=Fk7l8SLr2IIRFFHbAAA7 company=1 user=1 role=owner
2026-08-04T15:58:42: [disconnect] socket=Fk7l8SLr2IIRFFHbAAA7
2026-08-04T15:58:42: [connect] socket=pAuTSmpDDC86EZwXAAA9 company=1 user=1 role=owner
2026-08-04T16:41:11: [disconnect] socket=pAuTSmpDDC86EZwXAAA9
2026-08-04T16:41:13: [connect] socket=XmFaoUIej-g8203TAAA_ company=1 user=1 role=owner
2026-08-04T16:41:25: [disconnect] socket=XmFaoUIej-g8203TAAA_
2026-08-04T16:41:28: [connect] socket=TxwzTsUHzqDLHpODAABB company=1 user=1 role=owner
2026-08-04T16:43:43: [disconnect] socket=TxwzTsUHzqDLHpODAABB
2026-08-04T16:44:26: [connect] socket=TOhs2YrBWQkiGDZDAABD company=1 user=1 role=owner
2026-08-04T16:58:05: [disconnect] socket=TOhs2YrBWQkiGDZDAABD
2026-08-04T16:59:15: [connect] socket=p1xNFoGJFKox8FaOAABF company=1 user=1 role=owner
2026-08-04T17:25:03: [disconnect] socket=p1xNFoGJFKox8FaOAABF
2026-08-04T17:25:03: [connect] socket=j3s6wvwe_BxVzCA_AABH company=1 user=1 role=owner
2026-08-04T17:25:05: [disconnect] socket=j3s6wvwe_BxVzCA_AABH
2026-08-04T17:25:05: [connect] socket=4wkwmOWCAvQSicL3AABJ company=1 user=1 role=owner
2026-08-04T17:26:40: [disconnect] socket=4wkwmOWCAvQSicL3AABJ