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:
parent
9654ccee26
commit
06221d2d9c
29 changed files with 453 additions and 208 deletions
42
src/app/(portal)/dashboard/loading.tsx
Normal file
42
src/app/(portal)/dashboard/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/(portal)/inventory/[category]/detail/loading.tsx
Normal file
27
src/app/(portal)/inventory/[category]/detail/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
|
|
|
|||
24
src/app/(portal)/orders/loading.tsx
Normal file
24
src/app/(portal)/orders/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
24
src/app/(portal)/shipments/loading.tsx
Normal file
24
src/app/(portal)/shipments/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
75
src/lib/cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
29
src/lib/redis.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue