29 Commits

Author SHA1 Message Date
6b515c608f chore(release): 0.0.2-alpha.10
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 2m12s
Release and Build Image / release (push) Successful in 16s
2026-05-08 15:09:49 -05:00
d8869b103b fix(scan user): typo 2026-05-08 15:08:33 -05:00
1dba774abc chore(server): removed a console log that shouldnt be there
Some checks failed
Build and Push LST Docker Image / docker (push) Failing after 1m10s
2026-05-08 15:06:08 -05:00
505d7cea5d refactor(scan): bump in build and style update 2026-05-08 15:05:47 -05:00
1ff5e5032f test(scanusers): added in scan users as test 2026-05-08 15:05:09 -05:00
5fa70da90c chore(file): name changes.. spelled wrong 2026-05-08 15:04:31 -05:00
0459cd788a fix(spelling): corrected the spelling on the file 2026-05-08 15:03:53 -05:00
7d7d991122 fix(schema): typo in add_date 2026-05-08 15:03:33 -05:00
2721bb2a3b feat(api hits): added in api hits for monitoring 2026-05-08 15:03:03 -05:00
4424c742d2 refactor(analyitics): finished analyitics as a base 2026-05-08 15:02:34 -05:00
6d8499bfb8 ci(templates): force useage 2026-05-08 15:01:44 -05:00
9edafc9d28 feat(analytics): added in backend anaylitics 2026-05-07 10:20:50 -05:00
e9b0101095 ci(template): bug in getting the template to work correctly
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 1m28s
2026-05-07 09:01:15 -05:00
ca885fb01a ci(templates): added in templates for the repo to make it more easy to manage and add in new ideas
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 1m24s
2026-05-07 08:50:06 -05:00
edb3668548 refactor(scanner): added toasts in to make it look better
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 1m25s
2026-05-06 19:42:52 -05:00
87803eed43 feat(scanner): added in lanechecks 2026-05-06 19:42:22 -05:00
e61038e004 chore(release): 0.0.2-alpha.9
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 2m42s
Release and Build Image / release (push) Successful in 28s
2026-05-06 13:34:30 -05:00
d99449ddc4 test(scanner): lane check
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 1m41s
2026-05-06 13:30:58 -05:00
3552ca31f9 build(builds): changed to ip as its on the same server
Some checks failed
Build and Push LST Docker Image / docker (push) Failing after 4s
2026-05-06 12:27:20 -05:00
b578f05d64 build(release): bypass cloudflare upload limit
Some checks failed
Build and Push LST Docker Image / docker (push) Failing after 3m43s
2026-05-06 12:17:42 -05:00
4ca74de279 refactor(mobile): valildation of server after each scan
Some checks failed
Build and Push LST Docker Image / docker (push) Failing after 3m40s
2026-05-06 12:10:14 -05:00
12412536d1 refactor(scanner): finished login stuff for current routes 2026-05-06 12:09:47 -05:00
a38e2e0339 refactor(scanner): added in running number 2026-05-06 12:09:09 -05:00
8c253a90b6 chore(release): 0.0.2-alpha.8
Some checks failed
Build and Push LST Docker Image / docker (push) Failing after 4m12s
Release and Build Image / release (push) Failing after 2m19s
2026-05-06 05:08:27 -05:00
ba30281e59 feat(mobile): auth added in
Some checks failed
Build and Push LST Docker Image / docker (push) Has been cancelled
2026-05-06 05:07:16 -05:00
2ad78e22f1 chore(release): 0.0.2-alpha.7
Some checks failed
Build and Push LST Docker Image / docker (push) Failing after 4m3s
Release and Build Image / release (push) Failing after 2m30s
2026-05-05 19:50:58 -05:00
518c0a8c19 refactor(scanner): format changes
Some checks failed
Build and Push LST Docker Image / docker (push) Has been cancelled
2026-05-05 19:50:02 -05:00
cd13360cfb feat(intial auth): intial auth setup for the scanner
Some checks failed
Build and Push LST Docker Image / docker (push) Has been cancelled
2026-05-05 19:48:36 -05:00
4e0cf8c54c refactor(docker compose): changed to have the correct url that will be used as this is for auth 2026-05-05 14:52:36 -05:00
112 changed files with 26485 additions and 2876 deletions

View File

