feat: add Redis caching layer, fix connection pools, add loading skeletons

- Create src/lib/redis.ts (singleton client) and src/lib/cache.ts
  (cachedQuery with graceful degradation on Redis failure)
- Wrap all 12 API routes and 7 RSC pages with cachedQuery (TTLs:
  120s-900s by data type — dashboard 3min, inventory/orders/shipments
  5min, BOL/order-ack/traveler 10min, ship-to 15min, avail-coils 2min)
- Fix redundant connection pools in dashboard.ts and shipments.ts
  (were creating their own sql.connect instead of using shared pool)
- Add pool tuning to epicor.ts (max:15, min:2, 60s idle, 30s acquire)
- Parallelize session checks (getQuestSession + getActiveCompany +
  isSubUser) with Promise.all across all inventory and orders pages
- Add loading.tsx skeletons for dashboard, inventory detail, orders,
  and shipments pages for instant shell rendering via Next.js streaming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lorentz Hinrichsen 2026-02-18 07:40:07 -05:00
parent 9654ccee26
commit 06221d2d9c
29 changed files with 453 additions and 208 deletions

View file

@ -0,0 +1,42 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
export default function DashboardLoading() {
return (
<div>
<h1 className="mb-6 text-3xl font-bold">Dashboard</h1>
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-6 w-32 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, j) => (
<div
key={j}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,27 @@
import { Card, CardContent } from '@/components/ui/card';
export default function InventoryDetailLoading() {
return (
<div>
<div className="mb-6 flex items-center gap-4">
<div className="h-9 w-36 animate-pulse rounded bg-muted" />
<div>
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-1 h-5 w-48 animate-pulse rounded bg-muted" />
</div>
</div>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(8)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -52,9 +52,11 @@ async function InventoryDetailData({
plant?: string;
warehouse?: string;
}) {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
const [session, activeCompany, userIsSubUser] = await Promise.all([
getQuestSession(),
getActiveCompany(),
isSubUser(),
]);
if (!session || !activeCompany) {
redirect('/select-company');

View file

@ -9,26 +9,29 @@ import {
import { redirect } from 'next/navigation';
import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table';
import { Card, CardContent } from '@/components/ui/card';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function FinishedGoodsInventoryData() {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
const [session, activeCompany, userIsSubUser] = await Promise.all([
getQuestSession(),
getActiveCompany(),
isSubUser(),
]);
if (!session || !activeCompany) {
redirect('/select-company');
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const sub = userIsSubUser ? 1 : 0;
const [inventory, canShip] = await Promise.all([
getFinishedGoodsSummary(
activeCompany.epicor_cust_id,
dbName,
sub
cachedQuery(
{ key: `inv:${custId}:finished-goods:summary:${sub}`, ttlSeconds: 300 },
() => getFinishedGoodsSummary(custId, dbName, sub)
).catch(() => []),
hasPermission('create_shipment_request'),
]);

View file

@ -8,25 +8,28 @@ import {
import { redirect } from 'next/navigation';
import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table';
import { Card, CardContent } from '@/components/ui/card';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function ProcessedOtherInventoryData() {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
const [session, activeCompany, userIsSubUser] = await Promise.all([
getQuestSession(),
getActiveCompany(),
isSubUser(),
]);
if (!session || !activeCompany) {
redirect('/select-company');
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const sub = userIsSubUser ? 1 : 0;
const inventory = await getProcessedOtherSummary(
activeCompany.epicor_cust_id,
dbName,
sub
const inventory = await cachedQuery(
{ key: `inv:${custId}:processed-other:summary:${sub}`, ttlSeconds: 300 },
() => getProcessedOtherSummary(custId, dbName, sub)
).catch(() => []);
return <InventorySummaryTable data={inventory} category="processed-other" />;

View file

@ -8,13 +8,16 @@ import {
import { redirect } from 'next/navigation';
import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table';
import { Card, CardContent } from '@/components/ui/card';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function ProcessedRRInventoryData() {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
const [session, activeCompany, userIsSubUser] = await Promise.all([
getQuestSession(),
getActiveCompany(),
isSubUser(),
]);
if (!session || !activeCompany) {
redirect('/select-company');
@ -25,12 +28,12 @@ async function ProcessedRRInventoryData() {
redirect('/inventory');
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const inventory = await getProcessedRRSummary(
activeCompany.epicor_cust_id,
dbName,
userIsSubUser
const inventory = await cachedQuery(
{ key: `inv:${custId}:processed-rr:summary`, ttlSeconds: 300 },
() => getProcessedRRSummary(custId, dbName, userIsSubUser)
).catch(() => []);
return <InventorySummaryTable data={inventory} category="processed-rr" />;

View file

@ -8,13 +8,16 @@ import {
import { redirect } from 'next/navigation';
import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table';
import { Card, CardContent } from '@/components/ui/card';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function UnprocessedRRInventoryData() {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
const [session, activeCompany, userIsSubUser] = await Promise.all([
getQuestSession(),
getActiveCompany(),
isSubUser(),
]);
if (!session || !activeCompany) {
redirect('/select-company');
@ -25,12 +28,12 @@ async function UnprocessedRRInventoryData() {
redirect('/inventory');
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const inventory = await getUnprocessedRRSummary(
activeCompany.epicor_cust_id,
dbName,
userIsSubUser
const inventory = await cachedQuery(
{ key: `inv:${custId}:unprocessed-rr:summary`, ttlSeconds: 300 },
() => getUnprocessedRRSummary(custId, dbName, userIsSubUser)
).catch(() => []);
return <InventorySummaryTable data={inventory} category="unprocessed-rr" />;

View file

@ -8,13 +8,16 @@ import {
import { redirect } from 'next/navigation';
import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table';
import { Card, CardContent } from '@/components/ui/card';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function UnprocessedInventoryData() {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
const [session, activeCompany, userIsSubUser] = await Promise.all([
getQuestSession(),
getActiveCompany(),
isSubUser(),
]);
if (!session || !activeCompany) {
redirect('/select-company');
@ -25,12 +28,12 @@ async function UnprocessedInventoryData() {
redirect('/inventory');
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const inventory = await getUnprocessedSummary(
activeCompany.epicor_cust_id,
dbName,
userIsSubUser
const inventory = await cachedQuery(
{ key: `inv:${custId}:unprocessed:summary`, ttlSeconds: 300 },
() => getUnprocessedSummary(custId, dbName, userIsSubUser)
).catch(() => []);
return <InventorySummaryTable data={inventory} category="unprocessed" />;

View file

@ -6,23 +6,26 @@ import { getQuestSession, getActiveCompany, isSubUser } from '@/lib/permissions'
import { redirect } from 'next/navigation';
import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table';
import { Card, CardContent } from '@/components/ui/card';
import { cachedQuery } from '@/lib/cache';
async function WIPInventoryData() {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
const [session, activeCompany, userIsSubUser] = await Promise.all([
getQuestSession(),
getActiveCompany(),
isSubUser(),
]);
if (!session || !activeCompany) {
redirect('/select-company');
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const sub = userIsSubUser ? 1 : 0;
const inventory = await getWorkInProgressSummary(
activeCompany.epicor_cust_id,
dbName,
sub
const inventory = await cachedQuery(
{ key: `inv:${custId}:wip:summary:${sub}`, ttlSeconds: 300 },
() => getWorkInProgressSummary(custId, dbName, sub)
).catch(() => []);
return <InventorySummaryTable data={inventory} category="wip" />;

View file

@ -0,0 +1,24 @@
import { Card, CardContent } from '@/components/ui/card';
export default function OrdersLoading() {
return (
<div>
<h1 className="mb-2 text-3xl font-bold">Orders</h1>
<p className="mb-6 text-muted-foreground">
View your most recent orders and order acknowledgements
</p>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -4,23 +4,29 @@ import { getTop100Orders } from '@/services/orders';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { OrdersTable } from '@/components/orders/orders-table';
import { Card, CardContent } from '@/components/ui/card';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function OrdersData() {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const [session, activeCompany] = await Promise.all([
getQuestSession(),
getActiveCompany(),
]);
if (!session || !activeCompany) {
redirect('/select-company');
}
const orders = await getTop100Orders(activeCompany.epicor_cust_id).catch(
(err) => {
const custId = activeCompany.epicor_cust_id;
const orders = await cachedQuery(
{ key: `orders:${custId}:top100`, ttlSeconds: 300 },
() => getTop100Orders(custId)
).catch((err) => {
console.error('Failed to fetch orders:', err);
return [];
}
);
});
return <OrdersTable data={orders} />;
}

View file

@ -0,0 +1,24 @@
import { Card, CardContent } from '@/components/ui/card';
export default function ShipmentsLoading() {
return (
<div>
<h1 className="mb-2 text-3xl font-bold">Shipments</h1>
<p className="mb-6 text-muted-foreground">
View your most recent shipments and BOL details
</p>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions';
import { getAvailableCoils } from '@/services/available-coils';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -28,12 +29,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'jobNumber is required' }, { status: 400 });
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const coils = await getAvailableCoils(
activeCompany.epicor_cust_id,
dbName,
jobNumber
const coils = await cachedQuery(
{ key: `avail-coils:${custId}:${jobNumber}`, ttlSeconds: 120 },
() => getAvailableCoils(custId, dbName, jobNumber)
);
return NextResponse.json(coils);

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { getCoilByCoil } from '@/services/coil-activity';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -19,8 +20,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'jobNum is required' }, { status: 400 });
}
const custId = activeCompany.epicor_cust_id;
try {
const data = await getCoilByCoil(jobNum, activeCompany.epicor_cust_id);
const data = await cachedQuery(
{ key: `coil:${custId}:cbc:${jobNum}`, ttlSeconds: 300 },
() => getCoilByCoil(jobNum, custId)
);
return NextResponse.json(data);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCoilReceipts } from '@/services/coil-activity';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -44,11 +45,12 @@ export async function GET(request: NextRequest) {
);
}
const custId = activeCompany.epicor_cust_id;
try {
const data = await getCoilReceipts(
activeCompany.epicor_cust_id,
startDate,
endDate
const data = await cachedQuery(
{ key: `coil:${custId}:receipts:${startDate}:${endDate}`, ttlSeconds: 300 },
() => getCoilReceipts(custId, startDate, endDate)
);
return NextResponse.json(data);
} catch (err) {

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCoilUsage } from '@/services/coil-activity';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -46,11 +47,12 @@ export async function GET(request: NextRequest) {
);
}
const custId = activeCompany.epicor_cust_id;
try {
const data = await getCoilUsage(
activeCompany.epicor_cust_id,
startDate,
endDate
const data = await cachedQuery(
{ key: `coil:${custId}:usage:${startDate}:${endDate}`, ttlSeconds: 300 },
() => getCoilUsage(custId, startDate, endDate)
);
return NextResponse.json(data);
} catch (err) {

View file

@ -6,6 +6,7 @@ import {
} from '@/services/dashboard';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { getUnreadAlertCount } from '@/services/notifications';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -17,12 +18,23 @@ export async function GET() {
return NextResponse.json({ error: 'No active company' }, { status: 401 });
}
const custId = activeCompany.epicor_cust_id;
try {
const [orders, shipments, inventorySummary, unreadNotifications] =
await Promise.all([
getRecentOrders(activeCompany.epicor_cust_id).catch(() => []),
getRecentShipments(activeCompany.epicor_cust_id).catch(() => []),
getInventorySummary(activeCompany.epicor_cust_id).catch(() => ({
cachedQuery(
{ key: `dash:${custId}:orders`, ttlSeconds: 180 },
() => getRecentOrders(custId)
).catch(() => []),
cachedQuery(
{ key: `dash:${custId}:shipments`, ttlSeconds: 180 },
() => getRecentShipments(custId)
).catch(() => []),
cachedQuery(
{ key: `dash:${custId}:inv-summary`, ttlSeconds: 180 },
() => getInventorySummary(custId)
).catch(() => ({
wip_count: 0,
finished_goods_count: 0,
unprocessed_count: 0,

View file

@ -8,6 +8,7 @@ import {
getInventoryDetails,
type InventoryCategory,
} from '@/services/inventory';
import { cachedQuery } from '@/lib/cache';
const VALID_CATEGORIES: InventoryCategory[] = [
'wip',
@ -60,15 +61,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ data: [] });
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const sub = userIsSubUser ? 1 : 0;
const details = await getInventoryDetails(
category,
activeCompany.epicor_cust_id,
dbName,
sub,
{ partNum: part, plant, warehouse }
const filterKey = [part, plant, warehouse].filter(Boolean).join(':') || 'all';
const details = await cachedQuery(
{ key: `inv:${custId}:${category}:detail:${sub}:${filterKey}`, ttlSeconds: 300 },
() => getInventoryDetails(category, custId, dbName, sub, { partNum: part, plant, warehouse })
);
return NextResponse.json({ data: details });

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { getJobTraveler } from '@/services/jobs';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -16,9 +17,13 @@ export async function GET(
}
const { jobNum } = await params;
const custId = activeCompany.epicor_cust_id;
try {
const data = await getJobTraveler(jobNum);
const data = await cachedQuery(
{ key: `jobs:${custId}:traveler:${jobNum}`, ttlSeconds: 600 },
() => getJobTraveler(jobNum)
);
if (data === null) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 });

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { getJobStatusByPlant } from '@/services/jobs';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -12,8 +13,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'No active company' }, { status: 401 });
}
const custId = activeCompany.epicor_cust_id;
try {
const data = await getJobStatusByPlant(activeCompany.epicor_cust_id);
const data = await cachedQuery(
{ key: `jobs:${custId}:status`, ttlSeconds: 300 },
() => getJobStatusByPlant(custId)
);
return NextResponse.json(data);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getOrderAcknowledgement } from '@/services/orders';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -25,10 +26,12 @@ export async function GET(
);
}
const custId = activeCompany.epicor_cust_id;
try {
const data = await getOrderAcknowledgement(
orderNumInt,
activeCompany.epicor_cust_id
const data = await cachedQuery(
{ key: `order:${custId}:ack:${orderNumInt}`, ttlSeconds: 600 },
() => getOrderAcknowledgement(orderNumInt, custId)
);
if (!data) {

View file

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { getQuestSession, getActiveCompany, isSubUser } from '@/lib/permissions';
import { getShipToAddresses } from '@/services/ship-to-addresses';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -16,13 +17,14 @@ export async function GET() {
return NextResponse.json({ error: 'No active company' }, { status: 401 });
}
const custId = activeCompany.epicor_cust_id;
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const sub = await isSubUser();
const subKey = sub ? 1 : 0;
const addresses = await getShipToAddresses(
activeCompany.epicor_cust_id,
dbName,
sub
const addresses = await cachedQuery(
{ key: `shipto:${custId}:${subKey}`, ttlSeconds: 900 },
() => getShipToAddresses(custId, dbName, sub)
);
return NextResponse.json(addresses);

View file

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { getBOLDetail } from '@/services/shipments';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -21,8 +22,13 @@ export async function GET(
return NextResponse.json({ error: 'Invalid BOL number' }, { status: 400 });
}
const custId = activeCompany.epicor_cust_id;
try {
const data = await getBOLDetail(bolNum, activeCompany.epicor_cust_id);
const data = await cachedQuery(
{ key: `bol:${custId}:${bolNum}`, ttlSeconds: 600 },
() => getBOLDetail(bolNum, custId)
);
if (!data) {
return NextResponse.json({ error: 'BOL not found' }, { status: 404 });

View file

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { getTop100Shipments } from '@/services/shipments';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { cachedQuery } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@ -12,8 +13,13 @@ export async function GET() {
return NextResponse.json({ error: 'No active company' }, { status: 401 });
}
const custId = activeCompany.epicor_cust_id;
try {
const shipments = await getTop100Shipments(activeCompany.epicor_cust_id);
const shipments = await cachedQuery(
{ key: `ship:${custId}:top100`, ttlSeconds: 300 },
() => getTop100Shipments(custId)
);
return NextResponse.json(shipments);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

75
src/lib/cache.ts Normal file
View file

@ -0,0 +1,75 @@
import { ensureRedisConnected } from '@/lib/redis';
type CachedQueryOptions = {
key: string;
ttlSeconds: number;
};
/**
* Wraps any async data fetch with Redis caching.
*
* - On cache hit: returns parsed JSON from Redis
* - On cache miss: calls fetchFn, stores result in Redis with TTL, returns result
* - On any Redis error: falls through to fetchFn (graceful degradation)
*/
export async function cachedQuery<T>(
options: CachedQueryOptions,
fetchFn: () => Promise<T>
): Promise<T> {
const { key, ttlSeconds } = options;
try {
const redis = await ensureRedisConnected();
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached) as T;
}
} catch {
// Redis unavailable — fall through to fetchFn
}
const result = await fetchFn();
try {
const redis = await ensureRedisConnected();
await redis.set(key, JSON.stringify(result), { EX: ttlSeconds });
} catch {
// Redis unavailable — result is still returned
}
return result;
}
/**
* Delete all keys matching a pattern (e.g. `inv:ACM:*`).
* Uses SCAN to avoid blocking Redis.
*/
export async function invalidatePattern(pattern: string): Promise<number> {
try {
const redis = await ensureRedisConnected();
let deleted = 0;
for await (const key of redis.scanIterator({ MATCH: pattern })) {
await redis.del(key);
deleted++;
}
return deleted;
} catch {
return 0;
}
}
/**
* Delete a single cache key.
*/
export async function invalidateKey(key: string): Promise<boolean> {
try {
const redis = await ensureRedisConnected();
const result = await redis.del(key);
return result > 0;
} catch {
return false;
}
}

View file

@ -93,7 +93,15 @@ async function getPool(): Promise<sql.ConnectionPool> {
}
// Create new connection
connectionPromise = new sql.ConnectionPool(config)
connectionPromise = new sql.ConnectionPool({
...config,
pool: {
max: 15,
min: 2,
idleTimeoutMillis: 60000,
acquireTimeoutMillis: 30000,
},
})
.connect()
.then((connectedPool) => {
pool = connectedPool;

29
src/lib/redis.ts Normal file
View file

@ -0,0 +1,29 @@
import { createClient, type RedisClientType } from 'redis';
const globalForRedis = globalThis as unknown as {
redis: RedisClientType | undefined;
};
function createRedisClient(): RedisClientType {
const client = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
client.on('error', (err) => {
console.error('Redis client error:', err);
});
return client as RedisClientType;
}
export const redis: RedisClientType =
globalForRedis.redis ?? createRedisClient();
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis;
export async function ensureRedisConnected(): Promise<RedisClientType> {
if (!redis.isOpen) {
await redis.connect();
}
return redis;
}

View file

@ -9,8 +9,7 @@
* we need into plain objects.
*/
import sql from 'mssql';
import { execQuery, getPortalDbName } from '@/lib/epicor';
import { execQuery, execStoredProc, getPortalDbName } from '@/lib/epicor';
import { getTop100Shipments } from '@/services/shipments';
export type DashboardOrder = {
@ -107,7 +106,7 @@ export async function getRecentShipments(
/**
* Get inventory summary counts for dashboard.
*
* Calls the real portal inventory stored procedures:
* Calls the real portal inventory stored procedures via the shared connection pool:
* - PortalWorkInProgressInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER)
* - PortalFinishedGoodsInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER)
* - PortalUnprocessedInventorySummary (@CUSTID, @DBNAME)
@ -120,72 +119,40 @@ export async function getInventorySummary(
): Promise<InventorySummary> {
const dbName = getPortalDbName();
const config = {
server: process.env.MSSQL_HOST || '',
database: process.env.MSSQL_DATABASE || '',
user: process.env.MSSQL_USER || '',
password: process.env.MSSQL_PASSWORD || '',
port: parseInt(process.env.MSSQL_PORT || '1433', 10),
options: {
encrypt: false,
trustServerCertificate: true,
connectTimeout: 30000,
requestTimeout: 120000,
},
};
const pool = await sql.connect(config);
try {
// Run all three inventory SPs in parallel
const [wipResult, fgResult, unprocessedResult] = await Promise.all([
pool
.request()
.input('Customer', custId)
.input('DBNAME', dbName)
.input('SUBUSER', 0)
.execute('PortalWorkInProgressInventorySummaryV6')
.catch(() => null),
pool
.request()
.input('Customer', custId)
.input('DBNAME', dbName)
.input('SUBUSER', 0)
.execute('PortalFinishedGoodsInventorySummaryV6')
.catch(() => null),
pool
.request()
.input('CUSTID', custId)
.input('DBNAME', dbName)
.execute('PortalUnprocessedInventorySummary')
.catch(() => null),
const [wipRows, fgRows, unprocessedRows] = await Promise.all([
execStoredProc<Record<string, unknown>[]>(
'PortalWorkInProgressInventorySummaryV6',
{ Customer: custId, DBNAME: dbName, SUBUSER: 0 }
).catch(() => []),
execStoredProc<Record<string, unknown>[]>(
'PortalFinishedGoodsInventorySummaryV6',
{ Customer: custId, DBNAME: dbName, SUBUSER: 0 }
).catch(() => []),
execStoredProc<Record<string, unknown>[]>(
'PortalUnprocessedInventorySummary',
{ CUSTID: custId, DBNAME: dbName }
).catch(() => []),
]);
// Aggregate: sum Rows for counts, sum OnHandQty for total weight
let wipCount = 0;
let fgCount = 0;
let unprocessedCount = 0;
let totalWeight = 0;
if (wipResult) {
for (const r of wipResult.recordset) {
for (const r of wipRows) {
wipCount += Number(r.Rows ?? 0);
totalWeight += Number(r.OnHandQty ?? 0);
}
}
if (fgResult) {
for (const r of fgResult.recordset) {
for (const r of fgRows) {
fgCount += Number(r.Rows ?? 0);
totalWeight += Number(r.OnHandQty ?? 0);
}
}
if (unprocessedResult) {
for (const r of unprocessedResult.recordset) {
for (const r of unprocessedRows) {
unprocessedCount += Number(r.Rows ?? 0);
totalWeight += Number(r.OnHandQty ?? 0);
}
}
return {
wip_count: wipCount,
@ -193,16 +160,13 @@ export async function getInventorySummary(
unprocessed_count: unprocessedCount,
total_weight: totalWeight,
};
} finally {
// Don't close the pool — mssql reuses it globally
}
}
/**
* Get unread notification count
*/
export async function getUnreadNotificationCount(
userId: string
_userId: string
): Promise<number> {
// This would query the quest_user_notification_alert_read table
// For now, return 0 as placeholder

View file

@ -5,8 +5,7 @@
* SP columns are PascalCase; we map them to snake_case for the UI.
*/
import sql from 'mssql';
import { execQuery, getPortalDbName } from '@/lib/epicor';
import { execQuery, execStoredProc, getPortalDbName } from '@/lib/epicor';
import type { BOLData } from '@/types/shipments';
export type ShipmentRow = {
@ -20,56 +19,32 @@ export type ShipmentRow = {
/**
* Get top 100 shipments for a customer.
*
* Bypasses execStoredProc to avoid holding 7k+ mssql row objects in scope
* (which causes Next.js RSC serialization to blow the stack for large customers).
* Instead, we query directly and immediately extract only the fields we need.
* Uses the shared connection pool via execStoredProc, then immediately extracts
* only the fields we need into plain objects (RSC-safe).
*/
export async function getTop100Shipments(
custId: string
): Promise<ShipmentRow[]> {
const dbName = getPortalDbName();
const config = {
server: process.env.MSSQL_HOST || '',
database: process.env.MSSQL_DATABASE || '',
user: process.env.MSSQL_USER || '',
password: process.env.MSSQL_PASSWORD || '',
port: parseInt(process.env.MSSQL_PORT || '1433', 10),
options: {
encrypt: false,
trustServerCertificate: true,
connectTimeout: 30000,
requestTimeout: 120000,
},
};
const pool = await sql.connect(config);
try {
const result = await pool.request()
.input('CustID', custId)
.input('DBNAME', dbName)
.execute('portal_GetShipmentsV1');
const result = await execStoredProc<Record<string, unknown>[]>(
'portal_GetShipmentsV1',
{ CustID: custId, DBNAME: dbName }
);
// Immediately extract only the fields we need into plain objects.
// This prevents mssql row metadata from leaking into RSC serialization.
const rows: ShipmentRow[] = [];
for (let i = 0; i < result.recordset.length; i++) {
const r = result.recordset[i];
rows.push({
const rows: ShipmentRow[] = result.map((r) => ({
bol_num: String(r.BOLNum ?? ''),
ship_date: r.ShipDate ? new Date(r.ShipDate as string).toISOString() : '',
ship_to: String(r.ShipToLoc ?? '').replace(/, /g, '\n'),
plant: String(r.PlantName ?? ''),
weight: Number(r.Pounds ?? 0),
});
}
}));
// Sort by ship date descending and take top 100
rows.sort((a, b) => b.ship_date.localeCompare(a.ship_date));
return rows.slice(0, 100);
} finally {
// Don't close the pool — mssql reuses it globally
}
}
/**