Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
EditAcademyOnlineAccessFormValues,
CentralIdentityUserLicenseResult,
CentralIdentityAppLicense,
StoreDigitalDeliveryOption, StoreOrderWithStripeSession,
StoreDigitalDeliveryOption, StoreOrderWithStripeSession, StoreOrderListItem,
OrderCharge,
OrderSession,
CentralIdentityOrgAdminResult,
Expand Down Expand Up @@ -762,7 +762,7 @@ class API {
query?: string;
}) {
const res = await axios.get<
ConductorInfiniteScrollResponse<StoreOrderWithStripeSession>
ConductorInfiniteScrollResponse<StoreOrderListItem>
>("/store/admin/orders", {
params,
});
Expand Down
48 changes: 37 additions & 11 deletions client/src/screens/conductor/controlpanel/StoreManager/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { Link } from "react-router-dom";
import { Badge, Breadcrumb, Button, Heading, Select, Stack } from "@libretexts/davis-react";
import { Badge, Breadcrumb, Button, Heading, Input, Select, Stack } from "@libretexts/davis-react";
import type { BadgeVariant } from "@libretexts/davis-react";
import { StoreOrderWithStripeSession } from "../../../../types";
import { StoreOrderListItem } from "../../../../types";
import useGlobalError from "../../../../components/error/ErrorHooks";
import SupportCenterTable from "../../../../components/support/SupportCenterTable";
import { useInfiniteQuery } from "@tanstack/react-query";
import api from "../../../../api";
import useDocumentTitle from "../../../../hooks/useDocumentTitle";
import useDebounce from "../../../../hooks/useDebounce";
import {
IconCloudComputing,
IconDownload,
IconEye,
IconSearch,
} from "@tabler/icons-react";
import { useNotifications } from "../../../../context/NotificationContext";
import { formatPrice, truncateOrderId } from "../../../../utils/storeHelpers";
import { useState } from "react";
import { useMemo, useState } from "react";

function luluStatusVariant(status?: string | null): BadgeVariant {
if (!status) return "default";
Expand All @@ -36,19 +38,29 @@ const StoreManager = () => {
const limit = 25;
const { addNotification } = useNotifications();
const { handleGlobalError } = useGlobalError();
const { debounce } = useDebounce();
const [statusFilter, setStatusFilter] = useState("all");
const [luluStatusFilter, setLuluStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");

// Debounce the value that actually drives the query so each keystroke doesn't refetch.
const debouncedSetQuery = useMemo(
() => debounce((value: string) => setSearchQuery(value.trim()), 400),
[]
);
Comment on lines +48 to +51

const { data, isFetching, isInitialLoading, fetchNextPage } =
useInfiniteQuery({
queryKey: ["store-orders", limit, statusFilter, luluStatusFilter],
queryKey: ["store-orders", limit, statusFilter, luluStatusFilter, searchQuery],
queryFn: async ({ pageParam = null }) => {
const response = await api.adminGetStoreOrders({
limit,
starting_after: pageParam || undefined,
status: statusFilter === "all" ? undefined : statusFilter,
lulu_status:
luluStatusFilter === "all" ? undefined : luluStatusFilter,
query: searchQuery || undefined,
});

if (response.data.err) {
Expand Down Expand Up @@ -82,7 +94,21 @@ const StoreManager = () => {
</Stack>

<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="flex items-center gap-4 px-4 py-3 bg-gray-50 border-b border-gray-200">
<div className="flex flex-wrap items-end gap-4 px-4 py-3 bg-gray-50 border-b border-gray-200">
<div className="grow min-w-[16rem]">
<Input
name="orderSearch"
type="search"
label="Search Orders"
placeholder="Search by order ID, customer email, or Lulu job ID"
value={searchInput}
leftIcon={<IconSearch size={16} />}
onChange={(e) => {
setSearchInput(e.target.value);
debouncedSetQuery(e.target.value);
}}
/>
</div>
<Select
name="luluStatusFilter"
label="Lulu Job Status"
Expand Down Expand Up @@ -113,7 +139,7 @@ const StoreManager = () => {
/>
</div>

<SupportCenterTable<StoreOrderWithStripeSession & { actions?: string }>
<SupportCenterTable<StoreOrderListItem & { actions?: string }>
loading={isInitialLoading}
data={allData || []}
columns={[
Expand Down Expand Up @@ -143,21 +169,21 @@ const StoreManager = () => {
},
},
{
accessor: "stripe_session",
accessor: "customerEmail",
title: "Customer Email",
copyButton: true,
render(record) {
return record.stripe_session?.customer_email || "Unknown";
return record.customerEmail || "Unknown";
},
},
{
accessor: "stripe_session",
accessor: "amountTotal",
title: "Total Amount",
render(record) {
return (
<span>
{record.stripe_session?.amount_total
? formatPrice(record.stripe_session.amount_total, true)
{record.amountTotal
? formatPrice(record.amountTotal, true)
: "$0.00"}
</span>
);
Expand Down
18 changes: 18 additions & 0 deletions client/src/types/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,21 @@ export type StoreOrderWithStripeSession = StoreOrder & {
stripe_session: Stripe.Checkout.Session;
stripe_charge?: Stripe.Charge | null;
}

/**
* Flat, Stripe-free shape returned by the admin order-list endpoint (served from the
* "storeOrders" Meilisearch index). The Store Management table renders these directly —
* no live Stripe session is fetched for the list view.
*/
export type StoreOrderListItem = {
id: string;
status: StoreOrder["status"];
customerEmail?: string;
amountTotal?: number;
currency?: string;
luluJobID?: string;
luluJobStatus?: string;
supportTicketUUID?: string;
createdAt?: string; // ISO string as stored in the index
createdAtTimestamp?: number; // epoch millis
}
4 changes: 4 additions & 0 deletions server/api/search-index-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SupportTicketService from "./services/support-ticket-service.js";
import booksAPI from "./books.js";
import projectsAPI from "./projects.js";
import { syncUsersInBackground } from "./services/user-search-service.js";
import { syncStoreOrdersInBackground } from "./services/store-order-search-service.js";

// Indexes valid for the reinitialize-settings endpoint. Includes search-queries
// alongside the tuple-typed core indexes.
Expand Down Expand Up @@ -190,6 +191,9 @@ async function resyncIndexInBackground(indexName: typeof INDEXES[number]) {
case "users":
await syncUsersInBackground();
break;
case "storeOrders":
await syncStoreOrdersInBackground();
break;
default:
throw new Error(`Unknown index: ${indexName}`);
}
Expand Down
8 changes: 7 additions & 1 deletion server/api/services/search-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { debugServer, debugError } from "../../debug";
import Organization from "../../models/organization";
import { FilterInput, FilterValue } from "../../types";

export const INDEXES = ["books", "projects", "supportTickets", "users"] as const;
export const INDEXES = ["books", "projects", "supportTickets", "users", "storeOrders"] as const;

// Popular-search-terms index. Kept outside INDEXES so its (different) document shape and
// looser typing do not leak into addDocuments/search/getIndexStats, which are tuple-typed.
Expand Down Expand Up @@ -32,27 +32,33 @@ export const INDEX_PRIMARY_KEYS: Record<(typeof INDEXES)[number], string> = {
projects: "projectID",
supportTickets: "uuid",
users: "uuid",
storeOrders: "id",
};

export const INDEX_FILTERABLE_ATTRIBUTES = {
books: ["bookID", "library", "license", "author", "course", "courseNormalized", "affiliation", "location", "license", "subject", "publicAssets", "instructorAssets"],
projects: ["status", "classification", "visibility", "orgID"],
supportTickets: ["queue_id", "status", "priority", "category", "assignedUUIDs"],
users: ["uuid", "emailDomain"],
storeOrders: ["status", "luluJobStatus", "createdAtTimestamp"],
};

export const INDEX_SORTABLE_ATTRIBUTES = {
books: ["bookID", "library", "author", "course", "courseNormalized", "affiliation", "location"],
projects: ["status", "classification", "visibility", "orgID"],
supportTickets: ["status", "category", "timeOpened"],
users: ["firstName", "lastName"],
storeOrders: ["createdAtTimestamp", "amountTotal"],
};

// Per-index searchable-attribute overrides. Indexes absent from this map use the
// Meilisearch default (all fields searchable). The users index opts in explicitly so a
// query can only ever match a name — never the opaque uuid/centralID or the emailDomain.
// storeOrders opts in explicitly so the single admin search box matches only the order id,
// customer email, or Lulu job id — never internal fields like error text.
export const INDEX_SEARCHABLE_ATTRIBUTES: Partial<Record<(typeof INDEXES)[number], string[]>> = {
users: ["firstName", "lastName"],
storeOrders: ["id", "customerEmail", "luluJobID"],
};

export default class SearchService {
Expand Down
133 changes: 133 additions & 0 deletions server/api/services/store-order-search-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import StoreOrder from "../../models/storeorder.js";
import SearchService from "./search-service.js";
import { debugError, debugServer } from "../../debug.js";

/**
* Store-order search index ("storeOrders").
*
* This index backs the superadmin Store Management table. Unlike the "users" index, it
* DELIBERATELY contains customer PII (email) and order data, so it MUST NEVER be exposed to
* the browser or any client-facing Meilisearch key/tenant token. It is queried exclusively
* server-side through the `checkHasRoleMiddleware("libretexts", "superadmin")`-guarded
* `/store/admin/orders` endpoint. MongoDB (the StoreOrder collection) is the source of truth;
* this index is fully regenerable from it via the admin "Re-sync" button.
*
* Keeping the index in sync is a nicety, never a critical path: the incremental helpers
* (`upsertStoreOrderToSearchIndex` / `removeStoreOrderFromSearchIndex`) swallow and log every
* error and MUST be called fire-and-forget so a Meilisearch hiccup can never fail, delay, or
* throw into order processing (Stripe webhooks, Lulu webhooks, etc.). `syncStoreOrdersInBackground`
* rebuilds the index from scratch.
*/

// Pipeline stages shared by the full resync and the single-order upsert. The $project shapes
// the flat index document: only fields the admin table needs, plus a numeric `createdAtTimestamp`
// that Meilisearch can sort/filter on (Meilisearch cannot sort on ISO Date objects).
export const storeOrderSearchIndexAggregationStages: any[] = [
{
$project: {
_id: 0,
id: 1,
status: 1,
customerEmail: 1,
amountTotal: 1,
currency: 1,
luluJobID: 1,
luluJobStatus: 1,
supportTicketUUID: 1,
createdAt: 1,
// Numeric epoch millis — sortable/filterable in Meilisearch. Falls back to 0 when createdAt
// is somehow missing so a document never fails to sort.
createdAtTimestamp: { $toLong: { $ifNull: ["$createdAt", new Date(0)] } },
},
},
];

/**
* Rebuilds the entire storeOrders search index from MongoDB in batches. Used by the admin
* "Re-sync" control. Throws on failure so the admin endpoint can surface a meaningful error.
*/
export async function syncStoreOrdersInBackground(): Promise<void> {
try {
debugServer("Initiating Store Orders search index sync...");
const searchService = await SearchService.getInstance();

const batchSize = 500;
let skip = 0;
let hasMore = true;
let totalSynced = 0;

while (hasMore) {
const orders = await StoreOrder.aggregate([
{ $sort: { _id: 1 } },
...storeOrderSearchIndexAggregationStages,
{ $skip: skip },
{ $limit: batchSize },
]);

if (orders.length === 0) {
hasMore = false;
break;
}

// Strip ObjectIds/Dates so Meilisearch document validation doesn't choke.
const sanitized = JSON.parse(JSON.stringify(orders));
await searchService.addDocuments("storeOrders", sanitized);
totalSynced += orders.length;
debugServer(`Synced batch of ${orders.length} store orders (${totalSynced} total)...`);

skip += batchSize;
if (orders.length < batchSize) {
hasMore = false;
}
}

debugServer(`Store Orders search index sync completed. Total synced: ${totalSynced}`);
} catch (e) {
debugError(`Error in syncStoreOrdersInBackground: ${e}`);
throw e;
}
}

/**
* Upserts a single order into the search index. Best-effort: swallows and logs all errors,
* never throws. MUST be called fire-and-forget (do not await in a request/webhook path).
*
* If the order no longer exists, it is removed instead — keeping the index from going stale.
*/
export async function upsertStoreOrderToSearchIndex(id: string): Promise<void> {
try {
if (!id) return;
const searchService = await SearchService.getInstance();

const results = await StoreOrder.aggregate([
{ $match: { id } },
...storeOrderSearchIndexAggregationStages,
]);

const doc = results?.[0];
if (!doc) {
// No longer exists — make sure it isn't lingering in the index.
await searchService.deleteDocuments("storeOrders", [id]);
return;
}

const sanitized = JSON.parse(JSON.stringify(doc));
await searchService.addDocuments("storeOrders", [sanitized]);
} catch (err) {
debugError(`[StoreOrderSearchService] Error upserting order ${id} to search index: ${err}`);
}
}

/**
* Removes a single order from the search index. Best-effort: swallows and logs all errors,
* never throws. MUST be called fire-and-forget (do not await in a request/webhook path).
*/
export async function removeStoreOrderFromSearchIndex(id: string): Promise<void> {
try {
if (!id) return;
const searchService = await SearchService.getInstance();
await searchService.deleteDocuments("storeOrders", [id]);
} catch (err) {
debugError(`[StoreOrderSearchService] Error removing order ${id} from search index: ${err}`);
}
}
Loading
Loading