@@ -0,0 +1,66 @@
---
name: Bug Report
about: Report something that is broken or not working correctly
title: "[BUG] "
ref: "main"
labels:
- bug
---
# Summary
Briefly explain the issue.
---
# Steps To Reproduce
1. Go to ...
2. Click ...
3. Scan ...
4. Error occurs ...
---
# Expected Behavior
What should have happened?
---
# Actual Behavior
What actually happened instead?
---
# Severity
- [ ] Low
- [ ] Medium
- [ ] High
- [ ] Critical
---
# Environment
Example:
- Production
- Development
- Zebra Scanner
- Mobile Device
- Windows Server
- Docker
---
# Logs / Screenshots
Paste logs or upload screenshots here.
```txt
Paste logs here

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,47 @@
---
name: Enhancement
about: Improve or refine an existing feature
title: "[ENHANCEMENT] "
ref: "main"
labels:
- enhancement
---
# Existing Feature
What current feature or workflow is being improved?
Example:
- Notifications
- Scanner Login
- Release Monitor
- Printing
- Auth
---
# Proposed Improvement
Describe the improvement.
---
# Expected Benefit
Why would this improvement help?
---
# Impact
- [ ] Small
- [ ] Medium
- [ ] Large
---
# Additional Notes
Anything else worth mentioning.

View File

@@ -0,0 +1,40 @@
---
name: Feature Request
about: Suggest a brand new feature or module
title: "[FEATURE] "
ref: "main"
labels:
- feature
---
# Problem Statement
What problem are you trying to solve?
---
# Proposed Solution
Describe the feature you would like added.
---
# Alternatives Considered
Any other ideas, workarounds, or approaches?
---
# Priority
- [ ] Nice to Have
- [ ] Medium Priority
- [ ] High Priority
- [ ] Critical
---
# Additional Context
Add mockups, screenshots, examples, or notes here.

View File

@@ -12,20 +12,20 @@ jobs:
steps:
- name: Checkout (local)
run: |
git clone https://git.tuffraid.net/cowch/lst_v3.git .
git clone http://10.75.9.150:3100/cowch/lst_v3.git .
git checkout ${{ gitea.sha }}
- name: Login to registry
run: echo "${{ secrets.PASSWORD }}" | docker login git.tuffraid.net -u "cowch" --password-stdin
run: echo "${{ secrets.PASSWORD }}" | docker login 10.75.9.150:3100 -u "cowch" --password-stdin
- name: Build image
run: |
docker build \
-t git.tuffraid.net/cowch/lst_v3:latest \
-t git.tuffraid.net/cowch/lst_v3:${{ gitea.sha }} \
-t 10.75.9.150:3100/cowch/lst_v3:latest \
-t 10.75.9.150:3100/cowch/lst_v3:${{ gitea.sha }} \
.
- name: Push
run: |
docker push git.tuffraid.net/cowch/lst_v3:latest
docker push git.tuffraid.net/cowch/lst_v3:${{ gitea.sha }}
docker push 10.75.9.150:3100/cowch/lst_v3:latest
docker push 10.75.9.150:3100/cowch/lst_v3:${{ gitea.sha }}

View File

@@ -14,12 +14,12 @@ jobs:
# Examples:
# http://gitea.internal.lan:3000
# https://gitea-origin.yourdomain.local
GITEA_INTERNAL_URL: "https://git.tuffraid.net"
GITEA_INTERNAL_URL: "http://10.75.9.150:3100" #"https://git.tuffraid.net"
# Internal/origin registry host. Usually same host as above, but without protocol.
# Example:
# gitea.internal:3000
REGISTRY_HOST: "git.tuffraid.net"
REGISTRY_HOST: "10.75.9.150:3100" #"git.tuffraid.net"
steps:
- name: Check out repository

View File

@@ -1,5 +1,104 @@
# All Changes to LST can be found below.
## [0.0.2-alpha.10](https://git.tuffraid.net/cowch/lst_v3/compare/v0.0.2-alpha.9...v0.0.2-alpha.10) (2026-05-08)
### 🌟 Enhancements
* **analytics:** added in backend anaylitics ([9edafc9](https://git.tuffraid.net/cowch/lst_v3/commits/9edafc9d2810f339d197c10dfc6a037b3352d81f))
* **api hits:** added in api hits for monitoring ([2721bb2](https://git.tuffraid.net/cowch/lst_v3/commits/2721bb2a3bf1f829591d26a0716f74c4f7fc0c79))
* **scanner:** added in lanechecks ([87803ee](https://git.tuffraid.net/cowch/lst_v3/commits/87803eed43069b73de3f66e6524bb45da9c46334))
### 🐛 Bug fixes
* **scan user:** typo ([d8869b1](https://git.tuffraid.net/cowch/lst_v3/commits/d8869b103b80e4208b3928a370a9524ef33d25cd))
* **schema:** typo in add_date ([7d7d991](https://git.tuffraid.net/cowch/lst_v3/commits/7d7d9911223905d6767b87d2471b6607a90f1ea7))
* **spelling:** corrected the spelling on the file ([0459cd7](https://git.tuffraid.net/cowch/lst_v3/commits/0459cd788aaad6ac54a67e23f798ce5e5a437394))
### 📝 Chore
* **file:** name changes.. spelled wrong ([5fa70da](https://git.tuffraid.net/cowch/lst_v3/commits/5fa70da90ca290ee45088e9c8eb06ba48a6677af))
* **server:** removed a console log that shouldnt be there ([1dba774](https://git.tuffraid.net/cowch/lst_v3/commits/1dba774abc54bf20850c3f26d49926e86d59712d))
### 🛠️ Code Refactor
* **analyitics:** finished analyitics as a base ([4424c74](https://git.tuffraid.net/cowch/lst_v3/commits/4424c742d24dc230b2bc1782e33535184c378cf0))
* **scan:** bump in build and style update ([505d7ce](https://git.tuffraid.net/cowch/lst_v3/commits/505d7cea5d2f52fc4a3ec1edff1878be703c4034))
* **scanner:** added toasts in to make it look better ([edb3668](https://git.tuffraid.net/cowch/lst_v3/commits/edb366854825f4c24ab5d77cf88759465d067f00))
### 📝 Testing Code
* **scanusers:** added in scan users as test ([1ff5e50](https://git.tuffraid.net/cowch/lst_v3/commits/1ff5e5032f9c8bf81f972dc99d6c86ba8d3936c6))
### 📈 Project changes
* **template:** bug in getting the template to work correctly ([e9b0101](https://git.tuffraid.net/cowch/lst_v3/commits/e9b01010954624aed738cd6e4b82fccbba195cc4))
* **templates:** added in templates for the repo to make it more easy to manage and add in new ideas ([ca885fb](https://git.tuffraid.net/cowch/lst_v3/commits/ca885fb01a3c8bc22694c2e05269c43fcd4de70e))
* **templates:** force useage ([6d8499b](https://git.tuffraid.net/cowch/lst_v3/commits/6d8499bfb85f7b9131b1ec7b31a17c4256d0f0cf))
## [0.0.2-alpha.9](https://git.tuffraid.net/cowch/lst_v3/compare/v0.0.2-alpha.8...v0.0.2-alpha.9) (2026-05-06)
### 🛠️ Code Refactor
* **mobile:** valildation of server after each scan ([4ca74de](https://git.tuffraid.net/cowch/lst_v3/commits/4ca74de2795cea7244e38697d16afe2822164ed6))
* **scanner:** added in running number ([a38e2e0](https://git.tuffraid.net/cowch/lst_v3/commits/a38e2e033977b725538e9a9046098d94194d549e))
* **scanner:** finished login stuff for current routes ([1241253](https://git.tuffraid.net/cowch/lst_v3/commits/12412536d10981013053c39d156c6c9cb0babd11))
### 📝 Testing Code
* **scanner:** lane check ([d99449d](https://git.tuffraid.net/cowch/lst_v3/commits/d99449ddc4e2777c1b0fe9189ba0a7c01fe1dd8f))
### 📈 Project Builds
* **builds:** changed to ip as its on the same server ([3552ca3](https://git.tuffraid.net/cowch/lst_v3/commits/3552ca31f9f7b3bcbe557a145e7eb154bfdae79c))
* **release:** bypass cloudflare upload limit ([b578f05](https://git.tuffraid.net/cowch/lst_v3/commits/b578f05d6482f9b6f30febeee6ab0b708a70f68b))
## [0.0.2-alpha.8](https://git.tuffraid.net/cowch/lst_v3/compare/v0.0.2-alpha.7...v0.0.2-alpha.8) (2026-05-06)
### 🌟 Enhancements
* **mobile:** auth added in ([ba30281](https://git.tuffraid.net/cowch/lst_v3/commits/ba30281e59040513a036fb7413e372457d04a7c8))
## [0.0.2-alpha.7](https://git.tuffraid.net/cowch/lst_v3/compare/v0.0.2-alpha.6...v0.0.2-alpha.7) (2026-05-06)
### 🌟 Enhancements
* **intial auth:** intial auth setup for the scanner ([cd13360](https://git.tuffraid.net/cowch/lst_v3/commits/cd13360cfb931daca50fd7b111e1c8f8ab09a909))
* **mobile:** new route for the ehs launcher ([649ae1e](https://git.tuffraid.net/cowch/lst_v3/commits/649ae1ee9f245a9b5d308ea8a636357bf72b1e34))
* **mobile:** shadcn like and tailwind added to make things look yummy ([7d2f048](https://git.tuffraid.net/cowch/lst_v3/commits/7d2f048932b77269568149de34351840b75486e2))
* **mobile:** update notifications and more error handling added ([30ffd84](https://git.tuffraid.net/cowch/lst_v3/commits/30ffd843c725da79ed035e2d9564f60a6babcda8))
* **scanner:** more work on the scanner and can now scan to prod no lst right now ([77b4533](https://git.tuffraid.net/cowch/lst_v3/commits/77b4533dea8314fd4fb81a597995cabd041fe188))
* **servers:** added iowa ebm ([8446dbc](https://git.tuffraid.net/cowch/lst_v3/commits/8446dbc955462235b9df35c501354761661e4f6a))
### 🐛 Bug fixes
* **mobile:** typo for version checking ([0b7318f](https://git.tuffraid.net/cowch/lst_v3/commits/0b7318f8566d15414edd3cd67c89fa5346058ab0))
### 🛠️ Code Refactor
* **docker compose:** changed to have the correct url that will be used as this is for auth ([4e0cf8c](https://git.tuffraid.net/cowch/lst_v3/commits/4e0cf8c54c4dfd68edba7e733518846a47c55064))
* **gp connection:** added in gp ip into env if not there use static name for dns ([36995e9](https://git.tuffraid.net/cowch/lst_v3/commits/36995e9fb42cfa1b72c096b8860866d70b86e70c))
* **mobile:** more look and feel work ([bb6155c](https://git.tuffraid.net/cowch/lst_v3/commits/bb6155c9692220542a52664848abf0b9eee91a43))
* **mobile:** moved the versioning lookup at at the mobile folder plus renamed ([bddc9ac](https://git.tuffraid.net/cowch/lst_v3/commits/bddc9aca0d2da2b2f53dec1250276d7a076a8601))
* **scanner:** format changes ([518c0a8](https://git.tuffraid.net/cowch/lst_v3/commits/518c0a8c19a4bff0b757bbd06ca5460d3565d8bd))
### 📈 Project Builds
* **scripts:** changing how the relase works so it purposly builds before it trys to release ([83a542d](https://git.tuffraid.net/cowch/lst_v3/commits/83a542d1b7beafe394949c001917f2b25056fac2))
## [0.0.2-alpha.6](https://git.tuffraid.net/cowch/lst_v3/compare/v0.0.2-alpha.1...v0.0.2-alpha.6) (2026-04-23)
## [0.0.2-alpha.1](https://git.tuffraid.net/cowch/lst_v3/compare/v0.0.2-alpha.0...v0.0.2-alpha.1) (2026-04-23)

View File

@@ -1,12 +1,18 @@
import type { Express } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import build from "./admin.build.js";
import update from "./admin.updateServer.js";
export const setupAdminRoutes = (baseUrl: string, app: Express) => {
//stats will be like this as we dont need to change this
app.use(`${baseUrl}/api/admin/build`, requireAuth, build);
app.use(`${baseUrl}/api/admin/build`, requireAuth, update);
app.use(`${baseUrl}/api/admin/build`, requireAuth, routeHitMiddleware, build);
app.use(
`${baseUrl}/api/admin/build`,
requireAuth,
routeHitMiddleware,
update,
);
// all other system should be under /api/system/*
};

View File

@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import { toNodeHandler } from "better-auth/node";
import express from "express";
import morgan from "morgan";
import { umamiConfig } from "./configs/umami.config.js";
import { createLogger } from "./logger/logger.controller.js";
import { setupRoutes } from "./routeHandler.routes.js";
import { auth } from "./utils/auth.utils.js";
@@ -33,6 +34,22 @@ const createApp = async () => {
app.use(express.json());
setupRoutes(baseUrl, app);
app.get(`${baseUrl}/api/lst-config.js`, (_, res) => {
res.type("application/javascript");
res.setHeader("Cache-Control", "no-store");
res.send(`
window.LST_CONFIG = {
appName: ${JSON.stringify(umamiConfig.appName ?? "LST")},
site: ${JSON.stringify(umamiConfig.site ?? "unknown")},
server: ${JSON.stringify(umamiConfig.server ?? "unknown")},
appVersion: ${JSON.stringify(umamiConfig.appVersion ?? "dev")},
umamiHost: ${JSON.stringify(umamiConfig.umamiHost ?? "")},
umamiWebsiteId: ${JSON.stringify(umamiConfig.umamiWebsiteId ?? "")}
};
`);
});
app.use(
`${baseUrl}/app`,
express.static(join(__dirname, "../frontend/dist")),

View File

@@ -1,9 +1,11 @@
import type { Express } from "express";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import login from "./login.route.js";
import register from "./register.route.js";
export const setupAuthRoutes = (baseUrl: string, app: Express) => {
//setup all the routes
app.use(routeHitMiddleware);
app.use(`${baseUrl}/api/authentication/login`, login);
app.use(`${baseUrl}/api/authentication/register`, register);
};

View File

@@ -0,0 +1,21 @@
export type UmamiRuntimeConfig = {
appName: string;
site: string;
server: string;
appVersion: string;
umamiHost: string;
umamiWebsiteId: string;
};
export const umamiConfig: UmamiRuntimeConfig = {
appName: process.env.APP_NAME ?? "LST",
site: process.env.URL ?? "unknown",
server: process.env.PROD_PLANT_TOKEN ?? "unknown", // could also be server name based on our setup.
appVersion: process.env.NODE_ENV ?? "dev",
umamiHost: process.env.UMAMI_HOST ?? "",
umamiWebsiteId: process.env.UMAMI_WEBSITE_ID ?? "",
};
export function isUmamiEnabled() {
return Boolean(umamiConfig.umamiHost && umamiConfig.umamiWebsiteId);
}

View File

@@ -1,5 +1,5 @@
import type { Express } from "express";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
import { datamartData } from "./datamartData.utlis.js";
import runQuery from "./getDatamart.route.js";
@@ -30,7 +30,7 @@ export const setupDatamartRoutes = (baseUrl: string, app: Express) => {
// });
//setup all the routes
app.use(routeHitMiddleware);
app.use(`${baseUrl}/api/datamart`, runQuery);
// just sending a get on datamart will return all the queries that we can call.

View File

@@ -1,6 +1,8 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as scanUserSchema from "./schema/scanUsers.js";
const dbURL = `postgres://${process.env.DATABASE_USER}:${process.env.DATABASE_PASSWORD}@${process.env.DATABASE_HOST}:${process.env.DATABASE_PORT}/${process.env.DATABASE_DB}`;
const queryClient = postgres(dbURL, {
@@ -13,4 +15,10 @@ const queryClient = postgres(dbURL, {
},
});
export const db = drizzle({ client: queryClient });
//export const db = drizzle({ client: queryClient });
export const db = drizzle(queryClient, {
schema: {
...scanUserSchema,
},
});

View File

@@ -0,0 +1,21 @@
import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const analytics = pgTable("analytics", {
id: uuid("id").defaultRandom().primaryKey(),
createdAt: timestamp("created_at").defaultNow().notNull(),
method: text("method").notNull(),
routePattern: text("route_pattern").notNull(),
actualPath: text("actual_path").notNull(),
statusCode: integer("status_code").notNull(),
durationMs: integer("duration_ms").notNull(),
module: text("module"),
userId: text("user_id"),
userEmail: text("user_email"),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
});

View File

@@ -0,0 +1,33 @@
import {
date,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
export const analyticsDaily = pgTable("analytics_daily", {
id: uuid("id").defaultRandom().primaryKey(),
businessDate: date("business_date").notNull(),
method: text("method").notNull(),
routePattern: text("route_pattern").notNull(),
module: text("module").notNull(),
totalHits: integer("total_hits").notNull(),
uniqueUsers: integer("unique_users").notNull(),
successCount: integer("success_count").notNull(),
errorCount: integer("error_count").notNull(),
avgDurationMs: integer("avg_duration_ms").notNull(),
maxDurationMs: integer("max_duration_ms").notNull(),
firstHitAt: timestamp("first_hit_at").notNull(),
lastHitAt: timestamp("last_hit_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

View File

@@ -0,0 +1,48 @@
import {
boolean,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
unique,
uuid,
} from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type z from "zod";
export const mobileRoleEnum = pgEnum("mobile_role", [
"user",
"lead",
"manager",
"admin",
]);
export const scanUser = pgTable(
"scan_users",
{
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(), // the user that will be using the scanner
scannerId: text("scanner_id").unique().notNull(),
pinNumber: text("pin_number").unique().notNull(),
pinHash: text("pin_hash").notNull(),
excludedCommand: jsonb("excluded_commands").default([]),
role: mobileRoleEnum("role").notNull().default("user"),
active: boolean("active").default(true),
lastScan: timestamp("last_scan").defaultNow(),
add_Date: timestamp("add_Date").defaultNow(),
upd_date: timestamp("upd_date").defaultNow(),
},
(table) => ({
userNotificationUnique: unique("scan_user_unique").on(
table.scannerId,
table.pinNumber,
),
}),
);
export const scanUserSchema = createSelectSchema(scanUser);
export const newsSanUserSchema = createInsertSchema(scanUser);
export type ScanUser = z.infer<typeof scanUserSchema>;
export type NewScanUser = z.infer<typeof newsSanUserSchema>;

View File

@@ -4,13 +4,15 @@ import type z from "zod";
export const scanLog = pgTable("scan_log", {
id: uuid("id").defaultRandom().primaryKey(),
user: text("user"),
scannerId: text("scanner_id"),
message: text("message").notNull(),
prompt: text("prompt"),
commandDescription: text("command_description"),
runningNumber: text("running_number").default("0"),
status: text("status"),
lines: jsonb("lines").default([]),
add_Date: timestamp("add_Date").defaultNow(),
add_Date: timestamp("add_date").defaultNow(),
});
export const scanLogSchema = createSelectSchema(scanLog);

View File

@@ -1,5 +1,6 @@
import { type Express, Router } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import restart from "./gpSqlRestart.route.js";
import start from "./gpSqlStart.route.js";
import stop from "./gpSqlStop.route.js";
@@ -8,6 +9,7 @@ export const setupGPSqlRoutes = (baseUrl: string, app: Express) => {
// Apply auth to entire router
const router = Router();
router.use(requireAuth);
app.use(routeHitMiddleware);
router.use(start);
router.use(stop);

View File

@@ -0,0 +1,83 @@
// routeHit.middleware.ts
import type { NextFunction, Request, Response } from "express";
import {
createRouteHit,
shouldIgnoreRoute,
} from "../utils/analyticRouteHits.utils.js";
export function routeHitMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const start = performance.now();
res.on("finish", () => {
const actualPath = getActualPath(req);
if (shouldIgnoreRoute(actualPath)) {
return;
}
const durationMs = Math.round(performance.now() - start);
const routePattern = getRoutePattern(req) as string;
const module = getModuleName(req);
void createRouteHit({
method: req.method,
routePattern,
actualPath,
statusCode: res.statusCode,
durationMs,
module,
// adjust these names to your Better Auth/session shape
userId: req.user?.id ?? null,
userEmail: req.user?.email ?? null,
ipAddress: req.ip ?? null,
userAgent: req.get("user-agent") ?? null,
}).catch((err) => {
console.error("Failed to save route hit", err);
});
});
next();
}
function getActualPath(req: Request) {
return req.originalUrl.split("?")[0] ?? req.path ?? "unknown";
}
function getRoutePattern(req: Request) {
const baseUrl = req.baseUrl || "";
const routePath = req.route?.path;
if (typeof routePath === "string") {
return `${baseUrl}${routePath}`;
}
return getActualPath(req);
}
function getModuleName(req: Request) {
const path = req.originalUrl.split("?")[0];
if (path?.includes("/printers")) return "printers";
if (path?.includes("/releases")) return "releases";
if (path?.includes("/quality")) return "quality";
if (path?.includes("/scanner")) return "scanner";
if (path?.includes("/settings")) return "settings";
if (path?.includes("/users")) return "users";
if (path?.includes("/mobile")) return "mobile";
if (path?.includes("/servers")) return "servers";
if (path?.includes("/logistics")) return "servers";
if (path?.includes("/ocp")) return "ocp";
if (path?.includes("/auth")) return "auth";
if (path?.includes("/datamart")) return "datamart";
if (path?.includes("/opendock")) return "opendock";
return "unknown";
}

View File

@@ -0,0 +1,46 @@
import fs from "node:fs";
import { Router } from "express";
import path from "path";
import { fileURLToPath } from "url";
const router = Router();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const downloadDir = path.resolve(__dirname, "../../downloads/mobile");
const currentApk = {
fileName: "lst-mobile.apk",
};
router.get("/latest", (_, res) => {
const apkPath = path.join(downloadDir, currentApk.fileName);
if (!fs.existsSync(apkPath)) {
return res.status(404).json({ success: false, message: "APK not found" });
}
res.setHeader("Content-Type", "application/vnd.android.package-archive");
res.setHeader(
"Content-Disposition",
`attachment; filename="${currentApk.fileName}"`,
);
return res.sendFile(apkPath);
});
router.get("/ehs", (_, res) => {
const apkPath = path.join(downloadDir, "EHS.apk");
if (!fs.existsSync(apkPath)) {
return res.status(404).json({ success: false, message: "APK not found" });
}
res.setHeader("Content-Type", "application/vnd.android.package-archive");
res.setHeader("Content-Disposition", `attachment; filename="EHS.apk}"`);
return res.sendFile(apkPath);
});
export default router;

View File

@@ -0,0 +1,34 @@
import { Router } from "express";
import { runProdApi } from "../utils/prodEndpoint.utils.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
const router = Router();
router.post("/", async (req, res) => {
const body = req.body;
const lane = body.lane.split("#");
console.log(lane[2]);
const laneData = await runProdApi({
method: "post",
endpoint: "/public/v1.1/Warehousing/GetWarehouseUnits",
data: [
{
laneIds: [lane[2]],
},
],
});
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "lane check",
message: `all data for lane Id: ${lane}`,
data: laneData?.data ?? [],
status: 200,
});
});
export default router;

View File

@@ -0,0 +1,23 @@
import type { Express } from "express";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import downloads from "./downloadApps.route.js";
import lanes from "./laneCheck.js";
import authPin from "./mobileAuth.route.js";
import newPin from "./mobilePin.route.js";
import logs from "./scanLogs.route.js";
import version from "./version.route.js";
export const setupMobileRoutes = (baseUrl: string, app: Express) => {
//stats will be like this as we dont need to change this
app.use(routeHitMiddleware);
app.use(`${baseUrl}/api/mobile/version`, version);
app.use(`${baseUrl}/api/mobile/apk`, downloads);
app.use(`${baseUrl}/api/mobile/logs`, logs);
app.use(`${baseUrl}/api/mobile/auth`, authPin);
app.use(`${baseUrl}/api/mobile/pin`, newPin);
app.use(`${baseUrl}/api/mobile/laneCheck`, lanes);
// all other system should be under /api/system/*
};

View File

@@ -0,0 +1,343 @@
import bcrypt from "bcryptjs";
import { eq, sql } from "drizzle-orm";
import { Router } from "express";
import z from "zod";
import { db } from "../db/db.controller.js";
import {
type NewScanUser,
type ScanUser,
scanUser,
} from "../db/schema/scanUsers.js";
import { requireAuth } from "../middleware/auth.middleware.js";
import { apiReturn, returnFunc } from "../utils/returnHelper.utils.js";
import { tryCatch } from "../utils/trycatch.utils.js";
const r = Router();
export async function hashPin(pin: string) {
// if (!/^\d{6}$/.test(pin)) {
// throw new Error("PIN must be exactly 6 digits");
// }
return bcrypt.hashSync(pin, 12);
}
const registerSchema = z.object({
name: z.string().min(2).max(100),
pinNumber: z.string(),
scannerId: z
.string()
.min(1)
.max(500)
.optional()
.describe("if you leave blank it will be the same as your username"),
role: z
.enum(["user", "lead", "manager", "admin"])
.optional()
.describe("What roles are available to use."),
pinHash: z.string().optional(),
});
r.post("/pin", async (req, res) => {
const { pin } = req.body;
if (!pin || pin.length !== 6) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `Pin number must be a min of 6 digits`,
data: [],
status: 401,
});
}
// const user = await db
// .select()
// .from(scanUser)
// .where(eq(scanUser.pinNumber, parseInt(pin, 10)));
const user = await db.query.scanUser.findFirst({
where: (u, { eq }) => eq(u.pinNumber, pin),
});
if (!user) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `Invalid login please try again.`,
data: [],
status: 401,
});
}
const validPin = bcrypt.compareSync(pin, user.pinHash);
if (!validPin) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `Invalid pin please try again.`,
data: [],
status: 401,
});
}
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "auth",
message: `Welcome back ${user.name}`,
data: user as ScanUser | any,
status: 200,
});
});
r.post("/user", async (req, res) => {
try {
// validate the body is correct before accepting it
let validated = registerSchema.parse(req.body);
validated = {
...validated,
pinHash: await hashPin(validated.pinNumber.toString()),
};
const values: NewScanUser = {
name: validated.name,
pinNumber: validated.pinNumber,
pinHash: validated.pinHash ?? "",
scannerId: validated.scannerId ?? "",
};
const newUser = await db.insert(scanUser).values(values).returning();
apiReturn(res, {
success: true,
level: "info", //connect.success ? "info" : "error",
module: "mobile",
subModule: "auth",
message: `${validated.name} was just created`,
data: newUser as any,
status: 200, //connect.success ? 200 : 400,
});
} catch (err) {
if (err instanceof z.ZodError) {
const flattened = z.flattenError(err);
// return res.status(400).json({
// error: "Validation failed",
// details: flattened,
// });
return apiReturn(res, {
success: false,
level: "error", //connect.success ? "info" : "error",
module: "mobile",
subModule: "auth",
message: "Validation failed",
data: [flattened.fieldErrors],
status: 400, //connect.success ? 200 : 400,
});
}
return apiReturn(res, {
success: false,
level: "error", //connect.success ? "info" : "error",
module: "mobile",
subModule: "auth",
message:
"This User already exist with this pin or scanner id please try again",
data: [err],
status: 400, //connect.success ? 200 : 400,
});
}
});
r.get("/user", requireAuth, async (_, res) => {
const { data, error } = await tryCatch(db.select().from(scanUser));
// await trackLstEvent({
// eventName: "mobile_get_users",
// url: "/mobile/users",
// eventData: {
// module: "mobile",
// },
// });
if (error) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `There was an error getting the user`,
data: error as any,
status: 400,
});
}
if (!data) {
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "auth",
message: `There are no users you should add one . `,
data: [],
status: 200,
});
}
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "auth",
message: `All users. `,
data,
status: 200,
});
});
r.patch("/user/:id", requireAuth, async (req, res) => {
const updates: Record<string, unknown | null> = {};
const { id } = req.params;
const { data, error } = await tryCatch(
db.query.scanUser.findFirst({
where: (u, { eq }) => eq(u.id, `${id}`),
}),
);
if (error) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `There was an error getting the user`,
data: error as any,
status: 400,
});
}
if (!data) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `Invalid user id was passed over. `,
data: [],
status: 400,
});
}
if (req.body?.name !== undefined) {
updates.name = req.body.name;
}
if (req.body?.pinNumber !== undefined) {
const existing = await db.query.scanUser.findFirst({
where: (u, { eq }) => eq(u.pinHash, req.body.pinNumber),
});
if (existing)
return returnFunc({
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `${req.body.pinNumber} already exists please try again`,
data: [],
notify: false,
room: "",
});
updates.pinNumber = req.body.pinNumber;
updates.pinHash = await hashPin(req.body.pinNumber);
}
if (req.body?.scannerId !== undefined) {
updates.scannerId = req.body.scannerId;
}
if (req.body?.active !== undefined) {
updates.active = req.body.active;
}
if (req.body?.excludedCommand !== undefined) {
updates.excludedCommand = req.body.excludedCommand;
}
if (req.body?.role !== undefined) {
updates.role = req.body.role;
}
updates.upd_date = sql`NOW()`;
const updatedSetting = await db
.update(scanUser)
.set(updates)
.where(eq(scanUser.id, `${id}`))
.returning();
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "user",
message: `User ${data.name} was updated. `,
data: updatedSetting,
status: 200,
});
});
r.delete("/user/:id", requireAuth, async (req, res) => {
const { id } = req.params;
const { data, error } = await tryCatch(
db.delete(scanUser).where(eq(scanUser.id, `${id}`)),
);
if (error) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `There was an error deleting the user`,
data: error as any,
status: 400,
});
}
if (!data) {
return apiReturn(res, {
success: false,
level: "error",
module: "mobile",
subModule: "auth",
message: `There was no user to delete. `,
data: [],
status: 400,
});
}
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "user",
message: `User was deleted. `,
data: data ?? [],
status: 200,
});
});
export default r;

