29d9d23a26
- CartContext: lazy initializers replace mount-only useEffect hydration; remove 8 no-initialize-state warnings - Toast/AdminSearchInput: React 19 useContext/use + drop forwardRef (3 no-react19-deprecated-apis) - ProductFormModal: lazy initializers + useSyncExternalStore for mount; parent adds key=editingProduct.id - InstallPrompt: useReducer for prompt state (no-cascading-set-state) - QRScanModal: ref-based latest-callback pattern replaces useEffectEvent deps mistake - OnboardingFlow: functional setState (rerender-functional-setstate) - UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers (rerender-lazy-state-init) - FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic in layout; remove supabase import - LandingPageWrapper: href='#' → href='#top' (anchor-is-valid) - TuxedoVideoHero: replace animate-bounce with ease-out-expo (no-inline-bounce-easing) - ProductTableClient: useCallback for handleDeleted (jsx-no-new-function-as-prop) - excel-parser: pre-compile delimiter regexes (js-hoist-regexp) - water-log/settings: Promise.all for parallel DB calls (async-parallel) - ToastNotification: extract toast store to separate file (only-export-components) - WholesaleClient: inline <WholesaleIcon/> instead of hoisting to const (rendering-hoist-jsx)
126 lines
4.0 KiB
TypeScript
126 lines
4.0 KiB
TypeScript
"use server";
|
|
|
|
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
import { withBrand } from "@/db/client";
|
|
import { customers, orders, campaigns } from "@/db/schema";
|
|
import { getSession } from "@/lib/auth";
|
|
|
|
/**
|
|
* Server-side data loader for the per-stop "Message customers" panel.
|
|
* The new schema has no `orders.stop_id` column or
|
|
* `orders.pickup_complete` column, so the legacy "fetch orders for
|
|
* this stop" lookup cannot be reproduced exactly. The new approach:
|
|
* - "Orders" = the tenant's recent orders with a known customer
|
|
* (acts as a generic "people we can message" list).
|
|
* - "Messages" = the tenant's most recent campaigns (used as the
|
|
* recent-message history placeholder).
|
|
* The `MessageCustomersSection` UI degrades gracefully when these
|
|
* lists are empty.
|
|
*/
|
|
|
|
export type StopOrder = {
|
|
id: string;
|
|
customer_name: string;
|
|
customer_email: string | null;
|
|
customer_phone: string | null;
|
|
pickup_complete: boolean;
|
|
};
|
|
|
|
export type StopBlastMessage = {
|
|
id: string;
|
|
type: string;
|
|
subject: string | null;
|
|
body: string;
|
|
created_at: string;
|
|
message_recipients: { id: string }[];
|
|
};
|
|
|
|
export type GetStopMessagingDataResult = {
|
|
success: true;
|
|
orders: StopOrder[];
|
|
messages: StopBlastMessage[];
|
|
} | { success: false; error: string };
|
|
|
|
export async function getStopMessagingData(params: {
|
|
stopId: string;
|
|
brandId?: string;
|
|
}): Promise<GetStopMessagingDataResult> {
|
|
|
|
await getSession(); const adminUser = await getAdminUser();
|
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
|
|
// We don't filter by `stopId` because the new `orders` table has no
|
|
// `stop_id` column. Instead, fall back to "any recent order in the
|
|
// tenant". The caller is responsible for picking the right brand.
|
|
const brandId = params.brandId ?? adminUser.brand_id;
|
|
if (!brandId) {
|
|
return { success: false, error: "Brand context required" };
|
|
}
|
|
|
|
try {
|
|
const [orderRows, campaignRows] = await withBrand(brandId, async (db) => {
|
|
// Independent reads — fire in parallel.
|
|
const [o, c] = await Promise.all([
|
|
db
|
|
.select({
|
|
id: orders.id,
|
|
status: orders.status,
|
|
placedAt: orders.placedAt,
|
|
customerName: customers.fullName,
|
|
customerEmail: customers.email,
|
|
customerPhone: customers.phone,
|
|
})
|
|
.from(orders)
|
|
.innerJoin(customers, eq(customers.id, orders.customerId))
|
|
.where(
|
|
and(
|
|
eq(orders.brandId, brandId),
|
|
isNotNull(customers.email),
|
|
),
|
|
)
|
|
.orderBy(desc(orders.placedAt))
|
|
.limit(50),
|
|
db
|
|
.select({
|
|
id: campaigns.id,
|
|
name: campaigns.name,
|
|
sentAt: campaigns.sentAt,
|
|
updatedAt: campaigns.updatedAt,
|
|
})
|
|
.from(campaigns)
|
|
.where(eq(campaigns.brandId, brandId))
|
|
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
|
.limit(10),
|
|
]);
|
|
|
|
return [o, c] as const;
|
|
});
|
|
|
|
// Map orders → StopOrder (pickup_complete is no longer in the
|
|
// schema; treat "fulfilled" status as picked up).
|
|
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
|
|
id: o.id,
|
|
customer_name: o.customerName ?? "",
|
|
customer_email: o.customerEmail,
|
|
customer_phone: o.customerPhone,
|
|
pickup_complete: o.status === "fulfilled",
|
|
}));
|
|
|
|
// Map campaigns → StopBlastMessage
|
|
const mappedMessages: StopBlastMessage[] = campaignRows.map((c) => ({
|
|
id: c.id,
|
|
type: "campaign",
|
|
subject: c.name,
|
|
body: "",
|
|
created_at: (c.sentAt ?? c.updatedAt).toISOString(),
|
|
message_recipients: [],
|
|
}));
|
|
|
|
void params.stopId; // not used in the new schema
|
|
return { success: true, orders: mappedOrders, messages: mappedMessages };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : "Failed to load stop data" };
|
|
}
|
|
}
|