-
- {user.firstName || user.lastName
- ? [user.firstName, user.lastName].filter(Boolean).join(' ')
- : user.email}
-
- {(user.firstName || user.lastName) && (
-
{user.email}
+
+ {user.avatarUrl ? (
+
+ ) : (
+
+
+ {user.firstName?.[0] ?? user.email[0] ?? '?'}
+
+
)}
+
+
+ {user.firstName || user.lastName
+ ? [user.firstName, user.lastName].filter(Boolean).join(' ')
+ : user.email}
+
+ {(user.firstName || user.lastName) && (
+
{user.email}
+ )}
+
@@ -80,31 +103,37 @@ function UserRow({ user }: { user: AdminUser }) {
- {
- await handleDelete();
- }}
- />
+
+
+ {
+ await handleDelete();
+ }}
+ />
+
);
}
export default function UsersPage() {
- const searchParams = useSearchParams();
- const q = searchParams?.get('q') ?? undefined;
+ const { q, setSearch, page, setPage } = usePaginatedSearch();
+ const offset = page * PAGE_SIZE;
const {
data: users = [],
isLoading,
isError,
} = useQuery({
- queryKey: queryKeys.admin.users(q),
- queryFn: () => getUsers({ q }),
+ queryKey: queryKeys.admin.users.list({ q: q || undefined, page }),
+ queryFn: () => getUsers({ q: q || undefined, limit: PAGE_SIZE, offset }),
});
+ const hasPrev = page > 0;
+ const hasNext = users.length === PAGE_SIZE;
+
return (
@@ -114,7 +143,7 @@ export default function UsersPage() {
-
+
{isError ? (
Failed to load users. Check that the API is reachable.
@@ -139,7 +168,7 @@ export default function UsersPage() {
Joined
-
+
@@ -155,10 +184,34 @@ export default function UsersPage() {
-
- {users.length.toLocaleString()} user{users.length !== 1 ? 's' : ''}
- {q ? ` matching "${q}"` : ''}
-
+
+
+ {users.length === 0
+ ? `No users${q ? ` matching "${q}"` : ''}`
+ : `${(offset + 1).toLocaleString()}–${(offset + users.length).toLocaleString()} users${q ? ` matching "${q}"` : ''}`}
+
+
+ setPage(page - 1)}
+ disabled={!hasPrev}
+ >
+
+ Prev
+
+ Page {page + 1}
+ setPage(page + 1)}
+ disabled={!hasNext}
+ >
+ Next
+
+
+
+
>
)}
diff --git a/apps/admin/app/error.tsx b/apps/admin/app/error.tsx
new file mode 100644
index 0000000000..fa88e82241
--- /dev/null
+++ b/apps/admin/app/error.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { Button } from '@packrat/web-ui/components/button';
+import { useEffect } from 'react';
+
+export default function RootError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error('App error:', error);
+ }, [error]);
+
+ return (
+
+
Something went wrong
+
+ {error.message || 'An unexpected error occurred.'}
+
+
+ Try again
+
+
+ );
+}
diff --git a/apps/admin/app/global-error.tsx b/apps/admin/app/global-error.tsx
new file mode 100644
index 0000000000..1527c878c3
--- /dev/null
+++ b/apps/admin/app/global-error.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error('Global error:', error);
+ }, [error]);
+
+ return (
+
+
+
Something went wrong
+
+ An unexpected error occurred.
+
+
+ Try again
+
+
+
+ );
+}
diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx
index 532c0fff6f..3cc26dd33f 100644
--- a/apps/admin/app/layout.tsx
+++ b/apps/admin/app/layout.tsx
@@ -3,6 +3,7 @@ import { QueryProvider } from 'admin-app/components/query-provider';
import { ThemeProvider } from 'admin-app/components/theme-provider';
import type { Metadata } from 'next';
import { Mona_Sans as FontSans } from 'next/font/google';
+import { NuqsAdapter } from 'nuqs/adapters/next/app';
import type React from 'react';
import './globals.css';
@@ -29,16 +30,18 @@ export default function RootLayout({
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
diff --git a/apps/admin/components/analytics/catalog-analytics.tsx b/apps/admin/components/analytics/catalog-analytics.tsx
index 73d2221f88..1b517624dc 100644
--- a/apps/admin/components/analytics/catalog-analytics.tsx
+++ b/apps/admin/components/analytics/catalog-analytics.tsx
@@ -1,6 +1,7 @@
'use client';
import { Badge } from '@packrat/web-ui/components/badge';
+import { Button } from '@packrat/web-ui/components/button';
import {
Card,
CardContent,
@@ -16,6 +17,8 @@ import {
ChartTooltip,
ChartTooltipContent,
} from '@packrat/web-ui/components/chart';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { RawObjectDialog } from 'admin-app/components/raw-object-dialog';
import {
useCatalogBrands,
useCatalogEmbeddings,
@@ -23,6 +26,9 @@ import {
useCatalogOverview,
useCatalogPrices,
} from 'admin-app/hooks/use-catalog-analytics';
+import { resetStuckEtlJobs } from 'admin-app/lib/api';
+import { queryKeys } from 'admin-app/lib/queryKeys';
+import { RotateCcw } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts';
const priceConfig: ChartConfig = {
@@ -49,12 +55,25 @@ function statusBadgeVariant(status: string): 'default' | 'secondary' | 'destruct
}
export function CatalogAnalytics() {
+ const queryClient = useQueryClient();
const { data: overview } = useCatalogOverview();
const { data: brands } = useCatalogBrands(15);
const { data: prices } = useCatalogPrices();
const { data: etl } = useCatalogEtl(15);
const { data: embeddings } = useCatalogEmbeddings();
+ const {
+ mutate: resetStuck,
+ isPending: isResetting,
+ isError: resetFailed,
+ data: resetResult,
+ } = useMutation({
+ mutationFn: resetStuckEtlJobs,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.catalogAnalytics.etl.all() });
+ },
+ });
+
const availConfig: ChartConfig = Object.fromEntries(
(overview?.availability ?? []).map((a, i) => [
a.status ?? 'unknown',
@@ -250,12 +269,35 @@ export function CatalogAnalytics() {
{etl && (
- ETL Pipeline
-
- {etl.summary.totalRuns} total runs — {etl.summary.completed} completed,{' '}
- {etl.summary.failed} failed — {etl.summary.totalItemsIngested.toLocaleString()}{' '}
- items ingested
-
+
+
+ ETL Pipeline
+
+ {etl.summary.totalRuns} total runs — {etl.summary.completed} completed,{' '}
+ {etl.summary.failed} failed —{' '}
+ {etl.summary.totalItemsIngested.toLocaleString()} items ingested
+ {resetFailed && — reset failed }
+ {!resetFailed && resetResult && resetResult.reset > 0 && (
+
+ — reset {resetResult.reset} stuck job{resetResult.reset !== 1 ? 's' : ''}
+
+ )}
+ {!resetFailed && resetResult && resetResult.reset === 0 && (
+ — no stuck jobs found
+ )}
+
+
+
resetStuck()}
+ disabled={isResetting}
+ className="shrink-0"
+ >
+
+ Reset Stuck
+
+
@@ -266,8 +308,11 @@ export function CatalogAnalytics() {
Status
Processed
Valid
+
Invalid
Success %
Started
+
Completed
+
@@ -285,12 +330,27 @@ export function CatalogAnalytics() {
{job.totalValid?.toLocaleString() ?? '—'}
+
+ {job.totalInvalid != null ? (
+ 0 ? 'text-destructive' : ''}>
+ {job.totalInvalid.toLocaleString()}
+
+ ) : (
+ '—'
+ )}
+
{job.successRate != null ? `${job.successRate}%` : '—'}
-
+
{new Date(job.startedAt).toLocaleDateString()}
+
+ {job.completedAt ? new Date(job.completedAt).toLocaleDateString() : '—'}
+
+
+
+
))}
diff --git a/apps/admin/components/edit-catalog-dialog.tsx b/apps/admin/components/edit-catalog-dialog.tsx
index b35d414650..e03ff86c5f 100644
--- a/apps/admin/components/edit-catalog-dialog.tsx
+++ b/apps/admin/components/edit-catalog-dialog.tsx
@@ -29,7 +29,7 @@ export function EditCatalogDialog({ item }: EditCatalogDialogProps) {
const { mutate, isPending } = useMutation({
mutationFn: (data: Parameters
[1]) => updateCatalogItem(item.id, data),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog.all() });
setOpen(false);
},
});
diff --git a/apps/admin/components/error-fallback.tsx b/apps/admin/components/error-fallback.tsx
new file mode 100644
index 0000000000..91127ec1a6
--- /dev/null
+++ b/apps/admin/components/error-fallback.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { Button } from '@packrat/web-ui/components/button';
+import { useEffect } from 'react';
+import type { FallbackProps } from 'react-error-boundary';
+
+export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
+ useEffect(() => {
+ console.error('Dashboard error:', error);
+ }, [error]);
+
+ return (
+
+
Something went wrong
+
An unexpected error occurred.
+
+ Try again
+
+
+ );
+}
diff --git a/apps/admin/components/raw-object-dialog.tsx b/apps/admin/components/raw-object-dialog.tsx
new file mode 100644
index 0000000000..023aa8922c
--- /dev/null
+++ b/apps/admin/components/raw-object-dialog.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { Button } from '@packrat/web-ui/components/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@packrat/web-ui/components/dialog';
+import { Braces } from 'lucide-react';
+
+interface RawObjectDialogProps {
+ label: string;
+ data: unknown;
+}
+
+export function RawObjectDialog({ label, data }: RawObjectDialogProps) {
+ return (
+
+
+
+
+ View raw {label}
+
+
+
+
+ {label}
+ Raw JSON data for {label}
+
+
+
+ {JSON.stringify(data, null, 2)}
+
+
+
+
+ );
+}
diff --git a/apps/admin/components/search-input.tsx b/apps/admin/components/search-input.tsx
index a6c383eff2..1074ec87b6 100644
--- a/apps/admin/components/search-input.tsx
+++ b/apps/admin/components/search-input.tsx
@@ -2,59 +2,38 @@
import { Input } from '@packrat/web-ui/components/input';
import { Search } from 'lucide-react';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { Suspense, useCallback, useTransition } from 'react';
+import { parseAsString, useQueryState } from 'nuqs';
interface SearchInputProps {
placeholder?: string;
paramKey?: string;
+ onSearch?: (value: string) => void;
}
-// Inner component — must be inside because it calls useSearchParams()
-function SearchInputInner({ placeholder = 'Search…', paramKey = 'q' }: SearchInputProps) {
- const router = useRouter();
- const searchParams = useSearchParams();
- const [, startTransition] = useTransition();
-
- const value = searchParams?.get(paramKey) ?? '';
-
- const handleChange = useCallback(
- (e: React.ChangeEvent) => {
- const next = new URLSearchParams(searchParams?.toString() ?? '');
- if (e.target.value) {
- next.set(paramKey, e.target.value);
- } else {
- next.delete(paramKey);
- }
- startTransition(() => {
- router.replace(`?${next.toString()}`, { scroll: false });
- });
- },
- [router, searchParams, paramKey],
+export function SearchInput({
+ placeholder = 'Search…',
+ paramKey = 'q',
+ onSearch,
+}: SearchInputProps) {
+ const [value, setValue] = useQueryState(
+ paramKey,
+ parseAsString
+ .withDefault('')
+ .withOptions({ shallow: false, throttleMs: 300, clearOnDefault: true }),
);
- return (
-
-
-
-
- );
-}
+ function handleChange(e: React.ChangeEvent) {
+ if (onSearch) {
+ onSearch(e.target.value);
+ } else {
+ void setValue(e.target.value || null);
+ }
+ }
-// Fallback shown while the inner component suspends
-function SearchInputFallback({ placeholder }: Pick) {
return (
-
+
);
}
-
-export function SearchInput(props: SearchInputProps) {
- return (
- }>
-
-
- );
-}
diff --git a/apps/admin/hooks/use-catalog-analytics.ts b/apps/admin/hooks/use-catalog-analytics.ts
index 05408adffb..e3af5eb693 100644
--- a/apps/admin/hooks/use-catalog-analytics.ts
+++ b/apps/admin/hooks/use-catalog-analytics.ts
@@ -12,7 +12,7 @@ import { queryKeys } from 'admin-app/lib/queryKeys';
export function useCatalogOverview() {
return useQuery({
- queryKey: queryKeys.catalogAnalytics.overview,
+ queryKey: queryKeys.catalogAnalytics.overview(),
queryFn: () => getCatalogOverview(),
});
}
@@ -26,21 +26,21 @@ export function useCatalogBrands(limit = 20) {
export function useCatalogPrices() {
return useQuery({
- queryKey: queryKeys.catalogAnalytics.prices,
+ queryKey: queryKeys.catalogAnalytics.prices(),
queryFn: () => getCatalogPrices(),
});
}
export function useCatalogEtl(limit = 20) {
return useQuery({
- queryKey: queryKeys.catalogAnalytics.etl(limit),
+ queryKey: queryKeys.catalogAnalytics.etl.list(limit),
queryFn: () => getCatalogEtl(limit),
});
}
export function useCatalogEmbeddings() {
return useQuery({
- queryKey: queryKeys.catalogAnalytics.embeddings,
+ queryKey: queryKeys.catalogAnalytics.embeddings(),
queryFn: () => getCatalogEmbeddings(),
});
}
diff --git a/apps/admin/hooks/use-paginated-search.ts b/apps/admin/hooks/use-paginated-search.ts
new file mode 100644
index 0000000000..7cfbfa3c50
--- /dev/null
+++ b/apps/admin/hooks/use-paginated-search.ts
@@ -0,0 +1,26 @@
+'use client';
+
+import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
+import { useCallback } from 'react';
+
+const parsers = {
+ q: parseAsString.withDefault(''),
+ page: parseAsInteger.withDefault(0),
+};
+
+export function usePaginatedSearch() {
+ const [{ q, page }, setParams] = useQueryStates(parsers, { shallow: false });
+
+ // Setting q resets page atomically so users never land on a stale page.
+ const setSearch = useCallback(
+ (next: string) => setParams({ q: next || null, page: null }),
+ [setParams],
+ );
+
+ return {
+ q,
+ setSearch,
+ page: Math.max(0, page),
+ setPage: (next: number) => setParams({ page: next > 0 ? next : null }),
+ };
+}
diff --git a/apps/admin/hooks/use-platform-analytics.ts b/apps/admin/hooks/use-platform-analytics.ts
index a2721f43f4..68f889eea4 100644
--- a/apps/admin/hooks/use-platform-analytics.ts
+++ b/apps/admin/hooks/use-platform-analytics.ts
@@ -20,7 +20,7 @@ export function usePlatformActivity(period: 'day' | 'week' | 'month') {
export function usePlatformBreakdown() {
return useQuery({
- queryKey: queryKeys.platform.breakdown,
+ queryKey: queryKeys.platform.breakdown(),
queryFn: () => getPlatformBreakdown(),
});
}
diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts
index 3087a08894..2941f5f610 100644
--- a/apps/admin/lib/api.ts
+++ b/apps/admin/lib/api.ts
@@ -57,7 +57,9 @@ export interface AdminUser {
lastName: string | null;
role: string | null;
emailVerified: boolean | null;
+ avatarUrl: string | null;
createdAt: string | null;
+ updatedAt: string | null;
}
export function getUsers({
@@ -86,7 +88,11 @@ export interface AdminPack {
description: string | null;
category: string;
isPublic: boolean | null;
+ isAIGenerated: boolean;
+ tags: string[] | null;
+ image: string | null;
createdAt: string | null;
+ updatedAt: string | null;
userEmail: string | null;
}
@@ -113,11 +119,27 @@ export function deletePack(id: string): Promise<{ success: boolean }> {
export interface AdminCatalogItem {
id: number;
name: string;
+ description: string | null;
categories: string[] | null;
brand: string | null;
+ model: string | null;
+ sku: string | null;
price: number | null;
+ currency: string | null;
weight: number | null;
weightUnit: string;
+ availability: string | null;
+ ratingValue: number | null;
+ reviewCount: number | null;
+ color: string | null;
+ size: string | null;
+ material: string | null;
+ seller: string | null;
+ productUrl: string | null;
+ images: string[] | null;
+ variants: Array<{ attribute: string; values: string[] }> | null;
+ techs: Record | null;
+ links: Array<{ title: string; url: string }> | null;
createdAt: string | null;
}
@@ -246,3 +268,7 @@ export function getCatalogEtl(limit = 20): Promise {
export function getCatalogEmbeddings(): Promise {
return adminFetch('/analytics/catalog/embeddings');
}
+
+export function resetStuckEtlJobs(): Promise<{ reset: number; ids: string[] }> {
+ return adminFetch('/analytics/catalog/etl/reset-stuck', { method: 'POST' });
+}
diff --git a/apps/admin/lib/cfAccess.ts b/apps/admin/lib/cfAccess.ts
index aeffab874f..503ccefeaf 100644
--- a/apps/admin/lib/cfAccess.ts
+++ b/apps/admin/lib/cfAccess.ts
@@ -60,7 +60,7 @@ export async function isBehindCFAccess(): Promise {
*/
export function useCFAccessIdentity() {
return useQuery({
- queryKey: queryKeys.cfAccessIdentity,
+ queryKey: queryKeys.cfAccessIdentity(),
queryFn: getCFAccessIdentity,
staleTime: Infinity,
gcTime: Infinity,
diff --git a/apps/admin/lib/queryKeys.ts b/apps/admin/lib/queryKeys.ts
index cf2c9adceb..e19046e9e3 100644
--- a/apps/admin/lib/queryKeys.ts
+++ b/apps/admin/lib/queryKeys.ts
@@ -1,35 +1,52 @@
/**
- * Centralised query key registry for the admin SPA.
+ * Query key factory following the TkDodo self-referencing pattern.
+ * Each level spreads its parent so invalidating a parent truly invalidates all children.
*
- * All useQuery / useInfiniteQuery / invalidateQueries call sites should
- * reference these constants instead of inlining raw arrays. This makes
- * key shape changes a one-place edit and prevents typo-driven cache misses.
+ * @see https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories
*/
export const queryKeys = {
- /** CF Access identity — fetched once per page lifetime. */
- cfAccessIdentity: ['cf-access-identity'] as const,
+ cfAccessIdentity: () => ['cfAccessIdentity'] as const,
- /** Admin entity queries (dashboard pages). */
admin: {
- stats: ['admin', 'stats'] as const,
- users: (limitOrQuery?: number | string) => ['admin', 'users', limitOrQuery] as const,
- packs: (limitOrQuery?: number | string) => ['admin', 'packs', limitOrQuery] as const,
- catalog: (limitOrQuery?: number | string) => ['admin', 'catalog', limitOrQuery] as const,
+ all: () => ['admin'] as const,
+ stats: () => [...queryKeys.admin.all(), 'stats'] as const,
+
+ users: {
+ all: () => [...queryKeys.admin.all(), 'users'] as const,
+ list: (params?: { q?: string; page?: number; limit?: number }) =>
+ [...queryKeys.admin.users.all(), params] as const,
+ },
+
+ packs: {
+ all: () => [...queryKeys.admin.all(), 'packs'] as const,
+ list: (params?: { q?: string; page?: number; limit?: number }) =>
+ [...queryKeys.admin.packs.all(), params] as const,
+ },
+
+ catalog: {
+ all: () => [...queryKeys.admin.all(), 'catalog'] as const,
+ list: (params?: { q?: string; page?: number; limit?: number }) =>
+ [...queryKeys.admin.catalog.all(), params] as const,
+ },
},
- /** Platform analytics queries. */
platform: {
- growth: (period: string) => ['platform', 'growth', period] as const,
- activity: (period: string) => ['platform', 'activity', period] as const,
- breakdown: ['platform', 'breakdown'] as const,
+ all: () => ['platform'] as const,
+ growth: (period: string) => [...queryKeys.platform.all(), 'growth', period] as const,
+ activity: (period: string) => [...queryKeys.platform.all(), 'activity', period] as const,
+ breakdown: () => [...queryKeys.platform.all(), 'breakdown'] as const,
},
- /** Catalog analytics queries. */
catalogAnalytics: {
- overview: ['catalog', 'overview'] as const,
- brands: (limit?: number) => ['catalog', 'brands', limit] as const,
- prices: ['catalog', 'prices'] as const,
- etl: (limit?: number) => ['catalog', 'etl', limit] as const,
- embeddings: ['catalog', 'embeddings'] as const,
+ all: () => ['catalogAnalytics'] as const,
+ overview: () => [...queryKeys.catalogAnalytics.all(), 'overview'] as const,
+ brands: (limit?: number) => [...queryKeys.catalogAnalytics.all(), 'brands', limit] as const,
+ prices: () => [...queryKeys.catalogAnalytics.all(), 'prices'] as const,
+ embeddings: () => [...queryKeys.catalogAnalytics.all(), 'embeddings'] as const,
+
+ etl: {
+ all: () => [...queryKeys.catalogAnalytics.all(), 'etl'] as const,
+ list: (limit?: number) => [...queryKeys.catalogAnalytics.etl.all(), limit] as const,
+ },
},
-} as const;
+};
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 87c22f8003..739c3d5648 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -33,8 +33,10 @@
"lucide-react": "^1.8.0",
"next": "^15.3.4",
"next-themes": "^0.4.6",
+ "nuqs": "^2.8.9",
"react": "catalog:",
"react-dom": "catalog:",
+ "react-error-boundary": "^6.1.1",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
diff --git a/bun.lock b/bun.lock
index f8749fdf22..955293518a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -43,8 +43,10 @@
"lucide-react": "^1.8.0",
"next": "^15.3.4",
"next-themes": "^0.4.6",
+ "nuqs": "^2.8.9",
"react": "catalog:",
"react-dom": "catalog:",
+ "react-error-boundary": "^6.1.1",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -1741,7 +1743,7 @@
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
- "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -3221,6 +3223,8 @@
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
+ "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="],
+
"ob1": ["ob1@0.83.6", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-m/xZYkwcjo6UqLMrUICEB3iHk7Bjt3RSR7KXMi6Y1MO/kGkPhoRmfUDF6KAan3rLAZ7ABRqnQyKUTwaqZgUV4w=="],
"object-assign": ["object-assign@4.0.1", "", {}, "sha512-c6legOHWepAbWnp3j5SRUMpxCXBKI4rD7A5Osn9IzZ8w4O/KccXdW0lqdkQKbpk0eHGjNgKihgzY6WuEq99Tfw=="],
@@ -3433,6 +3437,8 @@
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
+ "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="],
+
"react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
@@ -4039,6 +4045,8 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="],
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="],
@@ -4217,6 +4225,8 @@
"@react-navigation/routers/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "@reduxjs/toolkit/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -4683,6 +4693,10 @@
"@manypkg/tools/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+ "@react-native-ai/apple/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "@react-native-ai/llama/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@react-native/codegen/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"@react-native/dev-middleware/serve-static/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
diff --git a/packages/api/scripts/reset-stuck-etl-jobs.sql b/packages/api/scripts/reset-stuck-etl-jobs.sql
deleted file mode 100644
index f5595b867e..0000000000
--- a/packages/api/scripts/reset-stuck-etl-jobs.sql
+++ /dev/null
@@ -1,7 +0,0 @@
--- Reset ETL jobs stuck in 'running' state for more than 3 hours.
--- 3h accounts for large first-time imports (~500K rows + embedding generation).
--- Run manually when zombie jobs are detected.
-UPDATE etl_jobs
-SET status = 'failed', completed_at = NOW()
-WHERE status = 'running'
- AND started_at < NOW() - INTERVAL '3 hours';
diff --git a/packages/api/src/routes/admin/analytics/catalog.ts b/packages/api/src/routes/admin/analytics/catalog.ts
index 40baab1c60..21aa098d0f 100644
--- a/packages/api/src/routes/admin/analytics/catalog.ts
+++ b/packages/api/src/routes/admin/analytics/catalog.ts
@@ -1,6 +1,6 @@
import { createDb } from '@packrat/api/db';
import { catalogItems, etlJobs } from '@packrat/api/db/schema';
-import { and, avg, count, desc, gt, isNotNull, max, min, sql } from 'drizzle-orm';
+import { and, avg, count, desc, eq, gt, isNotNull, lt, max, min, sql } from 'drizzle-orm';
import { Elysia, status } from 'elysia';
import { z } from 'zod';
@@ -242,4 +242,26 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' })
}
},
{ detail: { tags: ['Admin'], summary: 'Embedding coverage' } },
+ )
+
+ .post(
+ '/etl/reset-stuck',
+ async () => {
+ const db = createDb();
+ const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
+
+ try {
+ const reset = await db
+ .update(etlJobs)
+ .set({ status: 'failed', completedAt: new Date() })
+ .where(and(eq(etlJobs.status, 'running'), lt(etlJobs.startedAt, threeHoursAgo)))
+ .returning();
+
+ return { reset: reset.length, ids: reset.map((r) => r.id) };
+ } catch (error) {
+ console.error('ETL reset-stuck error:', error);
+ return status(500, { error: 'Failed to reset stuck jobs', code: 'ETL_RESET_STUCK_ERROR' });
+ }
+ },
+ { detail: { tags: ['Admin'], summary: 'Reset ETL jobs stuck in running state for >3 hours' } },
);
diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts
index 06b8d15dab..4e7b58179b 100644
--- a/packages/api/src/routes/admin/index.ts
+++ b/packages/api/src/routes/admin/index.ts
@@ -203,7 +203,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
lastName: users.lastName,
role: users.role,
emailVerified: users.emailVerified,
+ avatarUrl: users.avatarUrl,
createdAt: users.createdAt,
+ updatedAt: users.updatedAt,
})
.from(users)
.where(
@@ -221,7 +223,8 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
return usersList.map((u) => ({
...u,
- createdAt: u.createdAt?.toISOString() || null,
+ createdAt: u.createdAt?.toISOString() ?? null,
+ updatedAt: u.updatedAt?.toISOString() ?? null,
}));
} catch (error) {
console.error('Error fetching users:', error);
@@ -254,7 +257,11 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
description: packs.description,
category: packs.category,
isPublic: packs.isPublic,
+ isAIGenerated: packs.isAIGenerated,
+ tags: packs.tags,
+ image: packs.image,
createdAt: packs.createdAt,
+ updatedAt: packs.updatedAt,
userEmail: users.email,
})
.from(packs)
@@ -278,7 +285,8 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
return packsList.map((p) => ({
...p,
- createdAt: p.createdAt?.toISOString() || null,
+ createdAt: p.createdAt?.toISOString() ?? null,
+ updatedAt: p.updatedAt?.toISOString() ?? null,
}));
} catch (error) {
console.error('Error fetching packs:', error);
@@ -308,11 +316,27 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
.select({
id: catalogItems.id,
name: catalogItems.name,
+ description: catalogItems.description,
categories: catalogItems.categories,
brand: catalogItems.brand,
+ model: catalogItems.model,
+ sku: catalogItems.sku,
price: catalogItems.price,
+ currency: catalogItems.currency,
weight: catalogItems.weight,
weightUnit: catalogItems.weightUnit,
+ availability: catalogItems.availability,
+ ratingValue: catalogItems.ratingValue,
+ reviewCount: catalogItems.reviewCount,
+ color: catalogItems.color,
+ size: catalogItems.size,
+ material: catalogItems.material,
+ seller: catalogItems.seller,
+ productUrl: catalogItems.productUrl,
+ images: catalogItems.images,
+ variants: catalogItems.variants,
+ techs: catalogItems.techs,
+ links: catalogItems.links,
createdAt: catalogItems.createdAt,
})
.from(catalogItems)