View File

@@ -0,0 +1,21 @@
import { Router } from "express";
import { generateUniquePin } from "../utils/generateScannerPin.utils.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
const r = Router();
r.get("/new", async (_, res) => {
const getPin = await generateUniquePin();
return apiReturn(res, {
success: getPin.success,
level: getPin.level,
module: "mobile",
subModule: "auth",
message: getPin.message,
data: getPin.data,
status: getPin.success ? 200 : 400,
});
});
export default r;

View File

@@ -0,0 +1,37 @@
import { Router } from "express";
import { db } from "../db/db.controller.js";
import { scanLog } from "../db/schema/scanlog.schema.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
const router = Router();
router.post("/", async (req, res) => {
const body = req.body;
const newLog = await db
.insert(scanLog)
.values({
scannerId: body.scannerId,
message: body.message,
prompt: body.prompt,
commandDescription: body.commandDescription,
status: body.status,
lines: body.lines,
user: body.user,
runningNumber: body.runningNumber,
})
.returning();
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "scan logs",
message: `New log from ${body.scannerId}`,
data: newLog,
status: 200,
});
});
export default router;

View File

@@ -0,0 +1,40 @@
import fs from "node:fs";
import { and, eq } from "drizzle-orm";
import { Router } from "express";
import path from "path";
import { db } from "../db/db.controller.js";
import { settings } from "../db/schema/settings.schema.js";
const router = Router();
const projectRoot = path.resolve("./lstMobile"); // adjust as needed
const appJsonPath = path.join(projectRoot, "app.json");
router.get("/", async (req, res) => {
const baseUrl = `${req.protocol}://${req.get("host")}`;
const mobileSettings = await db
.select()
.from(settings)
.where(
and(
eq(settings.moduleName, "mobile"),
eq(settings.settingType, "standard"),
),
);
const raw = fs.readFileSync(appJsonPath, "utf-8");
const config = JSON.parse(raw);
const exp = config.expo;
res.json({
packageName: exp.android?.package,
versionName: exp.version,
versionCode: exp.android?.versionCode,
minSupportedVersionCode: exp?.android?.minSupportedVersionCode ?? 0,
downloadUrl: `${baseUrl}/lst/api/mobile/apk/latest`,
settings: mobileSettings,
});
});
export default router;

View File

@@ -0,0 +1,113 @@
import { eq } from "drizzle-orm";
import { db } from "../db/db.controller.js";
import { notifications } from "../db/schema/notifications.schema.js";
import { prodQuery } from "../prodSql/prodSqlQuery.controller.js";
import {
type SqlQuery,
sqlQuerySelector,
} from "../prodSql/prodSqlQuerySelector.utils.js";
import { returnFunc } from "../utils/returnHelper.utils.js";
import { sendEmail } from "../utils/sendEmail.utils.js";
import { tryCatch } from "../utils/trycatch.utils.js";
/**
*
*/
const func = async (data: any, emails: string) => {
// get the actual notification as items will be updated between intervals if no one touches
const { data: l, error: le } = (await tryCatch(
db.select().from(notifications).where(eq(notifications.id, data.id)),
)) as any;
if (le) {
return returnFunc({
success: false,
level: "error",
module: "notification",
subModule: "query",
message: `${data.name} encountered an error while trying to get initial info`,
data: [le],
notify: true,
});
}
// search the query db for the query by name
const sqlQuery = sqlQuerySelector(`${data.name}`) as SqlQuery;
// create the ignore audit logs ids
const ignoreIds = l[0].options[0]?.auditId
? `${l[0].options[0]?.auditId}`
: "0";
// run the check
const { data: queryRun, error } = await tryCatch(
prodQuery(
sqlQuery.query
.replace("[intervalCheck]", l[0].interval)
.replace("[ignoreList]", ignoreIds),
`Running notification query: ${l[0].name}`,
),
);
if (error) {
return returnFunc({
success: false,
level: "error",
module: "notification",
subModule: "query",
message: `Data for: ${l[0].name} encountered an error while trying to get it`,
data: [error],
notify: true,
});
}
if (queryRun.data.length > 0) {
// update the latest audit id
const { error: dbe } = await tryCatch(
db
.update(notifications)
.set({ options: [{ auditId: `${queryRun.data[0].id}` }] })
.where(eq(notifications.id, data.id)),
);
if (dbe) {
return returnFunc({
success: false,
level: "error",
module: "notification",
subModule: "query",
message: `Data for: ${l[0].name} encountered an error while trying to get it`,
data: [dbe],
notify: true,
});
}
// send the email
const sentEmail = await sendEmail({
email: emails,
subject: "Alert! Label Reprinted",
template: "reprintLabels",
context: {
items: queryRun.data,
},
});
if (!sentEmail?.success) {
return returnFunc({
success: false,
level: "error",
module: "email",
subModule: "notification",
message: `${l[0].name} failed to send the email`,
data: [sentEmail],
notify: true,
});
}
} else {
console.log("doing nothing as there is nothing to do.");
}
// TODO send the error to systemAdmin users so they do not always need to be on the notifications.
// these errors are defined per notification.
};
export default func;

View File

@@ -1,5 +1,6 @@
import type { Express } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import manual from "./notification.manualTrigger.js";
import getNotifications from "./notification.route.js";
import updateNote from "./notification.update.route.js";
@@ -10,13 +11,48 @@ import updateSub from "./notificationSub.update.route.js";
export const setupNotificationRoutes = (baseUrl: string, app: Express) => {
//stats will be like this as we dont need to change this
app.use(`${baseUrl}/api/notification`, requireAuth, getNotifications);
app.use(`${baseUrl}/api/notification`, requireAuth, updateNote);
app.use(`${baseUrl}/api/notification/manual`, requireAuth, manual);
app.use(`${baseUrl}/api/notification/sub`, requireAuth, subs);
app.use(`${baseUrl}/api/notification/sub`, requireAuth, newSub);
app.use(`${baseUrl}/api/notification/sub`, requireAuth, updateSub);
app.use(`${baseUrl}/api/notification/sub`, requireAuth, deleteSub);
app.use(
`${baseUrl}/api/notification`,
requireAuth,
routeHitMiddleware,
getNotifications,
);
app.use(
`${baseUrl}/api/notification`,
requireAuth,
routeHitMiddleware,
updateNote,
);
app.use(
`${baseUrl}/api/notification/manual`,
requireAuth,
routeHitMiddleware,
manual,
);
app.use(
`${baseUrl}/api/notification/sub`,
requireAuth,
routeHitMiddleware,
subs,
);
app.use(
`${baseUrl}/api/notification/sub`,
requireAuth,
routeHitMiddleware,
newSub,
);
app.use(
`${baseUrl}/api/notification/sub`,
requireAuth,
routeHitMiddleware,
updateSub,
);
app.use(
`${baseUrl}/api/notification/sub`,
requireAuth,
routeHitMiddleware,
deleteSub,
);
// all other system should be under /api/system/*
};

View File

