feat: add Stalker results page with filtering and pagination
- Introduced StalkerResultItem and StalkerResultsResponse types for handling API responses. - Implemented StalkerExplorer component for displaying Stalker results with search and filter options. - Added sorting functionality for Stalker results table. - Enhanced Dashboard to include a button for navigating to Stalker results. - Updated routing to support Stalker results page. - Improved styles for section headers and inventory columns in the results table.
This commit is contained in:
194
src/server.ts
194
src/server.ts
@@ -53,6 +53,31 @@ type ProductListRecord = {
|
||||
fetched_at: string;
|
||||
};
|
||||
|
||||
type StalkerResultRecord = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
status: string;
|
||||
input_file: string;
|
||||
source_asin: string;
|
||||
title: string | null;
|
||||
offer_count: number;
|
||||
candidate_seller_count: number;
|
||||
matched_seller_count: number;
|
||||
scan_fetched_at: string;
|
||||
seller_id: string;
|
||||
seller_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
storefront_asin_total: number | null;
|
||||
persisted_inventory_sample_count: number | null;
|
||||
offer_price: number | null;
|
||||
condition: string | null;
|
||||
is_fba: number | null;
|
||||
stock: number | null;
|
||||
persisted_inventory_asin_count: number;
|
||||
inventory_sample_asins: string | null;
|
||||
};
|
||||
|
||||
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
@@ -629,6 +654,170 @@ function getProductList(filters: URLSearchParams) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerFilters(filters: URLSearchParams) {
|
||||
const q = filters.get("q")?.trim() || "";
|
||||
const sellerId = filters.get("sellerId")?.trim().toUpperCase() || "";
|
||||
const runIdRaw = filters.get("runId")?.trim() || "";
|
||||
const minRatingCountRaw = filters.get("minRatingCount")?.trim() || "";
|
||||
const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || "";
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
|
||||
if (runIdRaw) {
|
||||
const runId = Number(runIdRaw);
|
||||
if (Number.isInteger(runId) && runId > 0) {
|
||||
conditions.push("r.id = ?");
|
||||
params.push(runId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sellerId) {
|
||||
conditions.push("s.seller_id = ?");
|
||||
params.push(sellerId);
|
||||
}
|
||||
|
||||
if (minRatingCountRaw) {
|
||||
conditions.push("s.rating_count >= ?");
|
||||
params.push(Number(minRatingCountRaw));
|
||||
}
|
||||
|
||||
if (maxRatingCountRaw) {
|
||||
conditions.push("s.rating_count <= ?");
|
||||
params.push(Number(maxRatingCountRaw));
|
||||
}
|
||||
|
||||
if (q) {
|
||||
const wildcard = `%${q}%`;
|
||||
conditions.push(
|
||||
`(sc.source_asin LIKE ? OR sc.title LIKE ? OR s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS (
|
||||
SELECT 1 FROM stalker_seller_inventory inv_q
|
||||
WHERE inv_q.run_id = r.id
|
||||
AND inv_q.seller_id = s.seller_id
|
||||
AND inv_q.asin LIKE ?
|
||||
))`,
|
||||
);
|
||||
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||
}
|
||||
|
||||
return {
|
||||
where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStalkerSort(sortParam: string | null): string {
|
||||
const allowedSort = new Set([
|
||||
"runId",
|
||||
"started_at",
|
||||
"source_asin",
|
||||
"title",
|
||||
"seller_id",
|
||||
"seller_name",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"offer_price",
|
||||
"stock",
|
||||
"persisted_inventory_asin_count",
|
||||
"storefront_asin_total",
|
||||
"scan_fetched_at",
|
||||
]);
|
||||
const parsed = parseSort(
|
||||
sortParam,
|
||||
allowedSort,
|
||||
"started_at DESC, runId DESC, source_asin ASC",
|
||||
);
|
||||
|
||||
return parsed
|
||||
.replaceAll("runId", "runId")
|
||||
.replaceAll("rating_count", "rating_count")
|
||||
.replaceAll("persisted_inventory_asin_count", "persisted_inventory_asin_count")
|
||||
.replaceAll("storefront_asin_total", "storefront_asin_total");
|
||||
}
|
||||
|
||||
function getStalkerResults(filters: URLSearchParams) {
|
||||
const page = parseIntParam(filters.get("page"), 1);
|
||||
const pageSize = Math.min(
|
||||
parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE),
|
||||
MAX_PAGE_SIZE,
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const { where, params } = parseStalkerFilters(filters);
|
||||
const orderBy = parseStalkerSort(filters.get("sort"));
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
r.id AS runId,
|
||||
r.started_at,
|
||||
r.status,
|
||||
r.input_file,
|
||||
sc.source_asin,
|
||||
sc.title,
|
||||
sc.offer_count,
|
||||
sc.candidate_seller_count,
|
||||
sc.matched_seller_count,
|
||||
sc.fetched_at AS scan_fetched_at,
|
||||
s.seller_id,
|
||||
s.seller_name,
|
||||
s.rating,
|
||||
s.rating_count,
|
||||
s.storefront_asin_total,
|
||||
s.persisted_inventory_sample_count,
|
||||
sas.offer_price,
|
||||
sas.condition,
|
||||
sas.is_fba,
|
||||
sas.stock,
|
||||
COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count,
|
||||
GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins
|
||||
FROM stalker_asin_sellers sas
|
||||
JOIN stalker_asin_scans sc ON sc.id = sas.scan_id
|
||||
JOIN stalker_runs r ON r.id = sc.run_id
|
||||
JOIN stalker_sellers s ON s.seller_id = sas.seller_id
|
||||
LEFT JOIN stalker_seller_inventory inv
|
||||
ON inv.run_id = r.id
|
||||
AND inv.seller_id = s.seller_id
|
||||
${where}
|
||||
GROUP BY sas.id
|
||||
`;
|
||||
|
||||
const totalRow = db
|
||||
.query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_rows`)
|
||||
.get(...params) as { total: number };
|
||||
|
||||
const summary = db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(DISTINCT runId) AS runs,
|
||||
COUNT(DISTINCT source_asin) AS sourceAsins,
|
||||
COUNT(DISTINCT seller_id) AS sellers,
|
||||
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
|
||||
FROM (${baseSelect}) stalker_rows`,
|
||||
)
|
||||
.get(...params) as {
|
||||
runs: number;
|
||||
sourceAsins: number;
|
||||
sellers: number;
|
||||
persistedInventoryAsins: number;
|
||||
};
|
||||
|
||||
const items = db
|
||||
.query(
|
||||
`SELECT * FROM (${baseSelect}) stalker_rows
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, offset) as StalkerResultRecord[];
|
||||
|
||||
return {
|
||||
items,
|
||||
summary,
|
||||
page,
|
||||
pageSize,
|
||||
total: totalRow.total,
|
||||
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
function getRun(processType: ProcessType, runId: number) {
|
||||
if (processType === "lead_analysis") {
|
||||
const run = db
|
||||
@@ -1259,6 +1448,7 @@ const server = Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/products": index,
|
||||
"/stalker": index,
|
||||
"/runs/:processType/:runId": index,
|
||||
"/api/runs": (req) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -1268,6 +1458,10 @@ const server = Bun.serve({
|
||||
const url = new URL(req.url);
|
||||
return json(getProductList(url.searchParams));
|
||||
},
|
||||
"/api/stalker/results": (req) => {
|
||||
const url = new URL(req.url);
|
||||
return json(getStalkerResults(url.searchParams));
|
||||
},
|
||||
"/api/upc/map": async (req) => {
|
||||
let upcs: string[];
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user