Version
This commit is contained in:
+17
-10
@@ -24,16 +24,23 @@ export const sessionDuration = 30 * 24 * 60 * 60000;
|
||||
export const accessTokenDuration = "10min";
|
||||
export const refreshTokenDuration = "30d";
|
||||
|
||||
export const accessTokenPrivateKey =
|
||||
readFileSync(`.accessToken.key`).toString();
|
||||
export const refreshTokenPrivateKey =
|
||||
readFileSync(`.refreshToken.key`).toString();
|
||||
export const permissionsPrivateKey = readFileSync(`.permissions.key`);
|
||||
export const secureValuesPrivateKey =
|
||||
readFileSync(`.secureValues.key`).toString();
|
||||
export const secureValuesPublicKey = readFileSync(
|
||||
`public-keys/.secureValues.pub`,
|
||||
).toString();
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
export const accessTokenPrivateKey = isProduction
|
||||
? process.env.ACCESS_TOKEN_PRIVATE_KEY!
|
||||
: readFileSync(`.accessToken.key`).toString();
|
||||
export const refreshTokenPrivateKey = isProduction
|
||||
? process.env.REFRESH_TOKEN_PRIVATE_KEY!
|
||||
: readFileSync(`.refreshToken.key`).toString();
|
||||
export const permissionsPrivateKey = isProduction
|
||||
? process.env.PERMISSIONS_PRIVATE_KEY!
|
||||
: readFileSync(`.permissions.key`).toString();
|
||||
export const secureValuesPrivateKey = isProduction
|
||||
? process.env.SECURE_VALUES_PRIVATE_KEY!
|
||||
: readFileSync(`.secureValues.key`).toString();
|
||||
export const secureValuesPublicKey = isProduction
|
||||
? process.env.SECURE_VALUES_PUBLIC_KEY!
|
||||
: readFileSync(`public-keys/.secureValues.pub`).toString();
|
||||
|
||||
// Microsoft Auth Constants
|
||||
const msalConfig: msal.Configuration = {
|
||||
|
||||
+18
-1
@@ -1,8 +1,10 @@
|
||||
import { refresh } from "./api/auth";
|
||||
import app from "./api/server";
|
||||
import { engine, PORT } from "./constants";
|
||||
import { engine, PORT, unifi, unifiPassword, unifiUsername } from "./constants";
|
||||
import { unifiSites } from "./managers/unifiSites";
|
||||
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
|
||||
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
|
||||
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
|
||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
|
||||
// Setup global event debugger in non-production environments
|
||||
@@ -14,6 +16,21 @@ setInterval(() => {
|
||||
return refreshCompanies();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh the internal catalog every minute
|
||||
await refreshCatalog();
|
||||
setInterval(() => {
|
||||
return refreshCatalog();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh inventory on hand every 2 minutes
|
||||
await refreshInventory();
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshInventory();
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
|
||||
await unifiSites.syncSites();
|
||||
setInterval(() => {
|
||||
return unifiSites.syncSites();
|
||||
|
||||
+54
-41
@@ -1,6 +1,7 @@
|
||||
import { prisma, unifi, unifiUsername, unifiPassword } from "../constants";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { UnifiSite } from "../../generated/prisma/client";
|
||||
import { events } from "../modules/globalEvents";
|
||||
|
||||
let loggedIn = false;
|
||||
|
||||
@@ -16,6 +17,24 @@ async function ensureLoggedIn(): Promise<void> {
|
||||
loggedIn = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a UniFi API call with automatic re-authentication on failure.
|
||||
* If the call fails and we were previously logged in (i.e. session likely
|
||||
* expired), resets the login state, re-authenticates, and retries once.
|
||||
*/
|
||||
async function withReauth<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await ensureLoggedIn();
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (!loggedIn) throw e;
|
||||
events.emit("unifi:reauth");
|
||||
loggedIn = false;
|
||||
await ensureLoggedIn();
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
export const unifiSites = {
|
||||
/**
|
||||
* Fetch a UniFi site record from the database by its internal ID.
|
||||
@@ -96,12 +115,14 @@ export const unifiSites = {
|
||||
* Creates new records for sites not yet tracked, updates names for existing ones.
|
||||
*/
|
||||
async syncSites(): Promise<UnifiSite[]> {
|
||||
await ensureLoggedIn();
|
||||
events.emit("unifi:sites:sync:started");
|
||||
|
||||
// Fetch all sites from the controller
|
||||
const allSites = await unifi.getAllSites();
|
||||
const allSites = await withReauth(() => unifi.getAllSites());
|
||||
|
||||
const results: UnifiSite[] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const site of allSites) {
|
||||
const existing = await prisma.unifiSite.findFirst({
|
||||
@@ -109,22 +130,30 @@ export const unifiSites = {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.unifiSite.update({
|
||||
const updatedSite = await prisma.unifiSite.update({
|
||||
where: { id: existing.id },
|
||||
data: { name: site.description },
|
||||
});
|
||||
results.push(updated);
|
||||
results.push(updatedSite);
|
||||
updated++;
|
||||
} else {
|
||||
const created = await prisma.unifiSite.create({
|
||||
const createdSite = await prisma.unifiSite.create({
|
||||
data: {
|
||||
name: site.description,
|
||||
siteId: site.name,
|
||||
},
|
||||
});
|
||||
results.push(created);
|
||||
results.push(createdSite);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
events.emit("unifi:sites:sync:completed", {
|
||||
total: results.length,
|
||||
created,
|
||||
updated,
|
||||
});
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
@@ -132,24 +161,21 @@ export const unifiSites = {
|
||||
* Get live site overview from the UniFi controller.
|
||||
*/
|
||||
async getSiteOverview(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getSiteOverview(siteId);
|
||||
return withReauth(() => unifi.getSiteOverview(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get live devices from the UniFi controller for a site.
|
||||
*/
|
||||
async getDevices(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getDevices(siteId);
|
||||
return withReauth(() => unifi.getDevices(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get live WiFi networks (WLANs) from the UniFi controller for a site.
|
||||
*/
|
||||
async getWlanConf(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getWlanConf(siteId);
|
||||
return withReauth(() => unifi.getWlanConf(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -160,25 +186,21 @@ export const unifiSites = {
|
||||
wlanId: string,
|
||||
updates: Parameters<typeof unifi.updateWlanConf>[2],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.updateWlanConf(siteId, wlanId, updates);
|
||||
return withReauth(() => unifi.updateWlanConf(siteId, wlanId, updates));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get live network configurations from the UniFi controller for a site.
|
||||
*/
|
||||
async getNetworks(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getNetworks(siteId);
|
||||
return withReauth(() => unifi.getNetworks(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new site on the UniFi controller and track it in the database.
|
||||
*/
|
||||
async createSite(description: string): Promise<UnifiSite> {
|
||||
await ensureLoggedIn();
|
||||
|
||||
const created = await unifi.createSite(description);
|
||||
const created = await withReauth(() => unifi.createSite(description));
|
||||
|
||||
return prisma.unifiSite.create({
|
||||
data: {
|
||||
@@ -192,8 +214,7 @@ export const unifiSites = {
|
||||
* Get WLAN groups from the UniFi controller for a site.
|
||||
*/
|
||||
async getWlanGroups(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getWlanGroups(siteId);
|
||||
return withReauth(() => unifi.getWlanGroups(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -203,16 +224,14 @@ export const unifiSites = {
|
||||
siteId: string,
|
||||
input: Parameters<typeof unifi.createWlanGroup>[1],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createWlanGroup(siteId, input);
|
||||
return withReauth(() => unifi.createWlanGroup(siteId, input));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user groups (speed profiles) from the UniFi controller for a site.
|
||||
*/
|
||||
async getUserGroups(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getUserGroups(siteId);
|
||||
return withReauth(() => unifi.getUserGroups(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -222,16 +241,14 @@ export const unifiSites = {
|
||||
siteId: string,
|
||||
input: Parameters<typeof unifi.createUserGroup>[1],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createUserGroup(siteId, input);
|
||||
return withReauth(() => unifi.createUserGroup(siteId, input));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get AP groups from the UniFi controller for a site.
|
||||
*/
|
||||
async getApGroups(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getApGroups(siteId);
|
||||
return withReauth(() => unifi.getApGroups(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -243,40 +260,37 @@ export const unifiSites = {
|
||||
deviceMacs: string[],
|
||||
forWlanconf: boolean = false,
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createApGroup(siteId, name, deviceMacs, forWlanconf);
|
||||
return withReauth(() =>
|
||||
unifi.createApGroup(siteId, name, deviceMacs, forWlanconf),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing AP group's device MACs on the UniFi controller.
|
||||
*/
|
||||
async updateApGroup(siteId: string, groupId: string, deviceMacs: string[]) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.updateApGroup(siteId, groupId, deviceMacs);
|
||||
return withReauth(() => unifi.updateApGroup(siteId, groupId, deviceMacs));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access points only from the UniFi controller for a site.
|
||||
*/
|
||||
async getAccessPoints(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getAccessPoints(siteId);
|
||||
return withReauth(() => unifi.getAccessPoints(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get WiFi SSID limits per AP per radio band.
|
||||
*/
|
||||
async getWifiLimits(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getWifiLimits(siteId);
|
||||
return withReauth(() => unifi.getWifiLimits(siteId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get private pre-shared keys for a specific WLAN.
|
||||
*/
|
||||
async getPrivatePSKs(siteId: string, wlanId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getPrivatePSKs(siteId, wlanId);
|
||||
return withReauth(() => unifi.getPrivatePSKs(siteId, wlanId));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -287,7 +301,6 @@ export const unifiSites = {
|
||||
wlanId: string,
|
||||
psk: Parameters<typeof unifi.createPrivatePSK>[2],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createPrivatePSK(siteId, wlanId, psk);
|
||||
return withReauth(() => unifi.createPrivatePSK(siteId, wlanId, psk));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
import { CatalogItem } from "./catalog.types.ts";
|
||||
|
||||
export interface CatalogSummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface InventoryEntry {
|
||||
id: number;
|
||||
onHand: number;
|
||||
}
|
||||
|
||||
export const catalogCw = {
|
||||
countItems: async (): Promise<number> => {
|
||||
const response = await connectWiseApi.get("/procurement/catalog/count");
|
||||
return response.data.count;
|
||||
},
|
||||
fetchAllCatalogSummary: async (): Promise<
|
||||
Collection<number, CatalogSummary>
|
||||
> => {
|
||||
const allItems = new Collection<number, CatalogSummary>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await catalogCw.countItems();
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/catalog?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
|
||||
);
|
||||
const items: CatalogSummary[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
fetchInventoryOnHand: async (cwCatalogId: number): Promise<number> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/catalog/${cwCatalogId}/inventory?fields=onHand`,
|
||||
);
|
||||
const entries: InventoryEntry[] = response.data;
|
||||
return entries.reduce((sum, e) => sum + (e.onHand || 0), 0);
|
||||
},
|
||||
fetchAllItemsFromCw: async (): Promise<Collection<number, CatalogItem>> => {
|
||||
const allItems = new Collection<number, CatalogItem>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await catalogCw.countItems();
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/catalog?page=${page + 1}&pageSize=${pageSize}`,
|
||||
);
|
||||
const items: CatalogItem[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
fetch: async (id: string): Promise<CatalogItem> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/catalog/items/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
interface CWReference {
|
||||
id: number;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWVendorReference {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
entryMethod: string;
|
||||
numberOfDecimals: number;
|
||||
value: unknown;
|
||||
connectWiseId: string;
|
||||
rowNum: number;
|
||||
userDefinedFieldRecId: number;
|
||||
podId: string;
|
||||
}
|
||||
|
||||
export interface CatalogItem {
|
||||
id: number;
|
||||
identifier: string;
|
||||
description: string;
|
||||
inactiveFlag: boolean;
|
||||
subcategory: CWReference;
|
||||
type: CWReference;
|
||||
productClass: string;
|
||||
serializedFlag: boolean;
|
||||
serializedCostFlag: boolean;
|
||||
phaseProductFlag: boolean;
|
||||
unitOfMeasure: CWReference;
|
||||
minStockLevel: number;
|
||||
price: number;
|
||||
cost: number;
|
||||
priceAttribute: string;
|
||||
taxableFlag: boolean;
|
||||
dropShipFlag: boolean;
|
||||
specialOrderFlag: boolean;
|
||||
customerDescription: string;
|
||||
manufacturer: CWReference;
|
||||
manufacturerPartNumber: string;
|
||||
vendor: CWVendorReference;
|
||||
vendorSku: string;
|
||||
notes: string;
|
||||
integrationXRef: string;
|
||||
sla: CWReference;
|
||||
entityType: CWReference;
|
||||
recurringFlag: boolean;
|
||||
recurringRevenue: number;
|
||||
recurringCost: number;
|
||||
recurringOneTimeFlag: boolean;
|
||||
recurringBillCycle: CWReference;
|
||||
recurringCycleType: string;
|
||||
calculatedPriceFlag: boolean;
|
||||
calculatedCostFlag: boolean;
|
||||
category: CWReference;
|
||||
calculatedPrice: number;
|
||||
calculatedCost: number;
|
||||
billableOption: string;
|
||||
connectWiseID: string;
|
||||
agreementType: CWReference;
|
||||
markupPercentage: number;
|
||||
markupFlag: boolean;
|
||||
autoUpdateUnitCostFlag: boolean;
|
||||
autoUpdateUnitPriceFlag: boolean;
|
||||
_info?: Record<string, string>;
|
||||
customFields?: CWCustomField[];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { catalogCw } from "./catalog";
|
||||
|
||||
export const refreshCatalog = async () => {
|
||||
events.emit("cw:catalog:refresh:check");
|
||||
|
||||
// 1. Fetch lightweight summaries from CW (id + _info with lastUpdated)
|
||||
const cwSummaries = await catalogCw.fetchAllCatalogSummary();
|
||||
|
||||
// 2. Fetch all DB items with their cwCatalogId and cwLastUpdated
|
||||
const dbItems = await prisma.catalogItem.findMany({
|
||||
select: { cwCatalogId: true, cwLastUpdated: true },
|
||||
});
|
||||
const dbMap = new Map(
|
||||
dbItems.map((item) => [item.cwCatalogId, item.cwLastUpdated]),
|
||||
);
|
||||
|
||||
// 3. Compare CW lastUpdated vs DB cwLastUpdated — collect IDs that are stale or new
|
||||
const staleIds: number[] = [];
|
||||
|
||||
for (const [cwId, summary] of cwSummaries) {
|
||||
const cwLastUpdated = summary._info?.lastUpdated
|
||||
? new Date(summary._info.lastUpdated)
|
||||
: null;
|
||||
const dbLastUpdated = dbMap.get(cwId) ?? null;
|
||||
|
||||
// New item (not in DB) or CW has a newer timestamp
|
||||
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
|
||||
staleIds.push(cwId);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length === 0) {
|
||||
events.emit("cw:catalog:refresh:skipped", {
|
||||
totalCw: cwSummaries.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
events.emit("cw:catalog:refresh:started", {
|
||||
totalCw: cwSummaries.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
});
|
||||
|
||||
// 4. Fetch full catalog data, then filter to only stale items
|
||||
const staleIdSet = new Set(staleIds);
|
||||
const allCwItems = await catalogCw.fetchAllItemsFromCw();
|
||||
const allStaleItems = new Map<number, any>();
|
||||
|
||||
for (const [id, item] of allCwItems) {
|
||||
if (staleIdSet.has(id)) {
|
||||
allStaleItems.set(id, item);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Batch fetch inventory onHand for stale items (50 concurrent)
|
||||
const onHandMap = new Map<number, number>();
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < staleIds.length; i += batchSize) {
|
||||
const batch = staleIds.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (cwId) => {
|
||||
try {
|
||||
const onHand = await catalogCw.fetchInventoryOnHand(cwId);
|
||||
onHandMap.set(cwId, onHand);
|
||||
} catch {
|
||||
onHandMap.set(cwId, 0);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Upsert only the stale/new items
|
||||
const updatedCount = (
|
||||
await Promise.all(
|
||||
staleIds.map(async (cwId) => {
|
||||
const item = allStaleItems.get(cwId);
|
||||
if (!item) return null;
|
||||
|
||||
const cwLastUpdated = item._info?.lastUpdated
|
||||
? new Date(item._info.lastUpdated)
|
||||
: new Date();
|
||||
const onHand = onHandMap.get(cwId) ?? 0;
|
||||
|
||||
return await prisma.catalogItem.upsert({
|
||||
where: { cwCatalogId: cwId },
|
||||
create: {
|
||||
cwCatalogId: cwId,
|
||||
name: item.description,
|
||||
description: item.description,
|
||||
customerDescription: item.customerDescription,
|
||||
internalNotes: item.notes,
|
||||
manufacturer: item.manufacturer?.name,
|
||||
manufactureCwId: item.manufacturer?.id,
|
||||
partNumber: item.manufacturerPartNumber,
|
||||
vendorName: item.vendor?.name,
|
||||
vendorSku: item.vendorSku,
|
||||
vendorCwId: item.vendor?.id,
|
||||
price: item.price,
|
||||
cost: item.cost,
|
||||
inactive: item.inactiveFlag,
|
||||
salesTaxable: item.taxableFlag,
|
||||
onHand,
|
||||
cwLastUpdated,
|
||||
},
|
||||
update: {
|
||||
name: item.description,
|
||||
description: item.description,
|
||||
customerDescription: item.customerDescription,
|
||||
internalNotes: item.notes,
|
||||
manufacturer: item.manufacturer?.name,
|
||||
manufactureCwId: item.manufacturer?.id,
|
||||
partNumber: item.manufacturerPartNumber,
|
||||
vendorName: item.vendor?.name,
|
||||
vendorSku: item.vendorSku,
|
||||
vendorCwId: item.vendor?.id,
|
||||
price: item.price,
|
||||
cost: item.cost,
|
||||
inactive: item.inactiveFlag,
|
||||
salesTaxable: item.taxableFlag,
|
||||
onHand,
|
||||
cwLastUpdated,
|
||||
},
|
||||
});
|
||||
}),
|
||||
)
|
||||
).filter(Boolean).length;
|
||||
|
||||
events.emit("cw:catalog:refresh:completed", {
|
||||
totalCw: cwSummaries.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
itemsUpdated: updatedCount,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { catalogCw } from "./catalog";
|
||||
|
||||
export const refreshInventory = async () => {
|
||||
events.emit("cw:inventory:refresh:check");
|
||||
|
||||
// 1. Get all active catalog items from DB
|
||||
const dbItems = await prisma.catalogItem.findMany({
|
||||
where: { inactive: false },
|
||||
select: { cwCatalogId: true, onHand: true },
|
||||
});
|
||||
|
||||
if (dbItems.length === 0) {
|
||||
events.emit("cw:inventory:refresh:skipped", {
|
||||
totalItems: 0,
|
||||
updatedCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
events.emit("cw:inventory:refresh:started", {
|
||||
totalItems: dbItems.length,
|
||||
});
|
||||
|
||||
// 2. Batch fetch inventory onHand for all items (50 concurrent)
|
||||
const onHandMap = new Map<number, number>();
|
||||
const batchSize = 150;
|
||||
|
||||
for (let i = 0; i < dbItems.length; i += batchSize) {
|
||||
const batch = dbItems.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (item) => {
|
||||
try {
|
||||
const onHand = await catalogCw.fetchInventoryOnHand(item.cwCatalogId);
|
||||
onHandMap.set(item.cwCatalogId, onHand);
|
||||
} catch {
|
||||
onHandMap.set(item.cwCatalogId, item.onHand);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Only update items where onHand has changed
|
||||
const updates = dbItems.filter((item) => {
|
||||
const newOnHand = onHandMap.get(item.cwCatalogId) ?? item.onHand;
|
||||
return newOnHand !== item.onHand;
|
||||
});
|
||||
|
||||
if (updates.length === 0) {
|
||||
events.emit("cw:inventory:refresh:skipped", {
|
||||
totalItems: dbItems.length,
|
||||
updatedCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedCount = (
|
||||
await Promise.all(
|
||||
updates.map(async (item) => {
|
||||
const newOnHand = onHandMap.get(item.cwCatalogId) ?? item.onHand;
|
||||
return await prisma.catalogItem.update({
|
||||
where: { cwCatalogId: item.cwCatalogId },
|
||||
data: { onHand: newOnHand },
|
||||
});
|
||||
}),
|
||||
)
|
||||
).length;
|
||||
|
||||
events.emit("cw:inventory:refresh:completed", {
|
||||
totalItems: dbItems.length,
|
||||
updatedCount,
|
||||
});
|
||||
};
|
||||
@@ -108,6 +108,56 @@ interface EventTypes {
|
||||
company: CompanyController;
|
||||
updatedFields: Partial<Company>;
|
||||
}) => void;
|
||||
|
||||
// ConnectWise Catalog Events
|
||||
"cw:catalog:refresh:check": () => void;
|
||||
"cw:catalog:refresh:started": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
"cw:catalog:refresh:completed": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
itemsUpdated: number;
|
||||
}) => void;
|
||||
"cw:catalog:refresh:skipped": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
|
||||
// UniFi Events
|
||||
"unifi:login:ok": (data: {
|
||||
type: "unifi-os" | "legacy";
|
||||
status: number;
|
||||
}) => void;
|
||||
"unifi:login:fallback": () => void;
|
||||
"unifi:reauth": () => void;
|
||||
"unifi:sites:sync:started": () => void;
|
||||
"unifi:sites:sync:completed": (data: {
|
||||
total: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
}) => void;
|
||||
"unifi:wlan:fetched": (data: { path: string }) => void;
|
||||
"unifi:wlan:fetch_failed": (data: {
|
||||
path: string;
|
||||
status: number | unknown;
|
||||
}) => void;
|
||||
|
||||
// ConnectWise Inventory Events
|
||||
"cw:inventory:refresh:check": () => void;
|
||||
"cw:inventory:refresh:started": (data: { totalItems: number }) => void;
|
||||
"cw:inventory:refresh:completed": (data: {
|
||||
totalItems: number;
|
||||
updatedCount: number;
|
||||
}) => void;
|
||||
"cw:inventory:refresh:skipped": (data: {
|
||||
totalItems: number;
|
||||
updatedCount: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const events = new Eventra<EventTypes>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import https from "https";
|
||||
import { events } from "../globalEvents";
|
||||
import {
|
||||
ApGroup,
|
||||
ApRadioWifiUsage,
|
||||
@@ -57,13 +58,13 @@ export class UnifiClient {
|
||||
try {
|
||||
// UniFi OS
|
||||
const res = await this.client.post("/api/auth/login", body);
|
||||
console.log("Login OK (UniFi OS)", res.status);
|
||||
events.emit("unifi:login:ok", { type: "unifi-os", status: res.status });
|
||||
this.persistSession(res);
|
||||
} catch (e) {
|
||||
// Legacy controller
|
||||
console.log("UniFi OS login failed, trying legacy...");
|
||||
events.emit("unifi:login:fallback");
|
||||
const res = await this.client.post("/api/login", body);
|
||||
console.log("Login OK (legacy)", res.status);
|
||||
events.emit("unifi:login:ok", { type: "legacy", status: res.status });
|
||||
this.persistSession(res);
|
||||
}
|
||||
}
|
||||
@@ -78,13 +79,13 @@ export class UnifiClient {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const data = (res.data?.data ?? res.data) as WlanConfRaw[];
|
||||
console.log(`Fetched wlan from ${path}`);
|
||||
events.emit("unifi:wlan:fetched", { path });
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Failed ${path}:`,
|
||||
axios.isAxiosError(e) ? e.response?.status : e,
|
||||
);
|
||||
events.emit("unifi:wlan:fetch_failed", {
|
||||
path,
|
||||
status: axios.isAxiosError(e) ? e.response?.status : e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user