@@ -1,6 +1,7 @@
import { type Express, Router } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { featureCheck } from "../middleware/featureActive.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import listener from "./ocp.printer.listener.js";
import update from "./ocp.printer.update.js";
@@ -17,6 +18,8 @@ export const setupOCPRoutes = (baseUrl: string, app: Express) => {
// auth routes below here
router.use(requireAuth);
app.use(routeHitMiddleware);
router.use(update);
//router.use("");

View File

@@ -1,6 +1,7 @@
import { type Express, Router } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { featureCheck } from "../middleware/featureActive.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import getApt from "./opendockGetRelease.route.js";
export const setupOpendockRoutes = (baseUrl: string, app: Express) => {
@@ -13,6 +14,7 @@ export const setupOpendockRoutes = (baseUrl: string, app: Express) => {
// we need to make sure we are authenticated to see the releases
router.use(requireAuth);
app.use(routeHitMiddleware);
router.use(getApt);
app.use(`${baseUrl}/api/opendock`, router);

View File

@@ -1,5 +1,6 @@
import { type Express, Router } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import restart from "./prodSqlRestart.route.js";
import start from "./prodSqlStart.route.js";
import stop from "./prodSqlStop.route.js";
@@ -8,6 +9,7 @@ export const setupProdSqlRoutes = (baseUrl: string, app: Express) => {
// Apply auth to entire router
const router = Router();
router.use(requireAuth);
app.use(routeHitMiddleware);
router.use(start);
router.use(stop);

View File

@@ -5,6 +5,7 @@ import { setupAuthRoutes } from "./auth/auth.routes.js";
import { setupApiDocsRoutes } from "./configs/scaler.config.js";
import { setupDatamartRoutes } from "./datamart/datamart.routes.js";
import { setupGPSqlRoutes } from "./gpSql/gpSql.routes.js";
import { setupMobileRoutes } from "./mobile/mobile.routes.js";
import { setupNotificationRoutes } from "./notification/notification.routes.js";
import { setupOCPRoutes } from "./ocp/ocp.routes.js";
import { setupOpendockRoutes } from "./opendock/opendock.routes.js";
@@ -27,4 +28,5 @@ export const setupRoutes = (baseUrl: string, app: Express) => {
setupNotificationRoutes(baseUrl, app);
setupOCPRoutes(baseUrl, app);
setupTCPRoutes(baseUrl, app);
setupMobileRoutes(baseUrl, app);
};

View File

@@ -18,6 +18,11 @@ import { setupSocketIORoutes } from "./socket.io/serverSetup.js";
import { serversChecks } from "./system/serverData.controller.js";
import { baseSettingValidationCheck } from "./system/settingsBase.controller.js";
import { startTCPServer } from "./tcpServer/tcp.server.js";
import {
aggregateRouteHitsForBusinessDay,
cleanupOldRouteHits,
runRouteHitAnalyticsCron,
} from "./utils/analyticRouteHits.utils.js";
import { createCronJob } from "./utils/croner.utils.js";
import { sendEmail } from "./utils/sendEmail.utils.js";
@@ -68,10 +73,16 @@ const start = async () => {
createCronJob("logsCleanup", "0 15 5 * * *", () => dbCleanup("logs", 120));
historicalSchedule();
createCronJob("aggregateHits", "0 0 7 * * *", async () =>
runRouteHitAnalyticsCron(),
);
createCronJob("cleanHitsUp", "0 0 7 * * *", () => cleanupOldRouteHits());
// one shots only needed to run on server startups
createNotifications();
startNotifications();
serversChecks();
aggregateRouteHitsForBusinessDay();
}, 5 * 1000);
process.on("uncaughtException", async (err) => {

View File

@@ -34,7 +34,7 @@ const servers: NewServerData[] = [
name: "Lima",
server: "USLIM1VMS006",
plantToken: "uslim1",
idAddress: "10.53.0.26",
idAddress: "10.53.0.26", // port opened 3000 2222
greatPlainsPlantCode: "50",
contactEmail: "",
contactPhone: "",
@@ -56,7 +56,7 @@ const servers: NewServerData[] = [
name: "Dayton",
server: "usday1VMS006",
plantToken: "usday1",
idAddress: "10.44.0.56", // 3000 opened and working
idAddress: "10.44.0.56", // ports opened 3000 and 2222
greatPlainsPlantCode: "80",
contactEmail: "",
contactPhone: "",
@@ -122,7 +122,7 @@ const servers: NewServerData[] = [
name: "Marked Tree",
server: "USMAR1VMS006",
plantToken: "usmar1",
idAddress: "10.206.9.26",
idAddress: "10.206.9.26", // 3000,2222 requested REQ0236838
greatPlainsPlantCode: "90",
contactEmail: "",
contactPhone: "",
@@ -144,7 +144,7 @@ const servers: NewServerData[] = [
name: "Bowling Green 1",
server: "USBOW1VMS006",
plantToken: "usbow1",
idAddress: "10.25.0.26", // 3000 is open REQ0236527
idAddress: "10.25.0.26", // 3000 is open REQ0236527 2222 already open
greatPlainsPlantCode: "55",
contactEmail: "",
contactPhone: "",

View File

@@ -76,6 +76,16 @@ const newSettings: NewSetting[] = [
roles: ["admin"],
seedVersion: 1,
},
{
name: "mobile",
value: "0",
active: false,
description: "LST Android Mobile app",
moduleName: "mobile",
settingType: "feature",
roles: ["admin"],
seedVersion: 1,
},
// standard settings
{
@@ -304,6 +314,38 @@ const newSettings: NewSetting[] = [
roles: ["admin"],
seedVersion: 1,
},
{
name: "laneCheck",
value: "0",
active: false,
description:
"Allows the driver to scan a lane and see what is in the lane and details about each pallet.",
moduleName: "mobile",
settingType: "standard",
roles: ["admin"],
seedVersion: 1,
},
{
name: "dockScan",
value: "0",
active: false,
description:
"Enables dock door scanning, must have a dock scanner setup for this to work.",
moduleName: "mobile",
settingType: "standard",
roles: ["admin"],
seedVersion: 1,
},
{
name: "cycleCounting",
value: "0",
active: false,
description: "Enables a cycle count to be triggered from the scanner.",
moduleName: "mobile",
settingType: "standard",
roles: ["admin"],
seedVersion: 1,
},
];
export const baseSettingValidationCheck = async () => {

View File

@@ -1,93 +0,0 @@
import fs from "node:fs";
import { Router } from "express";
import path from "path";
import { fileURLToPath } from "url";
import { db } from "../db/db.controller.js";
import { scanLog } from "../db/schema/scanlog.schema.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
const router = Router();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const downloadDir = path.resolve(__dirname, "../../downloads/mobile");
const projectRoot = path.resolve("./lstMobile"); // adjust as needed
const appJsonPath = path.join(projectRoot, "app.json");
const currentApk = {
fileName: "lst-mobile.apk",
};
router.get("/version", async (req, res) => {
const baseUrl = `${req.protocol}://${req.get("host")}`;
const raw = fs.readFileSync(appJsonPath, "utf-8");
const config = JSON.parse(raw);
const exp = config.expo;
res.json({
packageName: exp.android?.package,
versionName: exp.version,
versionCode: exp.android?.versionCode,
minSupportedVersionCode: exp?.android?.minSupportedVersionCode ?? 0,
downloadUrl: `${baseUrl}/lst/api/mobile/apk/latest`,
});
});
router.get("/apk/latest", (_, res) => {
const apkPath = path.join(downloadDir, currentApk.fileName);
if (!fs.existsSync(apkPath)) {
return res.status(404).json({ success: false, message: "APK not found" });
}
res.setHeader("Content-Type", "application/vnd.android.package-archive");
res.setHeader(
"Content-Disposition",
`attachment; filename="${currentApk.fileName}"`,
);
return res.sendFile(apkPath);
});
router.get("/apk/ehs", (_, res) => {
const apkPath = path.join(downloadDir, "EHS.apk");
if (!fs.existsSync(apkPath)) {
return res.status(404).json({ success: false, message: "APK not found" });
}
res.setHeader("Content-Type", "application/vnd.android.package-archive");
res.setHeader("Content-Disposition", `attachment; filename="EHS.apk}"`);
return res.sendFile(apkPath);
});
router.post("/logs", async (req, res) => {
const body = req.body;
const newLog = await db
.insert(scanLog)
.values({
scannerId: body.data.scannerId,
message: body.data.message,
prompt: body.data.prompt,
commandDescription: body.data.commandDescription,
status: body.data.status,
lines: body.data.lines,
})
.returning();
return apiReturn(res, {
success: true,
level: "info",
module: "mobile",
subModule: "scan logs",
message: `New log from ${body.data.scannerId}`,
data: newLog,
status: 200,
});
});
export default router;

View File

@@ -1,15 +1,15 @@
import type { Express } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import getServers from "./serverData.route.js";
import getSettings from "./settings.route.js";
import updSetting from "./settingsUpdate.route.js";
import stats from "./stats.route.js";
import mobile from "./system.mobileApp.js";
export const setupSystemRoutes = (baseUrl: string, app: Express) => {
//stats will be like this as we dont need to change this
app.use(routeHitMiddleware);
app.use(`${baseUrl}/api/stats`, stats);
app.use(`${baseUrl}/api/mobile`, mobile);
app.use(`${baseUrl}/api/settings`, getSettings);
app.use(`${baseUrl}/api/servers`, getServers);
app.use(`${baseUrl}/api/settings`, requireAuth, updSetting);

View File

@@ -1,14 +1,21 @@
import type { Express } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import restart from "./tcpRestart.route.js";
import start from "./tcpStart.route.js";
import stop from "./tcpStop.route.js";
export const setupTCPRoutes = (baseUrl: string, app: Express) => {
//stats will be like this as we dont need to change this
app.use(`${baseUrl}/api/tcp/start`, requireAuth, start);
app.use(`${baseUrl}/api/tcp/stop`, requireAuth, stop);
app.use(`${baseUrl}/api/tcp/restart`, requireAuth, restart);
app.use(`${baseUrl}/api/tcp/start`, requireAuth, routeHitMiddleware, start);
app.use(`${baseUrl}/api/tcp/stop`, requireAuth, routeHitMiddleware, stop);
app.use(
`${baseUrl}/api/tcp/restart`,
requireAuth,
routeHitMiddleware,
restart,
);
// all other system should be under /api/system/*
};

View File

@@ -0,0 +1,141 @@
import { and, count, countDistinct, gte, lt, sql } from "drizzle-orm";
import { db } from "../db/db.controller.js";
import { analytics } from "../db/schema/analytics.schema.js";
import { analyticsDaily } from "../db/schema/dailyAnalytics.schema.js";
export const ignoredRoutePrefixes = [
"/health",
"/favicon.ico",
"/socket.io",
"/lst/api/ws",
"/lst-config.js",
];
export function shouldIgnoreRoute(path: string) {
return ignoredRoutePrefixes.some((prefix) => path.startsWith(prefix));
}
type CreateRouteHitInput = {
method: string;
routePattern: string;
actualPath: string;
statusCode: number;
durationMs: number;
module?: string | null;
userId?: string | null;
userEmail?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
};
export async function createRouteHit(input: CreateRouteHitInput) {
await db.insert(analytics).values(input);
}
function getPreviousBusinessDayWindow(date = new Date()) {
const end = new Date(date);
end.setHours(7, 0, 0, 0);
const start = new Date(end);
start.setDate(start.getDate() - 1);
const businessDate = start.toISOString().slice(0, 10);
return {
start,
end,
businessDate,
};
}
export async function runRouteHitAnalyticsCron(): Promise<void> {
const result = await aggregateRouteHitsForBusinessDay();
await cleanupOldRouteHits();
console.log("Route hit analytics aggregated", result);
}
export async function aggregateRouteHitsForBusinessDay() {
const { start, end, businessDate } = getPreviousBusinessDayWindow();
const rows = await db
.select({
businessDate: sql<string>`${businessDate}`,
method: analytics.method,
routePattern: analytics.routePattern,
module: sql<string>`COALESCE(${analytics.module}, 'unknown')`,
totalHits: count(),
uniqueUsers: countDistinct(analytics.userId),
successCount: sql<number>`
COUNT(*) FILTER (WHERE ${analytics.statusCode} < 400)
`,
errorCount: sql<number>`
COUNT(*) FILTER (WHERE ${analytics.statusCode} >= 400)
`,
avgDurationMs: sql<number>`
COALESCE(ROUND(AVG(${analytics.durationMs})), 0)
`,
maxDurationMs: sql<number>`
COALESCE(MAX(${analytics.durationMs}), 0)
`,
firstHitAt: sql<Date>`
COALESCE(MIN(${analytics.createdAt}), NOW())
`,
lastHitAt: sql<Date>`
COALESCE(MAX(${analytics.createdAt}), NOW())
`,
})
.from(analytics)
.where(and(gte(analytics.createdAt, start), lt(analytics.createdAt, end)))
.groupBy(
analytics.method,
analytics.routePattern,
sql`COALESCE(${analytics.module}, 'unknown')`,
);
if (rows.length === 0) {
return {
businessDate,
inserted: 0,
};
}
await db
.insert(analyticsDaily)
.values(rows)
.onConflictDoUpdate({
target: [
analyticsDaily.businessDate,
analyticsDaily.method,
analyticsDaily.routePattern,
analyticsDaily.module,
],
set: {
totalHits: sql`excluded.total_hits`,
uniqueUsers: sql`excluded.unique_users`,
successCount: sql`excluded.success_count`,
errorCount: sql`excluded.error_count`,
avgDurationMs: sql`excluded.avg_duration_ms`,
maxDurationMs: sql`excluded.max_duration_ms`,
firstHitAt: sql`excluded.first_hit_at`,
lastHitAt: sql`excluded.last_hit_at`,
updatedAt: sql`now()`,
},
});
return {
businessDate,
inserted: rows.length,
};
}
export async function cleanupOldRouteHits() {
await db
.delete(analytics)
.where(lt(analytics.createdAt, sql`now() - interval '4 days'`));
}

View File

@@ -0,0 +1,39 @@
import { db } from "../db/db.controller.js";
import { returnFunc } from "./returnHelper.utils.js";
export function generateSixDigitPin() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export async function generateUniquePin() {
for (let i = 0; i < 10; i++) {
const pin = generateSixDigitPin();
const existing = await db.query.scanUser.findFirst({
where: (u, { eq }) => eq(u.pinHash, pin), // ⚠️ we'll fix this below
});
if (!existing)
return returnFunc({
success: true,
level: "info",
module: "utils",
subModule: "genPin",
message: "New pin generated",
data: [{ pin: pin }],
notify: false,
room: "",
});
}
return returnFunc({
success: false,
level: "error",
module: "utils",
subModule: "genPin",
message: "Failed to generate unique PIN after 10 attempts",
data: [],
notify: true,
room: "",
});
}

View File

@@ -0,0 +1,61 @@
import { isUmamiEnabled, umamiConfig } from "../configs/umami.config.js";
type TrackLstEventInput = {
eventName: string;
eventData?: Record<string, unknown>;
url?: string;
hostname?: string;
};
export async function trackLstEvent({
eventName,
eventData,
url = "/backend",
hostname = umamiConfig.server,
}: TrackLstEventInput): Promise<void> {
if (!isUmamiEnabled()) return;
try {
await fetch(`${umamiConfig.umamiHost}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "LST-Backend",
},
body: JSON.stringify({
type: "event",
payload: {
website: umamiConfig.umamiWebsiteId,
name: eventName,
url,
hostname,
language: "en-US",
screen: "backend",
data: {
app: umamiConfig.appName,
site: umamiConfig.site,
server: umamiConfig.server,
appVersion: umamiConfig.appVersion,
source: "backend",
...eventData,
},
},
}),
});
} catch (err) {
console.error("Failed to send Umami backend event", err);
}
}
/*
await trackLstEvent({
eventName: "label_print_completed",
url: "/backend/printers",
eventData: {
module: "printers",
printerName,
labelType,
},
});
*/

View File

@@ -1,7 +1,9 @@
import type { Express } from "express";
import { routeHitMiddleware } from "../middleware/routeHit.middleware.js";
import getActiveJobs from "./cronerActiveJobs.route.js";
import jobStatusChange from "./cronerStatusChange.route.js";
export const setupUtilsRoutes = (baseUrl: string, app: Express) => {
app.use(routeHitMiddleware);
app.use(`${baseUrl}/api/utils/croner`, getActiveJobs);
app.use(`${baseUrl}/api/utils/croner`, jobStatusChange);
};

View File

@@ -18,7 +18,7 @@ services:
environment:
- NODE_ENV=production
- LOG_LEVEL=info
- EXTERNAL_URL=http://192.168.8.222:3600
- URL=http://localhost:3600
- DATABASE_HOST=postgres # if running on the same docker then do this
- DATABASE_PORT=5432
- DATABASE_USER=${DATABASE_USER}
@@ -41,7 +41,10 @@ services:
#for all host including prod servers, plc's, printers, or other de
networks:
- docker-network
- pgNetwork
networks:
docker-network:
external: true
pgNetwork:
external: true

View File

@@ -7,7 +7,15 @@
<title>Logistics Support Tool</title>
</head>
<body>
<script>
const configScript = document.createElement("script");
configScript.src = `${window.location.origin}/lst/api/lst-config.js`;
configScript.defer = false;
document.head.appendChild(configScript);
</script>
<div id="root"></div>
<script defer src="https://stats.tuffraid.net/script.js" data-website-id="49bc2489-3930-4358-a13d-1cc609336572"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { Bell, Logs, Server, Settings } from "lucide-react";
import { Bell, Logs, Server, Settings, UsersRound } from "lucide-react";
import {
SidebarGroup,
@@ -56,22 +56,22 @@ export default function AdminSidebar({ session }: any) {
module: "admin",
active: true,
},
// {
// title: "Modules",
// url: "/admin/modules",
// icon: Settings,
// role: ["systemAdmin", "admin"],
// module: "admin",
// active: true,
// },
// {
// title: "Servers",
// url: "/admin/servers",
// icon: Server,
// role: ["systemAdmin", "admin"],
// module: "admin",
// active: true,
// },
{
title: "Users",
url: "/admin/users",
icon: UsersRound,
role: ["systemAdmin", "admin"],
module: "admin",
active: true,
},
{
title: "Scan users",
url: "/admin/scanUsers",
icon: UsersRound,
role: ["systemAdmin", "admin"],
module: "admin",
active: true,
},
];
return (
<SidebarGroup>

View File

@@ -36,6 +36,17 @@ const docs = [
},
],
},
{
title: "Mobile",
url: "/updateInstructions",
isActive: false,
items: [
{
title: "Settings",
url: "/mobile-settings",
},
],
},
];
export default function DocBar() {
const { setOpen } = useSidebar();

View File

@@ -0,0 +1,49 @@
import { Link } from "@tanstack/react-router";
import { ScanText, ScrollText } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "../ui/sidebar";
export default function MobileBar({ session }: any) {
const { setOpen } = useSidebar();
const items = [
{
title: "Update Instructions",
url: "/",
icon: ScrollText,
},
{
title: "Scan Log",
url: "/",
icon: ScanText,
},
];
console.log(session);
return (
<SidebarGroup>
<SidebarGroupLabel>Mobile</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link to={item.url} onClick={() => setOpen(false)}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -8,6 +8,7 @@ import {
import { useSession } from "@/lib/auth-client";
import AdminSidebar from "./AdminBar";
import DocBar from "./DocBar";
import MobileBar from "./MobileBar";
export function AppSidebar() {
const { data: session } = useSession();
@@ -22,7 +23,8 @@ export function AppSidebar() {
<SidebarMenu>
<SidebarMenuItem>
<SidebarContent>
<DocBar/>
<DocBar />
<MobileBar session={session} />
{session &&
(session.user.role === "admin" ||
session.user.role === "systemAdmin") && (

View File

@@ -0,0 +1,3 @@
export default function updateInstructions() {
return <div>updateInstructions</div>;
}

View File

@@ -0,0 +1,25 @@
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import axios from "axios";
export function getScanUsers() {
return queryOptions({
queryKey: ["getScanUsers"],
queryFn: () => fetch(),
staleTime: 5000,
refetchOnWindowFocus: true,
placeholderData: keepPreviousData,
});
}
const fetch = async () => {
if (window.location.hostname === "localhost") {
await new Promise((res) => setTimeout(res, 1500));
}
const { data } = await axios.get("/lst/api/mobile/auth/user", {
withCredentials: true,
timeout: 5000,
});
return data.data;
};

View File

@@ -11,6 +11,15 @@ import {
import React, { useState } from "react";
import { Button } from "../../components/ui/button";
import { ScrollArea, ScrollBar } from "../../components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import {
Table,
TableBody,
@@ -26,15 +35,23 @@ type LstTableType = {
tableClassName?: string;
data: any;
columns: any;
height?: string;
pageSize?: number;
};
export default function LstTable({
className = "",
tableClassName = "",
data,
columns,
height = "h-full",
pageSize = 5,
}: LstTableType) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [pagination, setPagination] = useState({
pageIndex: 0, //initial page index
pageSize: pageSize, //default page size
});
//console.log(data);
const table = useReactTable({
@@ -46,24 +63,33 @@ export default function LstTable({
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onPaginationChange: setPagination,
//renderSubComponent: ({ row }: { row: any }) => <ExpandedRow row={row} />,
//getRowCanExpand: () => true,
// columnResizeMode: "onChange",
filterFns: {},
state: {
sorting,
pagination,
columnFilters,
},
});
return (
<div className={className}>
<ScrollArea className="w-full rounded-md border whitespace-nowrap">
<div>{/* TODO: Add table header in here like title */}</div>
<ScrollArea
className={`w-full rounded-md border whitespace-nowrap ${height}`}
>
<Table className={cn("w-full", tableClassName)}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
<TableHead
key={header.id}
className="sticky top-0 z-20 bg-background"
>
{header.isPlaceholder
? null
: flexRender(
@@ -76,6 +102,7 @@ export default function LstTable({
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
@@ -107,14 +134,23 @@ export default function LstTable({
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
</ScrollArea>
<div className="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => table.firstPage()}
disabled={!table.getCanPreviousPage()}
>
{"<<"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
{"<"}
</Button>
<Button
variant="outline"
@@ -122,8 +158,42 @@ export default function LstTable({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
{">"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.lastPage()}
disabled={!table.getCanNextPage()}
>
{">>"}
</Button>
<Select
value={pagination.pageSize.toString()}
onValueChange={(e) =>
setPagination({
...pagination,
pageSize: e === "all" ? data.length : parseInt(e, 10),
})
}
>
<SelectTrigger className="w-16">
<SelectValue
//id={field.name}
placeholder="Select Page"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Page Size</SelectLabel>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
);

View File

@@ -0,0 +1,65 @@
type RuntimeConfig = {
appName: string;
site: string;
server: string;
appVersion: string;
umamiHost: string;
umamiWebsiteId: string;
};
declare global {
interface Window {
LST_CONFIG?: RuntimeConfig;
umami?: {
track: (eventName: string, eventData?: Record<string, unknown>) => void;
};
}
}
export const runtimeConfig: RuntimeConfig = {
appName: window.LST_CONFIG?.appName ?? "LST",
site: window.LST_CONFIG?.site ?? "unknown",
server: window.LST_CONFIG?.server ?? "unknown",
appVersion: window.LST_CONFIG?.appVersion ?? "dev",
umamiHost: window.LST_CONFIG?.umamiHost ?? "",
umamiWebsiteId: window.LST_CONFIG?.umamiWebsiteId ?? "",
};
export function loadUmami() {
if (!runtimeConfig.umamiHost) return;
if (!runtimeConfig.umamiWebsiteId) return;
if (document.querySelector("script[data-website-id]")) return;
const script = document.createElement("script");
script.defer = true;
script.src = `${runtimeConfig.umamiHost}/script.js`;
script.setAttribute("data-website-id", runtimeConfig.umamiWebsiteId);
document.head.appendChild(script);
}
export function trackLstEvent(
eventName: string,
eventData?: Record<string, unknown>,
) {
window.umami?.track(eventName, {
app: runtimeConfig.appName,
site: runtimeConfig.site,
server: runtimeConfig.server,
appVersion: runtimeConfig.appVersion,
...eventData,
});
}
/*
event type
trackLstEvent("exampleClick", {
module: "example",
action: "test_click",
label: "Example Button",
page: window.location.pathname,
});
*/

View File

@@ -4,6 +4,7 @@ import ReactDOM from "react-dom/client";
import "./index.css";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import socket from "./lib/socket.io";
import { loadUmami } from "./lib/umami.utils";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
@@ -38,6 +39,7 @@ declare module "@tanstack/react-router" {
// Render the app
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
loadUmami();
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>

View File

@@ -15,6 +15,7 @@ import { Route as DocsIndexRouteImport } from './routes/docs/index'
import { Route as DocsSplatRouteImport } from './routes/docs/$'
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
import { Route as AdminServersRouteImport } from './routes/admin/servers'
import { Route as AdminScanUsersRouteImport } from './routes/admin/scanUsers'
import { Route as AdminNotificationsRouteImport } from './routes/admin/notifications'
import { Route as AdminLogsRouteImport } from './routes/admin/logs'
import { Route as authLoginRouteImport } from './routes/(auth)/login'
@@ -52,6 +53,11 @@ const AdminServersRoute = AdminServersRouteImport.update({
path: '/admin/servers',
getParentRoute: () => rootRouteImport,
} as any)
const AdminScanUsersRoute = AdminScanUsersRouteImport.update({
id: '/admin/scanUsers',
path: '/admin/scanUsers',
getParentRoute: () => rootRouteImport,
} as any)
const AdminNotificationsRoute = AdminNotificationsRouteImport.update({
id: '/admin/notifications',
path: '/admin/notifications',
@@ -89,6 +95,7 @@ export interface FileRoutesByFullPath {
'/login': typeof authLoginRoute
'/admin/logs': typeof AdminLogsRoute
'/admin/notifications': typeof AdminNotificationsRoute
'/admin/scanUsers': typeof AdminScanUsersRoute
'/admin/servers': typeof AdminServersRoute
'/admin/settings': typeof AdminSettingsRoute
'/docs/$': typeof DocsSplatRoute
@@ -103,6 +110,7 @@ export interface FileRoutesByTo {
'/login': typeof authLoginRoute
'/admin/logs': typeof AdminLogsRoute
'/admin/notifications': typeof AdminNotificationsRoute
'/admin/scanUsers': typeof AdminScanUsersRoute
'/admin/servers': typeof AdminServersRoute
'/admin/settings': typeof AdminSettingsRoute
'/docs/$': typeof DocsSplatRoute
@@ -118,6 +126,7 @@ export interface FileRoutesById {
'/(auth)/login': typeof authLoginRoute
'/admin/logs': typeof AdminLogsRoute
'/admin/notifications': typeof AdminNotificationsRoute
'/admin/scanUsers': typeof AdminScanUsersRoute
'/admin/servers': typeof AdminServersRoute
'/admin/settings': typeof AdminSettingsRoute
'/docs/$': typeof DocsSplatRoute
@@ -134,6 +143,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin/logs'
| '/admin/notifications'
| '/admin/scanUsers'
| '/admin/servers'
| '/admin/settings'
| '/docs/$'
@@ -148,6 +158,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin/logs'
| '/admin/notifications'
| '/admin/scanUsers'
| '/admin/servers'
| '/admin/settings'
| '/docs/$'
@@ -162,6 +173,7 @@ export interface FileRouteTypes {
| '/(auth)/login'
| '/admin/logs'
| '/admin/notifications'
| '/admin/scanUsers'
| '/admin/servers'
| '/admin/settings'
| '/docs/$'
@@ -177,6 +189,7 @@ export interface RootRouteChildren {
authLoginRoute: typeof authLoginRoute
AdminLogsRoute: typeof AdminLogsRoute
AdminNotificationsRoute: typeof AdminNotificationsRoute
AdminScanUsersRoute: typeof AdminScanUsersRoute
AdminServersRoute: typeof AdminServersRoute
AdminSettingsRoute: typeof AdminSettingsRoute
DocsSplatRoute: typeof DocsSplatRoute
@@ -230,6 +243,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminServersRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/scanUsers': {
id: '/admin/scanUsers'
path: '/admin/scanUsers'
fullPath: '/admin/scanUsers'
preLoaderRoute: typeof AdminScanUsersRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/notifications': {
id: '/admin/notifications'
path: '/admin/notifications'
@@ -281,6 +301,7 @@ const rootRouteChildren: RootRouteChildren = {
authLoginRoute: authLoginRoute,
AdminLogsRoute: AdminLogsRoute,
AdminNotificationsRoute: AdminNotificationsRoute,
AdminScanUsersRoute: AdminScanUsersRoute,
AdminServersRoute: AdminServersRoute,
AdminSettingsRoute: AdminSettingsRoute,
DocsSplatRoute: DocsSplatRoute,

View File

@@ -3,30 +3,36 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "sonner";
import Header from "@/components/Header";
import { AppSidebar } from "@/components/Sidebar/sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { ThemeProvider } from "@/lib/theme-provider";
import { useSession } from "../lib/auth-client";
const RootLayout = () => (
<div className="[--header-height:calc(--spacing(14))]">
<ThemeProvider>
<SidebarProvider className="flex flex-col" defaultOpen={false}>
<Header />
const RootLayout = () => {
const { data: session } = useSession();
return (
<div className="[--header-height:calc(--spacing(14))]">
<ThemeProvider>
<SidebarProvider className="flex flex-col" defaultOpen={false}>
<Header />
<div className="relative min-h-[calc(100svh-var(--header-height))]">
<AppSidebar />
<div className="relative min-h-[calc(100svh-var(--header-height))]">
<AppSidebar />
<main className="w-full p-4">
<div className="mx-auto w-full max-w-7xl">
<Outlet />
</div>
</main>
</div>
<main className="w-full p-4">
<div className="mx-auto w-full max-w-7xl">
<Outlet />
</div>
</main>
</div>
<Toaster expand richColors closeButton />
</SidebarProvider>
</ThemeProvider>
<TanStackRouterDevtools />
</div>
);
<Toaster expand richColors closeButton />
</SidebarProvider>
</ThemeProvider>
{session && session.user.role === "systemAdmin" && (
<TanStackRouterDevtools />
)}
</div>
);
};
export const Route = createRootRoute({ component: RootLayout });

View File

@@ -0,0 +1,16 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { getScanUsers } from "../../lib/queries/getScanUsers";
export const Route = createFileRoute("/admin/scanUsers")({
component: RouteComponent,
});
const ScanUserTable = () => {
const { data } = useSuspenseQuery(getScanUsers());
console.log(data);
return <div>Hello "/admin/scanUsers"!</div>;
};
function RouteComponent() {
return <ScanUserTable />;
}

View File

@@ -155,7 +155,7 @@ const ServerTable = () => {
);
}
return <LstTable data={data} columns={columns} />;
return <LstTable data={data} columns={columns} pageSize={50} />;
};
function RouteComponent() {
@@ -163,7 +163,6 @@ function RouteComponent() {
const columnHelper = createColumnHelper<any>();
console.log(window.location);
const logColumns = [
columnHelper.accessor("timestamp", {
header: ({ column }) => (

View File

@@ -59,6 +59,33 @@ function RouteComponent() {
Only shows machines that are attached to the silo.
</ul>
</ul>
{/* Mobile stuff */}
<li>Mobile App</li>
<ul className="list-disc list-inside indent-8">
<li>Rewrite of Alpla scan</li>
<ul className="list-disc list-inside indent-16">
<li>All old settings same as before id, ip, port</li>
<li>Currently scanned pallets will show now as well</li>
</ul>
<li>
Custom addition - login and more features NOTE: This is activated
based on how you enter the settings
</li>
<ul className="list-disc list-inside indent-16">
<li>Pin numbers login</li>
<li>
Scan a lane barcode and it returns whats in the lane and its
current status
</li>
<li>Command restrictions per pin login</li>
<li>Dock Door scanning</li>
<li>
More details on the pallet that is scanned by touching the running
number on the scanner.
</li>
</ul>
</ul>
{/* TMS integration */}
<li>TMS integration</li>
<ul className="list-disc list-inside indent-8">
<li>integration with TI to auto add in orders</li>

View File

@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import z from "zod";
import { useSession } from "../lib/auth-client";
import { trackLstEvent } from "../lib/umami.utils";
export const Route = createFileRoute("/")({
validateSearch: z.object({
@@ -27,6 +28,16 @@ function Index() {
url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
}
//test tracking
const click = () => {
trackLstEvent("silly_click", {
module: "silly",
action: "click",
label: "rick rolled",
page: window.location.pathname,
});
};
return (
<div className="flex justify-center m-10 flex-col">
<h3 className="w-2xl text-3xl">Welcome Lst - V3</h3>
@@ -43,16 +54,18 @@ function Index() {
<b>
<strong>Click</strong>
</b>
</a>
<a
href={`https://www.youtube.com/watch?v=dQw4w9WgXcQ`}
target="_blank"
rel="noopener"
>
<b>
<strong> Here</strong>
</b>
</a>
</a>{" "}
<button onClick={click}>
<a
href={`https://www.youtube.com/watch?v=dQw4w9WgXcQ`}
target="_blank"
rel="noopener"
>
<b>
<strong> Here</strong>
</b>
</a>
</button>
</p>
</div>
);

View File

@@ -15,8 +15,8 @@
"foregroundImage": "./assets/adaptive-icon-white.png",
"backgroundColor": "#ffffff"
},
"versionCode": 21,
"minSupportedVersionCode": 21,
"versionCode": 32,
"minSupportedVersionCode": 26,
"predictiveBackGestureEnabled": false,
"package": "net.alpla.lst.mobile"
},
@@ -26,7 +26,7 @@
"bundler": "metro"
},
"plugins": [
"./plugins/withZebraScanner",
"./plugins/withZebraDataWedge",
"expo-router",
[
"expo-splash-screen",

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33",
"@rn-primitives/dialog": "^1.4.0",
"@rn-primitives/portal": "^1.4.0",
"@rn-primitives/separator": "^1.4.0",
"@rn-primitives/slot": "^1.4.0",
@@ -56,10 +57,11 @@
"react-dom": "19.2.0",
"react-native": "0.83.4",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "^4.2.1",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-tcp-socket": "^6.4.1",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2",
"socket.io-client": "^4.8.3",

View File

@@ -145,34 +145,32 @@ class ZebraScannerModule(
Thread.sleep(500)
val barcodeConfig = Bundle().apply {
putString("PLUGIN_NAME", "BARCODE")
putString("RESET_CONFIG", "true")
val barcodeConfig = Bundle().apply {
putString("PLUGIN_NAME", "BARCODE")
putString("RESET_CONFIG", "true")
val isLegacyTc8000 =
android.os.Build.MODEL.contains("TC8000", ignoreCase = true)
val props = Bundle().apply {
putString("scanner_input_enabled", "true")
val props = Bundle().apply {
putString("scanner_input_enabled", "true")
// Baseline that should be safe on old and new Zebra devices
putString("scanner_selection", "auto")
if (!isLegacyTc8000) {
// Newer Zebra devices
// Auto-select internal scanner
putString("scanner_selection", "auto")
putString("scanner_selection_by_identifier", "AUTO")
// Hardware trigger behavior
putString("hardware_trigger_enabled", "true")
putString("trigger_mode", "2") // HARD trigger
putString("trigger_mode", "2") // 2 = HARD trigger
// Disable Zebra's loud initial decode feedback
putString("decode_audio_feedback_uri", "")
putString("decode_haptic_feedback", "false")
putString("decode_led_feedback", "false")
}
}
putBundle("PARAM_LIST", props)
}
// add in wake on trigger
putString("trigger_wakeup_scan", "true");
}
putBundle("PARAM_LIST", props)
}
val intentConfig = Bundle().apply {
putString("PLUGIN_NAME", "INTENT")

View File

@@ -1,9 +1,41 @@
import { Tabs } from "expo-router";
import { Home, Settings } from "lucide-react-native";
import { Redirect, Tabs } from "expo-router";
import {
Boxes,
Container,
Home,
Logs,
Rows4,
Settings,
} from "lucide-react-native";
import { useAppStore } from "../../hooks/useAppStore";
import { useMobileAuthStore } from "../../hooks/useMobileAuth";
// const roles = {
// adminOnly: ["admin"],
// management: ["admin", "manager"],
// allStaff: ["admin", "manager", "driver", "lead", "user"],
// };
export default function TabsLayout() {
const serverPort = useAppStore((s) => s.serverPort);
const user = useMobileAuthStore((s) => s.user);
const isUnlocked = useMobileAuthStore((s) => s.isUnlocked);
const port = parseInt(serverPort || "0", 10) >= 50000;
console.log(port);
if (!port) {
if (!user || !isUnlocked) {
return <Redirect href="/login" />;
}
}
const isNormalScanner = parseInt(serverPort || "0", 10) >= 50000;
const hasRole = (allowed: string[] = []) => {
const role = user?.role?.toLowerCase();
return role ? allowed.includes(role) : false;
};
return (
<Tabs
screenOptions={{
@@ -27,11 +59,31 @@ export default function TabsLayout() {
}}
/>
<Tabs.Screen
name="config"
name="ppoo"
options={{
title: "settings",
title: "PPOO",
href: isNormalScanner ? null : "/(tabs)/ppoo",
tabBarIcon: ({ color, size }) => <Boxes size={size} color={color} />,
}}
/>
<Tabs.Screen
name="laneCheck"
options={{
title: "Lane Check",
href: isNormalScanner ? null : "/(tabs)/laneCheck",
tabBarIcon: ({ color, size }) => <Rows4 size={size} color={color} />,
}}
/>
<Tabs.Screen
name="dockScan"
options={{
title: "Dock scan",
href:
isNormalScanner || !hasRole(["admin", "manager"])
? null
: "/(tabs)/dockScan",
tabBarIcon: ({ color, size }) => (
<Settings size={size} color={color} />
<Container size={size} color={color} />
),
}}
/>
@@ -40,7 +92,10 @@ export default function TabsLayout() {
options={{
title: "Logs",
href:
parseInt(serverPort || "0", 10) >= 50000 ? null : "/(tabs)/logs",
isNormalScanner || !hasRole(["admin", "manager"])
? null
: "/(tabs)/logs",
tabBarIcon: ({ color, size }) => <Logs size={size} color={color} />,
}}
/>
{/* <Tabs.Screen
@@ -51,6 +106,15 @@ export default function TabsLayout() {
parseInt(serverPort || "0", 10) >= 50000 ? null : "/(tabs)/logs",
}}
/> */}
<Tabs.Screen
name="config"
options={{
title: "settings",
tabBarIcon: ({ color, size }) => (
<Settings size={size} color={color} />
),
}}
/>
</Tabs>
);
}

View File

@@ -1,7 +1,5 @@
import { Link } from "expo-router";
import { Text, View } from "react-native";
import Setup from "../setup";
export default function SettingsTab() {
return <Setup />
}
return <Setup />;
}

View File

@@ -0,0 +1,26 @@
import React from "react";
import { Text, View } from "react-native";
import { Button } from "../../components/ui/button";
export default function LaneCheck() {
const getInfo = async () => {
const info = "ho";
console.log(info);
};
return (
<View
style={{
flex: 1,
//justifyContent: "center",
alignItems: "center",
marginTop: 50,
}}
>
<Text>Dock Scanning</Text>
<Button onPress={getInfo}>
<Text>Check info</Text>
</Button>
</View>
);
}

View File

@@ -0,0 +1,210 @@
import axios from "axios";
import { format } from "date-fns-tz";
import { useFocusEffect } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Toast from "react-native-toast-message";
import { GlobalFooter } from "../../components/UpdateFooter";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader } from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { useAppStore } from "../../hooks/useAppStore";
import { scannerFeedback } from "../../lib/feedbackScan";
import { type ZebraScanResult, zebraScanner } from "../../lib/ZebraScanner";
const InfoRow = ({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) => {
return (
<View className="flex-row justify-between gap-4 py-2 border-b border-gray-200">
<Text className="text-sm text-gray-500">{label}</Text>
<Text className="text-sm font-medium text-gray-900 text-right flex-1">
{value}
</Text>
</View>
);
};
export default function LaneCheck() {
const [units, setUnits] = useState<any>(null);
const serverIp = useAppStore((s) => s.serverIp);
const handleScan = useCallback(
async (scan: ZebraScanResult) => {
setUnits(null);
await scannerFeedback({
type: "scan",
sound: true,
vibrate: true,
led: true,
});
if (!scan.data.startsWith("loc")) {
Toast.show({
type: "error",
text1: "Scan error",
text2: "The last scan was not a lane please try again",
});
return;
}
try {
const res = await axios.post(
`http://${serverIp.trim()}:3000/lst/api/mobile/lanecheck`,
{
lane: scan.data,
},
{
timeout: 5000,
},
);
if (res.status === 200) {
setUnits(res.data);
Toast.show({
type: "info",
text1: "Lane Data",
text2: "All Loading Units from this lane will be listed below",
});
}
} catch (error) {
console.log(error);
Toast.show({
type: "error",
text1: "Lane Data",
text2: "Error getting lane data please try again",
});
}
},
[serverIp.trim],
);
useFocusEffect(
useCallback(() => {
zebraScanner.startListening();
const sub = zebraScanner.addScanListener((scan) => {
//console.log("SCAN:", scan);
handleScan(scan);
});
return () => {
sub.remove();
zebraScanner.stopListening();
//setUnits(null);
};
}, [handleScan]),
);
return (
<View
style={{
//justifyContent: "center",
alignItems: "center",
marginTop: 50,
}}
>
{units ? (
// <SafeAreaView className={`flex-1 w-full items-center`}>
// <ScrollView className="w-full flex-1">
// <View className="flex items-center gap-2 w-full">
// {units.data?.map((i: any, index: any) => (
// <View key={`${i.runningNumber}-${index}`}>
// <Text>example</Text>
// </View>
// ))}
// </View>
// </ScrollView>
// </SafeAreaView>
<SafeAreaView className={`w-full items-center`}>
<View style={{ padding: 2 }}>
<Text>There Are {units.data.length} units in this lane</Text>
</View>
<ScrollView className="w-full" style={{ marginBottom: 20 }}>
<View>
{units.data.map((i, index) => (
<View
key={`${i.runningNumber}-${index}`}
style={{
justifyContent: "center",
margin: 2,
}}
>
<Dialog>
<DialogTrigger>
<Card
className="w-full"
style={{
borderColor:
i.state === "QualityBlocked" ? "red" : undefined,
borderWidth: 4,
}}
>
<CardContent>
<Text>
{i.articleId} - {i.articleName}
</Text>
<Text>
Running Number: {i.runningNumber ?? "Non barcoded"}
</Text>
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Details for Article {i.articleId}, Rn:
{i.runningNumber ?? "Non barcoded"}{" "}
</DialogTitle>
<DialogDescription>
<InfoRow
label="Production Date"
value={format(i.productionDate, "MM/dd/yyyy HH:mm")}
/>
<InfoRow label="Quantity" value={i.quantity} />
{i.state === "QualityBlocked" && (
<InfoRow
label="Defect"
value={i.mainDefectGroupDescription}
/>
)}
{i.state === "QualityBlocked" && (
<InfoRow
label="Description"
value={i.mainDefectDescription}
/>
)}
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</View>
))}
</View>
</ScrollView>
</SafeAreaView>
) : (
<View className="mt-50">
<Text className="text-2xl text-center">
Please scan a lane to see all Units that are in the lane.
</Text>
</View>
)}
<View>
<GlobalFooter />
</View>
</View>
);
}

View File

@@ -1,13 +1,26 @@
import React from 'react'
import { Text, View } from 'react-native'
import React from "react";
import { Text, View } from "react-native";
import { Button } from "../../components/ui/button";
export default function Logs() {
return (
<View style={{
const getInfo = async () => {
const info = "ho";
console.log(info);
};
return (
<View
style={{
flex: 1,
//justifyContent: "center",
alignItems: "center",
marginTop: 50,
}}><Text>Logs</Text></View>
)
}}
>
<Text>Logs</Text>
<Button onPress={getInfo}>
<Text>Check info</Text>
</Button>
</View>
);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import { Text, View } from "react-native";
import { Button } from "../../components/ui/button";
export default function PPOO() {
return (
<View
style={{
flex: 1,
//justifyContent: "center",
alignItems: "center",
marginTop: 50,
}}
>
<Text>Ppo checks</Text>
</View>
);
}

View File

@@ -1,11 +1,9 @@
import React from "react";
import { View } from "react-native";
import { useAppStore } from "../../hooks/useAppStore";
import ProdScanner from "../../components/ProdScanner";
import { View } from "react-native";
import LSTScanner from "../../components/LSTScanner";
import ProdScanner from "../../components/ProdScanner";
import { useAppStore } from "../../hooks/useAppStore";
export default function scanner() {
export default function Scanner() {
const serverPort = useAppStore((s) => s.serverPort);
return (
<View
@@ -16,7 +14,11 @@ export default function scanner() {
marginTop: 50,
}}
>
{parseInt(serverPort || "0", 10) >= 50000 ? <ProdScanner /> : <LSTScanner />}
{parseInt(serverPort || "0", 10) >= 50000 ? (
<ProdScanner />
) : (
<LSTScanner />
)}
</View>
);
}

View File

@@ -1,28 +1,30 @@
import { PortalHost } from "@rn-primitives/portal";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import "../../global.css";
import { PortalHost } from "@rn-primitives/portal";
import { View } from "react-native";
import { useEffect } from "react";
import Toast from "react-native-toast-message";
import useDeviceLock from "../hooks/useDeviceCheck";
import { zebraScanner } from "../lib/ZebraScanner";
export default function RootLayout() {
useDeviceLock();
useEffect(() => {
zebraScanner.ensureProfile();
}, []);
return (
<>
<StatusBar style="dark" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<View className="items-center">
<Stack.Screen
name="(tabs)"
options={{
title: "Pending update",
headerStyle: {
backgroundColor: "lightblue",
},
}}
/>
</View>
<Stack.Screen name="login" />
<Stack.Screen name="setup" />
<Stack.Screen name="updateScreen" />
<Stack.Screen name="(tabs)" />
</Stack>
<PortalHost />
<Toast />
</>
);
}

View File

@@ -1,122 +1,31 @@
import axios from "axios";
import Constants from "expo-constants";
import { Redirect, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { Redirect } from "expo-router";
import { ActivityIndicator, Text, View } from "react-native";
import { useAppStore } from "../hooks/useAppStore";
import { useServerStore } from "../hooks/useServerCheck";
import { devDelay } from "../lib/devMode";
import { useAppStartup } from "../hooks/useAppStartup";
const startupMessages = {
loading: "Loading app...",
validating: "Validating data...",
scannerMode: "Checking scanner mode...",
normalScanner: "Starting normal ALPLAprod scanner that has no LST rules",
checkingUpdates: "Checking for updates...",
opening: "Opening LST scan app...",
error: "Something went wrong during startup.",
};
export default function Index() {
const router = useRouter();
const [message, setMessage] = useState(<Text>Starting app...</Text>);
const [ready, setReady] = useState(false);
const setServerVersion = useServerStore((s) => s.setServerVersion);
const { ready, startupRoute, status } = useAppStartup();
const hasHydrated = useAppStore((s) => s.hasHydrated);
const serverPort = useAppStore((s) => s.serverPort);
const serverIp = useAppStore((s) => s.serverIp);
const build = Constants.expoConfig?.android?.versionCode ?? 1;
const hasValidSetup = useAppStore((s) => s.hasValidSetup);
useEffect(() => {
if (!hasHydrated) {
setMessage(<Text>Loading app...</Text>);
return;
}
const startup = async () => {
try {
await devDelay(1500);
setMessage(<Text>Validating data...</Text>);
await devDelay(1500);
if (!hasValidSetup()) {
router.replace("/setup");
return;
}
// checking for lst.
console.log(
`http://${serverIp}:${parseInt(serverPort || "0", 10) >= 50000 ? "3000" : serverPort}/lst/api/mobile/version`,
);
try {
const res = await axios.get(
`http://${serverIp}:${parseInt(serverPort || "0", 10) >= 50000 ? "3000" : serverPort}/lst/api/mobile/version`,
{
timeout: 5000,
},
);
console.log(res.data);
// if the build version dose not match the latest server version force update
if (res.status === 200) {
setServerVersion(res.data);
}
// TODO: change the header to show orange and theres a new version
// console.log(build < res.data.minSupportedVersionCode);
// if (build < res.data.minSupportedVersionCode) {
// router.replace("/updateScreen");
// return;
// }
} catch (error) {
console.log("Error: ", error);
}
setMessage(<Text>Checking scanner mode...</Text>);
await devDelay(1500);
if (parseInt(serverPort || "0", 10) >= 50000) {
setMessage(
<Text>
Starting normal alplaprod scanner that has no LST rules
</Text>,
);
await devDelay(1500);
//router.replace("/scanner");
setReady(true);
return;
}
setMessage(<Text>Checking for updates</Text>);
await devDelay(1500);
// TODO if theres an update go to update screen message :D
setMessage(<Text>Opening LST scan app</Text>);
await devDelay(3250);
//router.replace("/scanner");
setReady(true);
} catch (error) {
console.log("Startup error", error);
setMessage(<Text>Something went wrong during startup.</Text>);
}
};
startup();
}, [
hasHydrated,
hasValidSetup,
serverPort,
serverIp,
router,
setServerVersion,
]);
if (ready && startupRoute) {
return <Redirect href={startupRoute as any} />;
}
if (ready) {
return <Redirect href="/(tabs)/scanner" />;
return <Redirect href="/login" />;
}
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
marginTop: 12,
}}
>
{message}
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>{startupMessages[status]}</Text>
<ActivityIndicator size="large" />
</View>
);

View File

@@ -0,0 +1,96 @@
import axios from "axios";
import { useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Button, Text, View } from "react-native";
import Toast from "react-native-toast-message";
import { Input } from "../components/ui/input";
import { useAppStore } from "../hooks/useAppStore";
import { useMobileAuthStore } from "../hooks/useMobileAuth";
const formatName = (name?: string) =>
name ? name.charAt(0).toUpperCase() + name.slice(1).toLowerCase() : "";
export default function Login() {
// doing this causes rerender and sub
//const { setUser } = useMobileAuthStore();
const [pin, setPin] = useState("");
const serverPort = useAppStore((s) => s.serverPort);
const serverIp = useAppStore((s) => s.serverIp);
const router = useRouter();
const onLogin = async () => {
if (pin.length < 6) {
console.log("pin must be min 6 ");
}
console.log(pin);
try {
const res = await axios.post(
`http://${serverIp}:${parseInt(serverPort || "0", 10) >= 50000 ? "3000" : serverPort}/lst/api/mobile/auth/pin`,
{ pin },
{
timeout: 5000,
},
);
if (res.status === 200) {
// this way to set the user is direct and basically a 1 shot
Toast.show({
type: "success",
text1: `Welcome back ${formatName(res.data.data.name)}`,
});
useMobileAuthStore.getState().setUser(res.data.data);
return router.replace("/(tabs)/scanner");
}
} catch (error) {
console.log(error);
//Alert.alert("Login Error", `Invalid pin please try again`);
Toast.show({ type: "error", text1: `Invalid pin please try again` });
}
};
const config = () => {
console.log("config");
return router.replace("/setup");
};
return (
<View
style={{
flex: 1,
//justifyContent: "center",
alignItems: "center",
marginTop: 50,
}}
>
<View className="flex items-center m-5">
<Text style={{ fontSize: 20, fontWeight: "600" }}>
LST Scanner Login
</Text>
<View className="w-64 p-4">
<Input
className="w-fit"
keyboardType="number-pad"
textContentType="oneTimeCode"
placeholder="Pin number"
onChangeText={setPin}
/>
</View>
</View>
<View>
<Text className="p-3">
Warning: If you are logged into another scanner you will encounter
scan errors, please do not try to log into more than 1 scanner at a
time.
</Text>
</View>
<View className="flex gap-2 flex-row">
<Button title="Login" onPress={onLogin} />
<Button title="Config" onPress={config} />
</View>
</View>
);
}

View File

@@ -2,6 +2,7 @@ import Constants from "expo-constants";
import { useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Button, Text, TextInput, View } from "react-native";
import Toast from "react-native-toast-message";
import { useAppStore } from "../hooks/useAppStore";
import { useServerStore } from "../hooks/useServerCheck";
@@ -25,18 +26,29 @@ export default function Setup() {
const server = useServerStore((s) => s.serverVersion);
// TODO: if on lst version and the user is manager or admin just login
const authCheck = () => {
if (pin === "6971") {
setAuth(true);
} else {
Alert.alert("Incorrect pin entered please try again");
//Alert.alert("Incorrect pin entered please try again");
Toast.show({
type: "error",
text1: "Incorrect pin entered please try again",
});
setPin("");
}
};
const handleSave = async () => {
if (!serverIp.trim() || !serverPort.trim()) {
Alert.alert("Missing info", "Please fill in both fields.");
//Alert.alert("Missing info", "Please fill in both fields.");
Toast.show({
type: "error",
text1: "Missing info",
text2: "Please fill in both fields.",
});
return;
}
@@ -48,7 +60,12 @@ export default function Setup() {
isRegistered: true,
});
Alert.alert("Saved", "Config saved to device.");
//Alert.alert("Saved", "Config saved to device.");
Toast.show({
type: "info",
text1: "Saved",
text2: "Config saved to device.",
});
//router.replace("/");
};
return (
@@ -151,13 +168,13 @@ export default function Setup() {
marginTop: "auto",
alignItems: "center",
padding: 10,
marginBottom: 12,
marginBottom: 50,
}}
>
<Text className="text-[12] color-#666">
<Text className="text-sm color-[#312f2f]">
App v{version}-{build}
</Text>
<Text className="text-[12] color-#666">
<Text className="text-sm color-[#312f2f]">
Server version - v{server?.versionName}-{server?.versionCode}
</Text>
</View>

View File

@@ -1,24 +1,240 @@
import React from 'react'
import { View, Text } from 'react-native'
import axios from "axios";
import { format } from "date-fns-tz";
import { useFocusEffect } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Text, View } from "react-native";
import { useAppStore } from "../hooks/useAppStore";
import { useMobileAuthStore } from "../hooks/useMobileAuth";
import { useScannerStore } from "../hooks/useScannerStore";
import { scannerFeedback } from "../lib/feedbackScan";
import { sendTcpMessage } from "../lib/tcpScan";
import { versionCheck } from "../lib/versionValidation";
import { type ZebraScanResult, zebraScanner } from "../lib/ZebraScanner";
import { ScannedLabelBox } from "./ScannedLabels";
import { GlobalFooter } from "./UpdateFooter";
import { Separator } from "./ui/separator";
const STX = "\x02";
const ETX = "\x03";
const formatName = (name?: string) =>
name ? name.charAt(0).toUpperCase() + name.slice(1).toLowerCase() : "";
export default function LSTScanner() {
return (
<View><View style={{ alignItems: "center", margin: 10 }}>
<Text style={{ fontSize: 20, fontWeight: "600" }}>LST Scanner</Text>
</View>
<View
style={{
marginTop: 50,
alignItems: "center",
}}
>
<Text>Relocate</Text>
<Text>0 / 4</Text>
</View>
{/* <View>
<Text>List of recent scanned pallets TBA</Text>
</View> */}
</View>
)
const user = useMobileAuthStore((s) => s.user);
const logout = useMobileAuthStore((s) => s.logout);
// TODO : move to off tcp stuff after od
const lastScan = useScannerStore((s) => s.lastScan);
const setLastScan = useScannerStore((s) => s.setLastScan);
const [tagScans, setTagScans] = useState<any>([]);
const scannerIdFromStore = useAppStore((s) => s.scannerId);
const serverIp = useAppStore((s) => s.serverIp);
const serverPort = useAppStore((s) => s.serverPort);
const [bgColor, setBGColor] = useState<string | null>(null);
const handleScan = useCallback(
async (scan: ZebraScanResult) => {
await scannerFeedback({
type: "scan",
sound: true,
vibrate: true,
led: true,
});
const isAlphaStart = /^[a-zA-Z]/.test(scan.data);
const isExcluded = (user?.excludedCommand ?? []).some((cmd) =>
scan.data.toLowerCase().includes(cmd.toLowerCase()),
);
console.log(user?.excludedCommand);
if (isAlphaStart && isExcluded) {
Alert.alert(
"Command not allowed",
`Command: ${scan.data}\n\nPlease contact logistics if this is an error`,
);
return;
}
let commandToSend = `${STX}${user?.scannerId}@${scan.data}${ETX}`;
// if we are sscc we need to scan like this .... <STX>98@]C100090087710038712256<ETX>
if (scan.data.startsWith("000")) {
commandToSend = `${STX}${user?.scannerId}@]C1${scan.data}${ETX}`;
setTagScans((prev: any) => [
{
label: parseInt(scan.data.slice(10, -1) || "000", 10).toString(),
date: format(new Date(Date.now()), "HH:mm"),
},
...prev,
]);
}
const scanned = (await sendTcpMessage(
commandToSend,
serverIp,
50004,
)) as any;
// send the logs to lst but allow it to time out if it dose not exist just bc.
try {
await axios.post(`http://${serverIp.trim()}:3000/lst/api/mobile/logs`, {
scannerId: user?.scannerId ?? "0",
message: scanned.data.message,
prompt: scanned.data.prompt,
commandDescription: scanned.data.commandDescription,
status: scanned.data.status,
lines: scanned.data.lines,
user: user?.name ?? "prodScan",
runningNumber: scan.data.startsWith("000")
? parseInt(scan.data.slice(10, -1) || "000", 10).toString()
: scan.data.startsWith("loc")
? scan.data
: "0",
});
} catch (error) {
console.log(error);
}
// const response = await sendTcpMessage(tcpMessage);
console.log(scanned.data);
if (scanned.data.status !== "error") {
await scannerFeedback({
type: "good",
sound: true,
vibrate: true,
led: true,
});
setBGColor("bg-green-500");
// version check
versionCheck();
// auth update
useMobileAuthStore.getState().updateLastScan();
setTimeout(() => {
setBGColor(null);
}, 1 * 1000);
}
if (scanned.data.status === "error") {
await scannerFeedback({
type: scanned.data.status === "error" ? "bad" : "good",
sound: true,
vibrate: true,
led: true,
});
setBGColor("bg-red-500");
setTimeout(() => {
setBGColor(null);
}, 1 * 1000);
}
setLastScan(scanned.data);
// if we change commands we want to zero out the last scanned labels
if (isAlphaStart) {
setTagScans([]);
}
},
[
serverIp,
setLastScan,
user?.scannerId,
user?.name,
user?.excludedCommand?.some,
user?.excludedCommand,
],
);
const clearScans = () => {
setTagScans([]);
};
//console.log(lastScan);
useFocusEffect(
useCallback(() => {
zebraScanner.startListening();
const sub = zebraScanner.addScanListener((scan) => {
//console.log("SCAN:", scan);
handleScan(scan);
});
return () => {
sub.remove();
zebraScanner.stopListening();
};
}, [handleScan]),
);
return (
<View className={`${bgColor ?? ""} flex-1 w-screen`}>
<View style={{ alignItems: "center", margin: 5 }}>
<Text style={{ fontSize: 14, fontWeight: "600" }}>
User: {formatName(user?.name ?? "")}
</Text>
<Text style={{ fontSize: 18, fontWeight: "600" }}>
LST Scanner id: {user?.scannerId}
</Text>
<View
style={{
marginTop: 5,
alignItems: "center",
}}
>
{!lastScan ? (
<View style={{ marginTop: 10, alignItems: "center" }}>
<Text className="text-xl font-bold">Ready to scan</Text>
<Text>Waiting for first scan...</Text>
</View>
) : (
<View
style={{
marginTop: 10,
alignItems: "center",
}}
>
{lastScan.lines
?.filter((line) => !/^\d+@$/.test(line))
.map((i) => {
return (
<View
style={{ marginTop: 10, alignItems: "center" }}
key={i}
>
<Text style={{ fontSize: 18, fontWeight: "600" }}>
{i}
</Text>
</View>
);
})}
</View>
)}
</View>
</View>
<Separator className="m-2" />
<View className="flex-1 w-full px-4">
<ScannedLabelBox
labels={tagScans}
color={bgColor}
clearScan={clearScans}
/>
</View>
<View className="m-2">
{user && (
<View className="items-center">
<Button title="Logout" onPress={logout} />
</View>
)}
</View>
<View>
<GlobalFooter />
</View>
</View>
);
}

View File

@@ -1,11 +1,14 @@
import axios from "axios";
import { format } from "date-fns-tz";
import { useFocusEffect } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useAppStore } from "../hooks/useAppStore";
import { useMobileAuthStore } from "../hooks/useMobileAuth";
import { useScannerStore } from "../hooks/useScannerStore";
import { scannerFeedback } from "../lib/feedbackScan";
import { sendTcpMessage } from "../lib/tcpScan";
import { versionCheck } from "../lib/versionValidation";
import { type ZebraScanResult, zebraScanner } from "../lib/ZebraScanner";
import { ScannedLabelBox } from "./ScannedLabels";
import { GlobalFooter } from "./UpdateFooter";
@@ -25,6 +28,13 @@ export default function ProdScanner() {
const handleScan = useCallback(
async (scan: ZebraScanResult) => {
await scannerFeedback({
type: "scan",
sound: true,
vibrate: true,
led: true,
});
let commandToSend = `${STX}${scannerIdFromStore}@${scan.data}${ETX}`;
// if we are sscc we need to scan like this .... <STX>98@]C100090087710038712256<ETX>
@@ -45,11 +55,18 @@ export default function ProdScanner() {
parseInt(serverPort || "0", 10),
)) as any;
// send the logs to lst but allow it to time out if it dose not exist just bc.
const data = {
...scanned.data,
runningNumber: scan.data.startsWith("000")
? parseInt(scan.data.slice(10, -1) || "000", 10).toString()
: scan.data.startsWith("loc")
? scan.data
: "0",
};
try {
await axios.post(
`http://${serverIp.trim()}:3000/lst/api/mobile/logs`,
scanned,
data,
);
} catch (error) {
console.log(error);
@@ -64,6 +81,13 @@ export default function ProdScanner() {
led: true,
});
setBGColor("bg-green-500");
// version check
versionCheck();
// auth update
useMobileAuthStore.getState().updateLastScan();
setTimeout(() => {
setBGColor(null);
}, 1 * 1000);
@@ -97,20 +121,21 @@ export default function ProdScanner() {
//console.log(lastScan);
useEffect(() => {
zebraScanner.ensureProfile();
zebraScanner.startListening();
useFocusEffect(
useCallback(() => {
zebraScanner.startListening();
const sub = zebraScanner.addScanListener((scan) => {
//console.log("SCAN:", scan);
handleScan(scan);
});
const sub = zebraScanner.addScanListener((scan) => {
//console.log("SCAN:", scan);
handleScan(scan);
});
return () => {
sub.remove();
zebraScanner.stopListening();
};
}, [handleScan]);
return () => {
sub.remove();
zebraScanner.stopListening();
};
}, [handleScan]),
);
return (
<View className={`${bgColor ?? ""} flex-1 w-screen`}>
<View>
@@ -137,7 +162,7 @@ export default function ProdScanner() {
.map((i) => {
return (
<View style={{ marginTop: 10, alignItems: "center" }} key={i}>
<Text style={{ fontSize: 20, fontWeight: "600" }}>{i}</Text>
<Text style={{ fontSize: 18, fontWeight: "600" }}>{i}</Text>
</View>
);
})}

View File

@@ -28,7 +28,7 @@ export function GlobalFooter() {
{!hasUpdate && shouldUpdate && (
<View className="bg-[#FDBA74]">
<Link href={"/updateScreen"}>
<Text className="h-[32] font-medium text-lg text-wrap text-center">
<Text className="h-[16] font-medium text-base text-wrap text-center">
There is an update click me for instructions
</Text>
</Link>

View File

@@ -0,0 +1,140 @@
import { Icon } from '@/components/ui/icon';
import { NativeOnlyAnimatedView } from '@/components/ui/native-only-animated-view';
import { cn } from '@/lib/utils';
import * as DialogPrimitive from '@rn-primitives/dialog';
import { X } from 'lucide-react-native';
import * as React from 'react';
import { Platform, Text, View, type ViewProps } from 'react-native';
import { FadeIn, FadeOut } from 'react-native-reanimated';
import { FullWindowOverlay as RNFullWindowOverlay } from 'react-native-screens';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const FullWindowOverlay = Platform.OS === 'ios' ? RNFullWindowOverlay : React.Fragment;
function DialogOverlay({
className,
children,
...props
}: Omit<React.ComponentProps<typeof DialogPrimitive.Overlay>, 'asChild'> & {
children?: React.ReactNode;
}) {
return (
<FullWindowOverlay>
<DialogPrimitive.Overlay
className={cn(
'absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center bg-black/50 p-2',
Platform.select({
web: 'animate-in fade-in-0 fixed cursor-default [&>*]:cursor-auto',
}),
className
)}
{...props}
asChild={Platform.OS !== 'web'}>
<NativeOnlyAnimatedView entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
<NativeOnlyAnimatedView entering={FadeIn.delay(50)} exiting={FadeOut.duration(150)}>
<>{children}</>
</NativeOnlyAnimatedView>
</NativeOnlyAnimatedView>
</DialogPrimitive.Overlay>
</FullWindowOverlay>
);
}
function DialogContent({
className,
portalHost,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
portalHost?: string;
}) {
return (
<DialogPortal hostName={portalHost}>
<DialogOverlay>
<DialogPrimitive.Content
className={cn(
'bg-background border-border z-50 mx-auto flex w-full max-w-[calc(100%-2rem)] flex-col gap-4 rounded-lg border p-6 shadow-lg shadow-black/5 sm:max-w-lg',
Platform.select({
web: 'animate-in fade-in-0 zoom-in-95 duration-200',
}),
className
)}
{...props}>
<>{children}</>
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 rounded opacity-70 active:opacity-100',
Platform.select({
web: 'ring-offset-background focus:ring-ring data-[state=open]:bg-accent transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2',
})
)}
hitSlop={12}>
<Icon
as={X}
className={cn('text-accent-foreground web:pointer-events-none size-4 shrink-0')}
/>
<Text className="sr-only">Close</Text>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogOverlay>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: ViewProps) {
return (
<View className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />
);
}
function DialogFooter({ className, ...props }: ViewProps) {
return (
<View
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn('text-foreground text-lg font-semibold leading-none', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,57 @@
import { TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import type { LucideIcon, LucideProps } from 'lucide-react-native';
import { cssInterop } from 'nativewind';
import * as React from 'react';
type IconProps = LucideProps & {
as: LucideIcon;
} & React.RefAttributes<LucideIcon>;
function IconImpl({ as: IconComponent, ...props }: IconProps) {
return <IconComponent {...props} />;
}
cssInterop(IconImpl, {
className: {
target: 'style',
nativeStyleToProp: {
height: 'size',
width: 'size',
},
},
});
/**
* A wrapper component for Lucide icons with Nativewind `className` support via `cssInterop`.
*
* This component allows you to render any Lucide icon while applying utility classes
* using `nativewind`. It avoids the need to wrap or configure each icon individually.
*
* @component
* @example
* ```tsx
* import { ArrowRight } from 'lucide-react-native';
* import { Icon } from '@/registry/components/ui/icon';
*
* <Icon as={ArrowRight} className="text-red-500" size={16} />
* ```
*
* @param {LucideIcon} as - The Lucide icon component to render.
* @param {string} className - Utility classes to style the icon using Nativewind.
* @param {number} size - Icon size (defaults to 14).
* @param {...LucideProps} ...props - Additional Lucide icon props passed to the "as" icon.
*/
function Icon({ as: IconComponent, className, size = 14, ...props }: IconProps) {
const textClass = React.useContext(TextClassContext);
return (
<IconImpl
as={IconComponent}
className={cn('text-foreground', textClass, className)}
size={size}
{...props}
/>
);
}
export { Icon };

View File

@@ -0,0 +1,29 @@
import { cn } from '@/lib/utils';
import { Platform, TextInput } from 'react-native';
function Input({ className, ...props }: React.ComponentProps<typeof TextInput> & React.RefAttributes<TextInput>) {
return (
<TextInput
className={cn(
'dark:bg-input/30 border-input bg-background text-foreground flex h-10 w-full min-w-0 flex-row items-center rounded-md border px-3 py-1 text-base leading-5 shadow-sm shadow-black/5 sm:h-9',
props.editable === false &&
cn(
'opacity-50',
Platform.select({ web: 'disabled:pointer-events-none disabled:cursor-not-allowed' })
),
Platform.select({
web: cn(
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground outline-none transition-[color,box-shadow] md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive'
),
native: 'placeholder:text-muted-foreground/50',
}),
className
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,23 @@
import { Platform } from 'react-native';
import Animated from 'react-native-reanimated';
/**
* This component is used to wrap animated views that should only be animated on native.
* @param props - The props for the animated view.
* @returns The animated view if the platform is native, otherwise the children.
* @example
* <NativeOnlyAnimatedView entering={FadeIn} exiting={FadeOut}>
* <Text>I am only animated on native</Text>
* </NativeOnlyAnimatedView>
*/
function NativeOnlyAnimatedView(
props: React.ComponentProps<typeof Animated.View> & React.RefAttributes<typeof Animated.View>
) {
if (Platform.OS === 'web') {
return <>{props.children as React.ReactNode}</>;
} else {
return <Animated.View {...props} />;
}
}
export { NativeOnlyAnimatedView };

View File

@@ -1,5 +1,5 @@
import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { Slot } from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Platform, Text as RNText, type Role } from 'react-native';
@@ -70,11 +70,12 @@ function Text({
variant = 'default',
...props
}: React.ComponentProps<typeof RNText> &
React.RefAttributes<typeof RNText> &
TextVariantProps & {
asChild?: boolean;
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
const Component = asChild ? Slot : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}

View File

@@ -0,0 +1,111 @@
import axios from "axios";
import Constants from "expo-constants";
import { useEffect, useRef, useState } from "react";
import { devDelay } from "../lib/devMode";
import { versionCheck } from "../lib/versionValidation";
import { useAppStore } from "./useAppStore";
import { useServerStore } from "./useServerCheck";
type StartupStatus =
| "loading"
| "validating"
| "scannerMode"
| "normalScanner"
| "checkingUpdates"
| "opening"
| "error";
export function useAppStartup() {
const [ready, setReady] = useState(false);
const [status, setStatus] = useState<StartupStatus>("loading");
const [startupRoute, setStartupRoute] = useState<string | null>(null);
const hasRunKey = useRef<string | null>(null);
const hasHydrated = useAppStore((s) => s.hasHydrated);
const serverPort = useAppStore((s) => s.serverPort);
const serverIp = useAppStore((s) => s.serverIp);
useEffect(() => {
if (!hasHydrated) {
setStatus("loading");
return;
}
const runKey = `${serverIp}:${serverPort}`;
if (hasRunKey.current === runKey) {
return;
}
hasRunKey.current = runKey;
let cancelled = false;
const startup = async () => {
try {
setReady(false);
setStartupRoute(null);
await devDelay(1500);
if (cancelled) return;
setStatus("validating");
await devDelay(1500);
if (cancelled) return;
const hasValidSetup = useAppStore.getState().hasValidSetup;
if (!hasValidSetup()) {
setStartupRoute("/setup");
setReady(true);
return;
}
await versionCheck();
setStatus("scannerMode");
await devDelay(1500);
if (cancelled) return;
if (parseInt(serverPort || "0", 10) >= 50000) {
setStatus("normalScanner");
await devDelay(1500);
setStartupRoute("/scanner");
setReady(true);
return;
}
setStatus("checkingUpdates");
console.log("checking updates");
await devDelay(1500);
if (cancelled) return;
setStatus("opening");
console.log("opening");
await devDelay(1500);
if (cancelled) return;
setStartupRoute("/(tabs)/scanner");
console.log("scanner");
setReady(true);
} catch (error) {
console.log("Startup error:", error);
setStatus("error");
}
};
startup();
return () => {
cancelled = true;
};
}, [hasHydrated, serverIp, serverPort]);
return {
ready,
startupRoute,
status,
};
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useMobileAuthStore } from "./useMobileAuth";
export default function useDeviceLock() {
const [appState, setAppState] = useState<AppStateStatus>(
AppState.currentState,
);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
const previousAppState = appStateRef.current;
const wasActive = previousAppState === "active";
// if the we see aggressive locking then we should remove inactive.
const isNowInactive =
nextAppState === "background" || nextAppState === "inactive";
if (wasActive && isNowInactive) {
const auth = useMobileAuthStore.getState();
if (auth.shouldLockForIdle()) {
auth.lock();
}
}
appStateRef.current = nextAppState;
setAppState(nextAppState);
});
return () => subscription.remove();
}, []);
return appState;
}

View File

@@ -0,0 +1,52 @@
import { create } from "zustand";
const ONE_HOUR = 1000 * 60 * 60;
type MobileUser = {
id: string;
name: string;
role: "user" | "lead" | "manager" | "admin";
excludedCommand: string[];
scannerId: string;
};
type AuthState = {
user: MobileUser | null;
isUnlocked: boolean;
lastScanAt: number | null;
setUser: (user: MobileUser) => void;
updateLastScan: () => void;
lock: () => void;
logout: () => void;
shouldLockForIdle: () => boolean;
};
export const useMobileAuthStore = create<AuthState>((set, get) => ({
user: null,
isUnlocked: false,
lastScanAt: null,
setUser: (user) =>
set({
user,
isUnlocked: true,
lastScanAt: Date.now(),
}),
updateLastScan: () => set({ lastScanAt: Date.now() }),
lock: () => set({ isUnlocked: false }),
logout: () =>
set({
user: null,
isUnlocked: false,
lastScanAt: null,
}),
shouldLockForIdle: () => {
const lastScanAt = get().lastScanAt;
if (!lastScanAt) return true;
return Date.now() - lastScanAt > ONE_HOUR;
},
}));

View File

@@ -28,13 +28,16 @@ export const zebraScanner = {
ZebraScanner.triggerScan();
},
ensureProfile() {
ZebraScanner.ensureProfile();
},
ensureProfile() {
ZebraScanner.ensureProfile();
},
addScanListener(
callback: (scan: ZebraScanResult) => void,
): EmitterSubscription {
return scannerEmitter.addListener("barcodeScanned", callback);
},
disableScannerInput() {
ZebraScanner.disableScannerInput();
},
};

View File

@@ -0,0 +1,13 @@
const roleRank = {
user: 1,
lead: 2,
manager: 3,
admin: 4,
} as const;
export function hasMobileRole(
userRole: keyof typeof roleRank,
requiredRole: keyof typeof roleRank,
) {
return roleRank[userRole] >= roleRank[requiredRole];
}

View File

@@ -2,12 +2,13 @@ import { createAudioPlayer } from "expo-audio";
import * as Haptics from "expo-haptics";
export type ScanFeedback = {
type: "good" | "bad";
type: "good" | "bad" | "scan";
sound?: boolean;
vibrate?: boolean;
led?: boolean;
};
const scan = createAudioPlayer(require("../../assets/sounds/scan.wav"));
const goodSound = createAudioPlayer(require("../../assets/sounds/good.wav"));
const badSound = createAudioPlayer(require("../../assets/sounds/bad.wav"));
@@ -18,14 +19,15 @@ export async function scannerFeedback({
led = true,
}: ScanFeedback) {
if (sound) {
const player = type === "good" ? goodSound : badSound;
const player =
type === "scan" ? scan : type === "good" ? goodSound : badSound;
player.seekTo(0);
player.play();
}
if (vibrate) {
await Haptics.notificationAsync(
type === "good"
type === "good" || type === "scan"
? Haptics.NotificationFeedbackType.Success
: Haptics.NotificationFeedbackType.Error,
);

View File

@@ -1,43 +1,73 @@
import axios from "axios";
import { useAppStore } from "../hooks/useAppStore";
import { useServerStore } from "../hooks/useServerCheck";
export type ServerVersionInfo = {
packageName: string;
versionName: string;
versionCode: number;
minSupportedVersionCode: number;
fileName: string;
packageName: string;
versionName: string;
versionCode: number;
minSupportedVersionCode: number;
fileName: string;
};
export type StartupStatus =
| { state: "checking" }
| { state: "needs-config" }
| { state: "offline" }
| { state: "blocked"; reason: string; server: ServerVersionInfo }
| { state: "warning"; message: string; server: ServerVersionInfo }
| { state: "ready"; server: ServerVersionInfo | null };
| { state: "checking" }
| { state: "needs-config" }
| { state: "offline" }
| { state: "blocked"; reason: string; server: ServerVersionInfo }
| { state: "warning"; message: string; server: ServerVersionInfo }
| { state: "ready"; server: ServerVersionInfo | null };
export function evaluateVersion(
appBuildCode: number,
server: ServerVersionInfo
appBuildCode: number,
server: ServerVersionInfo,
): StartupStatus {
if (appBuildCode < server.minSupportedVersionCode) {
return {
state: "blocked",
reason: "This scanner app is too old and must be updated before use.",
server,
};
}
if (appBuildCode < server.minSupportedVersionCode) {
return {
state: "blocked",
reason: "This scanner app is too old and must be updated before use.",
server,
};
}
if (appBuildCode !== server.versionCode) {
return {
state: "warning",
message: `A newer version is available. Installed build: ${appBuildCode}, latest build: ${server.versionCode}.`,
server,
};
}
if (appBuildCode !== server.versionCode) {
return {
state: "warning",
message: `A newer version is available. Installed build: ${appBuildCode}, latest build: ${server.versionCode}.`,
server,
};
}
return {
state: "ready",
server,
};
}
return {
state: "ready",
server,
};
}
export const versionCheck = async () => {
const { setServerVersion } = useServerStore.getState();
const { serverPort, serverIp } = useAppStore.getState();
const port = parseInt(serverPort || "0", 10) >= 50000 ? "3000" : serverPort;
try {
const res = await axios.get(
`http://${serverIp}:${port}/lst/api/mobile/version`,
{ timeout: 5000 },
);
if (res.status === 200) {
setServerVersion(res.data);
}
// const build = Constants.expoConfig?.android?.versionCode ?? 1;
// if (build < res.data.minSupportedVersionCode) {
// setStartupRoute("/updateScreen");
// setReady(true);
// return;
// }
} catch (error) {
console.log("Version check error:", error);
}
};

View File

@@ -0,0 +1,14 @@
CREATE TYPE "public"."mobile_role" AS ENUM('user', 'lead', 'manager', 'admin');--> statement-breakpoint
CREATE TABLE "scan_users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"scanner_id" integer NOT NULL,
"pin_number" integer NOT NULL,
"pin_hash" text NOT NULL,
"excluded_commands" text DEFAULT '',
"role" "mobile_role" DEFAULT 'user' NOT NULL,
"active" boolean DEFAULT true,
"last_scan" timestamp DEFAULT now(),
"add_Date" timestamp DEFAULT now(),
"upd_date" timestamp DEFAULT now()
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "scan_users" ADD CONSTRAINT "scan_users_scanner_id_unique" UNIQUE("scanner_id");--> statement-breakpoint
ALTER TABLE "scan_users" ADD CONSTRAINT "scan_users_pin_number_unique" UNIQUE("pin_number");--> statement-breakpoint
ALTER TABLE "scan_users" ADD CONSTRAINT "scan_user_unique" UNIQUE("scanner_id","pin_number");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "scan_users" ALTER COLUMN "scanner_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "scan_users" ALTER COLUMN "pin_number" SET DATA TYPE text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "scan_users" ALTER COLUMN "excluded_commands" SET DATA TYPE jsonb;--> statement-breakpoint
ALTER TABLE "scan_users" ALTER COLUMN "excluded_commands" SET DEFAULT '';--> statement-breakpoint
ALTER TABLE "scan_log" ADD COLUMN "user" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "scan_users" ALTER COLUMN "excluded_commands" SET DEFAULT '[]'::jsonb;

View File

@@ -0,0 +1 @@
ALTER TABLE "scan_log" ADD COLUMN "running_number" text DEFAULT '0';

View File

@@ -0,0 +1,14 @@
CREATE TABLE "analytics" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"method" text NOT NULL,
"route_pattern" text NOT NULL,
"actual_path" text NOT NULL,
"status_code" integer NOT NULL,
"duration_ms" integer NOT NULL,
"module" text,
"user_id" text,
"user_email" text,
"ip_address" text,
"user_agent" text
);

View File

@@ -0,0 +1 @@
ALTER TABLE "scan_log" RENAME COLUMN "add_Date" TO "add_date";

View File

@@ -0,0 +1,17 @@
CREATE TABLE "analytics_daily" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"business_date" date NOT NULL,
"method" text NOT NULL,
"route_pattern" text NOT NULL,
"module" text NOT NULL,
"total_hits" integer NOT NULL,
"unique_users" integer NOT NULL,
"success_count" integer NOT NULL,
"error_count" integer NOT NULL,
"avg_duration_ms" integer NOT NULL,
"max_duration_ms" integer NOT NULL,
"first_hit_at" timestamp NOT NULL,
"last_hit_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More