@mohasinac/appkit 4.62.1 → 4.63.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +8 -29
- package/dist/_internal/client/features/layout/filterNavItems.d.ts +28 -0
- package/dist/_internal/client/features/layout/filterNavItems.js +35 -0
- package/dist/_internal/server/features/ads/booking.d.ts +118 -0
- package/dist/_internal/server/features/ads/booking.js +326 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +51 -11
- package/dist/_internal/server/features/checkout/actions.js +335 -80
- package/dist/_internal/server/features/checkout/expand-order-items.d.ts +1 -2
- package/dist/_internal/server/features/checkout/expand-order-items.js +32 -0
- package/dist/_internal/server/features/checkout/freeze-parts.d.ts +50 -0
- package/dist/_internal/server/features/checkout/freeze-parts.js +94 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.d.ts +55 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.js +60 -0
- package/dist/_internal/server/features/dashboard/attention.d.ts +128 -0
- package/dist/_internal/server/features/dashboard/attention.js +183 -0
- package/dist/_internal/server/features/dashboard/data.d.ts +47 -0
- package/dist/_internal/server/features/dashboard/data.js +82 -0
- package/dist/_internal/server/features/inventory/reservations.d.ts +99 -0
- package/dist/_internal/server/features/inventory/reservations.js +273 -0
- package/dist/_internal/server/features/offers/destinations.d.ts +40 -0
- package/dist/_internal/server/features/offers/destinations.js +65 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
- package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
- package/dist/_internal/server/features/orders/balance.d.ts +89 -0
- package/dist/_internal/server/features/orders/balance.js +256 -0
- package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
- package/dist/_internal/server/features/pricing/actions.js +144 -0
- package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
- package/dist/_internal/server/features/shipping/book-split.js +130 -0
- package/dist/_internal/server/features/shipping/book.d.ts +61 -0
- package/dist/_internal/server/features/shipping/book.js +176 -0
- package/dist/_internal/server/features/shipping/courier-block.js +26 -1
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +14 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +14 -0
- package/dist/_internal/server/functions/scheduled.d.ts +2 -1
- package/dist/_internal/server/functions/scheduled.js +57 -1
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +2 -0
- package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +4 -1
- package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
- package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +15 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +268 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +31 -2
- package/dist/_internal/shared/checkout/order-math.js +22 -36
- package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
- package/dist/_internal/shared/checkout/rules/index.js +1 -1
- package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
- package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
- package/dist/_internal/shared/checkout/split.d.ts +0 -12
- package/dist/_internal/shared/checkout/split.js +22 -2
- package/dist/_internal/shared/content/disclaimers.js +7 -7
- package/dist/_internal/shared/content/published-values.d.ts +6 -1
- package/dist/_internal/shared/content/published-values.js +8 -0
- package/dist/_internal/shared/features/admin/integrations.d.ts +32 -2
- package/dist/_internal/shared/features/admin/integrations.js +19 -9
- package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
- package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
- package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
- package/dist/_internal/shared/features/emi/choice.js +69 -0
- package/dist/_internal/shared/features/emi/group.d.ts +84 -0
- package/dist/_internal/shared/features/emi/group.js +126 -0
- package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
- package/dist/_internal/shared/features/ledger/posting.js +165 -0
- package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
- package/dist/_internal/shared/features/ledger/reports.js +127 -0
- package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
- package/dist/_internal/shared/features/ledger/rules.js +221 -0
- package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
- package/dist/_internal/shared/features/offers/coordination.js +80 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
- package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
- package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
- package/dist/_internal/shared/features/points/accepts.d.ts +1 -8
- package/dist/_internal/shared/features/points/accepts.js +12 -6
- package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
- package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
- package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
- package/dist/_internal/shared/features/shipping/parcel.js +139 -0
- package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
- package/dist/_internal/shared/features/site-settings/fees.js +12 -8
- package/dist/_internal/shared/fees/explain.d.ts +81 -0
- package/dist/_internal/shared/fees/explain.js +101 -0
- package/dist/_internal/shared/history/index.d.ts +1 -1
- package/dist/_internal/shared/history/index.js +1 -1
- package/dist/_internal/shared/history/types.d.ts +7 -1
- package/dist/_internal/shared/history/types.js +25 -0
- package/dist/client.d.ts +11 -1
- package/dist/client.js +13 -0
- package/dist/constants/api-endpoints.d.ts +72 -0
- package/dist/constants/api-endpoints.js +41 -0
- package/dist/constants/field-names.d.ts +41 -0
- package/dist/constants/field-names.js +47 -0
- package/dist/features/account/components/UserOrdersView.js +28 -2
- package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
- package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +16 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +72 -3
- package/dist/features/admin/components/AdminOrdersView.js +20 -1
- package/dist/features/admin/schemas/firestore.d.ts +32 -0
- package/dist/features/ads/schemas/booking-write.d.ts +157 -0
- package/dist/features/ads/schemas/booking-write.js +88 -0
- package/dist/features/ads/schemas/creative-form.d.ts +18 -0
- package/dist/features/ads/schemas/creative-form.js +51 -0
- package/dist/features/ads/schemas/firestore.d.ts +140 -0
- package/dist/features/ads/schemas/firestore.js +109 -0
- package/dist/features/auctions/repository/bid.repository.js +3 -1
- package/dist/features/audit-log/schemas/firestore.d.ts +110 -0
- package/dist/features/audit-log/schemas/firestore.js +74 -0
- package/dist/features/auth/actions/profile-actions.d.ts +69 -1
- package/dist/features/auth/actions/profile-actions.js +62 -1
- package/dist/features/auth/schemas/firestore.d.ts +16 -2
- package/dist/features/auth/schemas/firestore.js +2 -1
- package/dist/features/blog/actions/blog-actions.d.ts +6 -0
- package/dist/features/blog/actions/blog-actions.js +17 -2
- package/dist/features/blog/api/route.js +4 -1
- package/dist/features/blog/columns/index.js +5 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -1
- package/dist/features/blog/repository/blog.repository.d.ts +4 -0
- package/dist/features/blog/repository/blog.repository.js +5 -0
- package/dist/features/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +1 -0
- package/dist/features/blog/schemas/firestore.d.ts +38 -1
- package/dist/features/blog/schemas/firestore.js +23 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +2 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +12 -1
- package/dist/features/cart/components/CartPriceBreakdown.js +24 -5
- package/dist/features/cart/repository/cart.repository.d.ts +14 -0
- package/dist/features/cart/repository/cart.repository.js +20 -0
- package/dist/features/cart/schemas/firestore.d.ts +24 -0
- package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
- package/dist/features/dashboard/components/DashboardHubView.js +56 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +24 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +28 -0
- package/dist/features/dispute-reasons/schemas/firestore.d.ts +3 -5
- package/dist/features/events/components/EventSaleBanner.js +1 -1
- package/dist/features/faq/components/FAQPageContent.js +2 -2
- package/dist/features/gateway-events/schemas/index.d.ts +2 -2
- package/dist/features/homepage/schemas/index.d.ts +8 -8
- package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
- package/dist/features/inventory/schemas/stock-reservation.js +103 -0
- package/dist/features/layout/AppLayoutShell.d.ts +9 -1
- package/dist/features/layout/AppLayoutShell.js +12 -4
- package/dist/features/layout/BottomActions.js +2 -1
- package/dist/features/layout/BottomNavbar.d.ts +10 -2
- package/dist/features/layout/SideActionDock.js +52 -2
- package/dist/features/layout/TitleBarLayout.js +13 -4
- package/dist/features/layout/TitleBarLayout.style.css +59 -0
- package/dist/features/layout/useCtaReveal.d.ts +16 -1
- package/dist/features/layout/useCtaReveal.js +17 -26
- package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
- package/dist/features/ledger/repository/journal.repository.js +312 -0
- package/dist/features/ledger/schemas/expenses.d.ts +87 -0
- package/dist/features/ledger/schemas/expenses.js +27 -0
- package/dist/features/ledger/schemas/firestore.d.ts +183 -0
- package/dist/features/ledger/schemas/firestore.js +69 -0
- package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
- package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
- package/dist/features/orders/components/RaiseDisputeForm.js +19 -11
- package/dist/features/orders/repository/orders.repository.d.ts +28 -1
- package/dist/features/orders/repository/orders.repository.js +60 -6
- package/dist/features/orders/schemas/balance.d.ts +40 -0
- package/dist/features/orders/schemas/balance.js +47 -0
- package/dist/features/orders/schemas/firestore.d.ts +202 -0
- package/dist/features/orders/schemas/firestore.js +43 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +21 -0
- package/dist/features/payments/repository/payout.repository.js +3 -2
- package/dist/features/payments/schemas/firestore.d.ts +92 -1
- package/dist/features/payments/schemas/firestore.js +23 -0
- package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
- package/dist/features/pricing/components/AdminPricingView.js +157 -0
- package/dist/features/products/schemas/firestore.d.ts +26 -1
- package/dist/features/products/schemas/firestore.js +2 -0
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
- package/dist/features/reviews/repository/reviews.repository.js +16 -0
- package/dist/features/scams/repository/scammer.repository.js +2 -1
- package/dist/features/scams/schemas/firestore.d.ts +2 -1
- package/dist/features/scams/schemas/firestore.js +2 -1
- package/dist/features/seller/actions/offer-actions.js +56 -0
- package/dist/features/seller/actions/seller-actions.d.ts +1 -1
- package/dist/features/seller/actions/seller-actions.js +4 -0
- package/dist/features/seller/components/DispatchForm.d.ts +11 -0
- package/dist/features/seller/components/DispatchForm.js +190 -0
- package/dist/features/seller/components/SellerOrdersView.js +94 -2
- package/dist/features/seller/components/index.d.ts +1 -0
- package/dist/features/seller/components/index.js +1 -0
- package/dist/features/seller/repository/offer.repository.js +4 -3
- package/dist/features/seller/schemas/firestore.d.ts +23 -2
- package/dist/features/seller/schemas/firestore.js +3 -2
- package/dist/features/seller/schemas/index.d.ts +4 -4
- package/dist/features/seller/schemas/order-forms.d.ts +61 -4
- package/dist/features/seller/schemas/order-forms.js +41 -8
- package/dist/features/shipments/schemas/firestore.d.ts +25 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +39 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +55 -0
- package/dist/features/shipping-boxes/schemas/firestore.d.ts +2 -4
- package/dist/features/stores/repository/store.repository.d.ts +18 -0
- package/dist/features/stores/repository/store.repository.js +27 -1
- package/dist/features/stores/schemas/firestore.d.ts +41 -0
- package/dist/features/stores/schemas/firestore.js +20 -0
- package/dist/features/stores/schemas/index.d.ts +6 -6
- package/dist/features/support/repository/support.repository.js +72 -6
- package/dist/features/support/schemas/firestore.d.ts +17 -0
- package/dist/features/support/schemas/index.d.ts +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +19 -0
- package/dist/next/routing/route-map.d.ts +92 -0
- package/dist/next/routing/route-map.js +53 -0
- package/dist/react/contexts/SessionContext.d.ts +17 -1
- package/dist/schemas/registry.d.ts +22 -16
- package/dist/security/pii-schemas.d.ts +1 -1
- package/dist/security/pii-schemas.js +45 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +10 -1
- package/dist/server.d.ts +29 -1
- package/dist/server.js +158 -0
- package/dist/styles.css +349 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/Button.d.ts +10 -1
- package/dist/ui/components/Button.js +25 -3
- package/dist/ui/components/Chart.d.ts +63 -0
- package/dist/ui/components/Chart.js +169 -0
- package/dist/ui/components/Chart.style.css +214 -0
- package/dist/ui/components/Disclaimer.js +10 -1
- package/dist/ui/components/OverflowRow.js +54 -2
- package/dist/ui/components/OverflowRow.style.css +38 -0
- package/dist/ui/components/RichTextEditor.js +99 -8
- package/dist/ui/components/chart-spec.d.ts +68 -0
- package/dist/ui/components/chart-spec.js +140 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/components/surface-tokens.d.ts +1 -1
- package/dist/ui/components/surface-tokens.js +9 -0
- package/dist/ui/components/toolbar-collapse.d.ts +13 -0
- package/dist/ui/components/toolbar-collapse.js +10 -1
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/rich-text/RichText.d.ts +10 -1
- package/dist/ui/rich-text/RichText.js +33 -2
- package/dist/ui/rich-text/RichText.style.css +35 -0
- package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
- package/dist/ui/rich-text/RichTextRenderer.js +34 -2
- package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
- package/dist/ui/rich-text/chart-blocks.js +87 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +3 -0
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.63.1",
|
|
4
|
+
"head": "4851d3d585790224397f1dcb19e6dee274fe9d60",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
7
|
+
"builtAt": "2026-10-23T08:10:33.829Z"
|
|
8
8
|
}
|
|
@@ -30,7 +30,7 @@ import { AdminSidebar } from "../../../../features/admin/components/AdminSidebar
|
|
|
30
30
|
import { StoreSidebar } from "../../../../features/seller/components/SellerSidebar.js";
|
|
31
31
|
import { UserSidebar } from "../../../../features/account/components/UserSidebar.js";
|
|
32
32
|
import { DASHBOARD_DESKTOP_MEDIA_QUERY } from "../../../shared/features/layout/config.js";
|
|
33
|
-
import {
|
|
33
|
+
import { filterNavGroups } from "./filterNavItems.js";
|
|
34
34
|
import { useSiteSettings } from "../../../../core/hooks/useSiteSettings.js";
|
|
35
35
|
import { useTheme } from "../../theme/index.js";
|
|
36
36
|
import { useHandMode } from "../../hand-mode/index.js";
|
|
@@ -119,33 +119,12 @@ function useResponsiveDrawer(storageKey) {
|
|
|
119
119
|
}, [registerNav, unregisterNav, open, close, toggle]);
|
|
120
120
|
return { desktopOpen, mobileOpen, close, closeMobile, toggle };
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
* The early return below encoded the convention correctly and then applied it
|
|
129
|
-
* only when `!navConfig`. The moment site settings carried ANY nav config,
|
|
130
|
-
* an admin fell through to `filterNavItems(..., undefined)` — and
|
|
131
|
-
* `undefined?.includes(x)` is falsy, so every item carrying a
|
|
132
|
-
* `requiredPermission` was dropped. The Admin Panel sidebar rendered empty
|
|
133
|
-
* while every admin page stayed reachable by URL.
|
|
134
|
-
*
|
|
135
|
-
* Passing `isAdmin` keeps the admin's navConfig toggles working (disabling an
|
|
136
|
-
* entry is a configuration decision, not a permission) while granting the
|
|
137
|
-
* permissions an admin implicitly holds.
|
|
138
|
-
*/
|
|
139
|
-
const isAdmin = permissions === null;
|
|
140
|
-
if (isAdmin && !navConfig)
|
|
141
|
-
return groups;
|
|
142
|
-
return groups
|
|
143
|
-
.map((group) => ({
|
|
144
|
-
...group,
|
|
145
|
-
items: filterNavItems(group.items, navConfig, permissions ?? undefined, isAdmin),
|
|
146
|
-
}))
|
|
147
|
-
.filter((group) => group.items.length > 0);
|
|
148
|
-
}
|
|
122
|
+
/*
|
|
123
|
+
* The group filter MOVED to `filterNavItems.ts` as `filterNavGroups` when the
|
|
124
|
+
* dashboard hubs needed the same answer. It is imported, never re-derived —
|
|
125
|
+
* a hub whose cards disagreed with this sidebar would promise a page that then
|
|
126
|
+
* 403s. See that function's own header for the `permissions === null` lesson.
|
|
127
|
+
*/
|
|
149
128
|
/*
|
|
150
129
|
* ── Content clearance for the desktop sidebar ───────────────────────────────
|
|
151
130
|
*
|
|
@@ -238,7 +217,7 @@ responsive: _responsive, crossNav, className, contentPadding, contentSurface, co
|
|
|
238
217
|
const resolvedLightBackground = lightBackground ?? settings?.background?.light;
|
|
239
218
|
const resolvedDarkBackground = darkBackground ?? settings?.background?.dark;
|
|
240
219
|
const hasBackground = Boolean(resolvedLightBackground?.value || resolvedDarkBackground?.value);
|
|
241
|
-
const filteredGroups =
|
|
220
|
+
const filteredGroups = filterNavGroups(groups, navConfig, permissions);
|
|
242
221
|
const adminGroups = variant === "admin"
|
|
243
222
|
? filteredGroups
|
|
244
223
|
: groups;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SidebarNavGroup } from "../../../shared/features/layout/types";
|
|
1
2
|
type FilterableItem = {
|
|
2
3
|
id?: string;
|
|
3
4
|
requiredPermission?: string;
|
|
@@ -33,4 +34,31 @@ type FilterableItem = {
|
|
|
33
34
|
export declare function filterNavItems<T extends FilterableItem>(items: T[], navConfig: Record<string, {
|
|
34
35
|
enabled: boolean;
|
|
35
36
|
}> | undefined, userPermissions: string[] | undefined, isAdmin?: boolean): T[];
|
|
37
|
+
/**
|
|
38
|
+
* The GROUP-level filter — the sidebar's and the dashboard hubs' single answer
|
|
39
|
+
* to "which sections and links may this viewer see".
|
|
40
|
+
*
|
|
41
|
+
* ## 🛑 `permissions === null` MEANS ADMIN, and that is why this is shared
|
|
42
|
+
*
|
|
43
|
+
* This lived as a module-private `filterGroups` inside
|
|
44
|
+
* `DashboardLayoutClient.tsx` until the dashboard hubs needed the same answer.
|
|
45
|
+
* Copying it would have been Root Cause #75's shape exactly — a rule whose own
|
|
46
|
+
* comment says it is the convention, reachable only by copy-paste, where the
|
|
47
|
+
* copy made today never learns about tomorrow's branch. And the branch it would
|
|
48
|
+
* have lost is the expensive one: the `isAdmin && !navConfig` early return was
|
|
49
|
+
* ONCE applied only when no navConfig existed, and the moment site settings
|
|
50
|
+
* carried any nav config an admin's whole sidebar filtered to empty.
|
|
51
|
+
*
|
|
52
|
+
* A hub that disagreed with the sidebar would be worse than that, not better:
|
|
53
|
+
* the sidebar is a list of links, while a hub card is a promise that the page
|
|
54
|
+
* behind it will open. A card for a page that then 403s is a dead end the
|
|
55
|
+
* viewer cannot distinguish from a broken permission.
|
|
56
|
+
*
|
|
57
|
+
* An empty group is dropped rather than rendered as a heading with nothing
|
|
58
|
+
* under it — on a hub that is a section card claiming "0 screens", which reads
|
|
59
|
+
* as a fault rather than as "not for you".
|
|
60
|
+
*/
|
|
61
|
+
export declare function filterNavGroups<T extends SidebarNavGroup>(groups: T[], navConfig: Record<string, {
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
}> | undefined, permissions: string[] | null | undefined): T[];
|
|
36
64
|
export {};
|
|
@@ -41,3 +41,38 @@ export function filterNavItems(items, navConfig, userPermissions, isAdmin = fals
|
|
|
41
41
|
return true;
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* The GROUP-level filter — the sidebar's and the dashboard hubs' single answer
|
|
46
|
+
* to "which sections and links may this viewer see".
|
|
47
|
+
*
|
|
48
|
+
* ## 🛑 `permissions === null` MEANS ADMIN, and that is why this is shared
|
|
49
|
+
*
|
|
50
|
+
* This lived as a module-private `filterGroups` inside
|
|
51
|
+
* `DashboardLayoutClient.tsx` until the dashboard hubs needed the same answer.
|
|
52
|
+
* Copying it would have been Root Cause #75's shape exactly — a rule whose own
|
|
53
|
+
* comment says it is the convention, reachable only by copy-paste, where the
|
|
54
|
+
* copy made today never learns about tomorrow's branch. And the branch it would
|
|
55
|
+
* have lost is the expensive one: the `isAdmin && !navConfig` early return was
|
|
56
|
+
* ONCE applied only when no navConfig existed, and the moment site settings
|
|
57
|
+
* carried any nav config an admin's whole sidebar filtered to empty.
|
|
58
|
+
*
|
|
59
|
+
* A hub that disagreed with the sidebar would be worse than that, not better:
|
|
60
|
+
* the sidebar is a list of links, while a hub card is a promise that the page
|
|
61
|
+
* behind it will open. A card for a page that then 403s is a dead end the
|
|
62
|
+
* viewer cannot distinguish from a broken permission.
|
|
63
|
+
*
|
|
64
|
+
* An empty group is dropped rather than rendered as a heading with nothing
|
|
65
|
+
* under it — on a hub that is a section card claiming "0 screens", which reads
|
|
66
|
+
* as a fault rather than as "not for you".
|
|
67
|
+
*/
|
|
68
|
+
export function filterNavGroups(groups, navConfig, permissions) {
|
|
69
|
+
const isAdmin = permissions === null;
|
|
70
|
+
if (isAdmin && !navConfig)
|
|
71
|
+
return groups;
|
|
72
|
+
return groups
|
|
73
|
+
.map((group) => ({
|
|
74
|
+
...group,
|
|
75
|
+
items: filterNavItems(group.items, navConfig, permissions ?? undefined, isAdmin),
|
|
76
|
+
}))
|
|
77
|
+
.filter((group) => group.items.length > 0);
|
|
78
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D56 — self-serve ad bookings: take a slot, moderate the creative, bill it.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 Availability is a DOCUMENT COLLISION, not a query
|
|
5
|
+
*
|
|
6
|
+
* The obvious implementation of "two advertisers must not book overlapping
|
|
7
|
+
* dates" is to query for an overlap and write when none is found. That is a
|
|
8
|
+
* read-then-write race: two concurrent bookings both read "free" and both
|
|
9
|
+
* write, and the slot is sold twice with nothing erroring. It is invisible
|
|
10
|
+
* in any test that does not run two requests at the same instant.
|
|
11
|
+
*
|
|
12
|
+
* So the booking id is `${placementId}__${period}` and creation uses
|
|
13
|
+
* `tx.create`, which throws `ALREADY_EXISTS` on a document that is already
|
|
14
|
+
* there. Firestore does the locking; there is no window between the check
|
|
15
|
+
* and the write because they are the same operation.
|
|
16
|
+
*
|
|
17
|
+
* ## 🛑 Nothing renders until a human approves the creative
|
|
18
|
+
*
|
|
19
|
+
* `isAdBookingLive` requires `approved` AND `paid` AND in-period AND
|
|
20
|
+
* not-cancelled, and the advertiser's write schema cannot express the first
|
|
21
|
+
* two. A self-serve surface where the submitter sets their own moderation
|
|
22
|
+
* state is a form with an extra field, not a moderated system.
|
|
23
|
+
*/
|
|
24
|
+
import { type AdBookingCreative, type AdBookingDocument, type AdPeriod } from "../../../../features/ads/schemas/firestore";
|
|
25
|
+
export interface BookAdSlotInput {
|
|
26
|
+
placementId: string;
|
|
27
|
+
period: AdPeriod;
|
|
28
|
+
advertiserUid: string;
|
|
29
|
+
advertiserName?: string;
|
|
30
|
+
creative: AdBookingCreative;
|
|
31
|
+
/**
|
|
32
|
+
* Admin override — book on an advertiser's behalf. The plan requires it:
|
|
33
|
+
* *"a self-serve flow with no manual escape hatch has no way to fix a
|
|
34
|
+
* mistake."* It changes who may call, never what is written.
|
|
35
|
+
*/
|
|
36
|
+
byAdmin?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Take a slot. Returns the booking id.
|
|
40
|
+
*
|
|
41
|
+
* Throws **409** when the slot is taken (a well-formed request against a
|
|
42
|
+
* world that moved — the same distinction the lottery config write makes for
|
|
43
|
+
* a pulled slot) and **400** for a past period or an unknown placement.
|
|
44
|
+
*/
|
|
45
|
+
export declare function bookAdSlot(input: BookAdSlotInput): Promise<string>;
|
|
46
|
+
/**
|
|
47
|
+
* A moderator's verdict. **This is the only writer of `moderation.state`.**
|
|
48
|
+
*/
|
|
49
|
+
export declare function moderateAdCreative(bookingId: string, input: {
|
|
50
|
+
approve: boolean;
|
|
51
|
+
note?: string;
|
|
52
|
+
reviewerUid: string;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Re-submit a rejected creative.
|
|
56
|
+
*
|
|
57
|
+
* 🛑 Resets moderation to `pending` in the SAME write. A creative edited
|
|
58
|
+
* while `approved` would otherwise go live unreviewed — change the image,
|
|
59
|
+
* keep the approval. That is the whole attack, and it needs no cleverness:
|
|
60
|
+
* submit something bland, get approved, edit.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resubmitAdCreative(bookingId: string, advertiserUid: string, creative: AdBookingCreative): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Cancel a booking. **The slot stays locked for its period** — see
|
|
65
|
+
* `AD_BOOKING_CANCELLED_KEEPS_SLOT`. Releasing it is a separate, deliberate
|
|
66
|
+
* admin act.
|
|
67
|
+
*/
|
|
68
|
+
export declare function cancelAdBooking(bookingId: string, opts: {
|
|
69
|
+
actorUid: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
byAdmin?: boolean;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* 🛑 ADMIN ONLY, and it DELETES — this is the one path that frees a period.
|
|
75
|
+
*
|
|
76
|
+
* Cancelling keeps the lock because the advertiser paid for a month that may
|
|
77
|
+
* already be running. Releasing says "that month is for sale again", which
|
|
78
|
+
* is a commercial decision with a refund behind it, so it is separate,
|
|
79
|
+
* explicit, and logged rather than a side effect of a cancel button.
|
|
80
|
+
*/
|
|
81
|
+
export declare function releaseAdSlot(placementId: string, period: AdPeriod, actorUid: string): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* What should actually render in a placement right now.
|
|
84
|
+
*
|
|
85
|
+
* 🛑 ONE document read, by derived id — no query, no index, no scan. The
|
|
86
|
+
* slot's occupant for this month is computable from the placement and the
|
|
87
|
+
* date, which is the second reason the period model is worth its
|
|
88
|
+
* coarseness: the render path of an ad costs the same as reading one row.
|
|
89
|
+
*/
|
|
90
|
+
export declare function liveBookingForPlacement(placementId: string, now?: Date): Promise<AdBookingDocument | null>;
|
|
91
|
+
/** The moderation queue. */
|
|
92
|
+
export declare function pendingAdBookings(limit?: number): Promise<AdBookingDocument[]>;
|
|
93
|
+
/** An advertiser's own bookings. */
|
|
94
|
+
export declare function adBookingsForAdvertiser(advertiserUid: string, limit?: number): Promise<AdBookingDocument[]>;
|
|
95
|
+
export interface AdSlotOffer {
|
|
96
|
+
placementId: string;
|
|
97
|
+
label: string;
|
|
98
|
+
priceRsPerPeriod: number;
|
|
99
|
+
/** `YYYY-MM` → is it still free? */
|
|
100
|
+
availability: {
|
|
101
|
+
period: AdPeriod;
|
|
102
|
+
available: boolean;
|
|
103
|
+
}[];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The rate card plus availability — what an `/advertise` page renders.
|
|
107
|
+
*
|
|
108
|
+
* 🛑 One read per OFFERED SLOT, and the slot count is bounded by
|
|
109
|
+
* `placements × months`, both small and both known. The id is derived, so
|
|
110
|
+
* this is a `getAll` of specific documents rather than a query — there is no
|
|
111
|
+
* scan, no index, and no page of results to cap.
|
|
112
|
+
*
|
|
113
|
+
* Unpriced and disabled placements are OMITTED rather than shown greyed out:
|
|
114
|
+
* a card for something that cannot be bought is a dead facet, and
|
|
115
|
+
* `brand-rows.ts` already records why that is worse than absent — "it
|
|
116
|
+
* renders as a card in the grid that opens an empty page".
|
|
117
|
+
*/
|
|
118
|
+
export declare function adSlotOffers(months?: number, now?: Date): Promise<AdSlotOffer[]>;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D56 — self-serve ad bookings: take a slot, moderate the creative, bill it.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 Availability is a DOCUMENT COLLISION, not a query
|
|
5
|
+
*
|
|
6
|
+
* The obvious implementation of "two advertisers must not book overlapping
|
|
7
|
+
* dates" is to query for an overlap and write when none is found. That is a
|
|
8
|
+
* read-then-write race: two concurrent bookings both read "free" and both
|
|
9
|
+
* write, and the slot is sold twice with nothing erroring. It is invisible
|
|
10
|
+
* in any test that does not run two requests at the same instant.
|
|
11
|
+
*
|
|
12
|
+
* So the booking id is `${placementId}__${period}` and creation uses
|
|
13
|
+
* `tx.create`, which throws `ALREADY_EXISTS` on a document that is already
|
|
14
|
+
* there. Firestore does the locking; there is no window between the check
|
|
15
|
+
* and the write because they are the same operation.
|
|
16
|
+
*
|
|
17
|
+
* ## 🛑 Nothing renders until a human approves the creative
|
|
18
|
+
*
|
|
19
|
+
* `isAdBookingLive` requires `approved` AND `paid` AND in-period AND
|
|
20
|
+
* not-cancelled, and the advertiser's write schema cannot express the first
|
|
21
|
+
* two. A self-serve surface where the submitter sets their own moderation
|
|
22
|
+
* state is a form with an extra field, not a moderated system.
|
|
23
|
+
*/
|
|
24
|
+
import { getAdminDb } from "../../../../providers/db-firebase/index.js";
|
|
25
|
+
import { ValidationError } from "../../../../errors/validation-error.js";
|
|
26
|
+
import { NotFoundError } from "../../../../errors/not-found-error.js";
|
|
27
|
+
import { ConflictError } from "../../../../errors/conflict-error.js";
|
|
28
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
29
|
+
import { serverLogger } from "../../../../monitoring/server-logger.js";
|
|
30
|
+
import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
|
|
31
|
+
import { AD_BOOKING_FIELDS } from "../../../../constants/field-names.js";
|
|
32
|
+
/*
|
|
33
|
+
* 🛑 ONE message for both "no such booking" and "not yours", and that is the
|
|
34
|
+
* whole point of naming it.
|
|
35
|
+
*
|
|
36
|
+
* A booking the caller does not own answers 404, never 403 — a 403 confirms
|
|
37
|
+
* the id exists, which turns a guessable id into an enumeration oracle for
|
|
38
|
+
* who is advertising where. The two branches must therefore be
|
|
39
|
+
* indistinguishable, and two string literals that merely happen to match
|
|
40
|
+
* today are one careless edit away from telling them apart.
|
|
41
|
+
*/
|
|
42
|
+
const BOOKING_NOT_FOUND = "Booking not found.";
|
|
43
|
+
import { AD_BOOKING_COLLECTION, adBookingId, adPeriodOf, adPeriodStart, isAdBookingLive, isAdPeriod, } from "../../../../features/ads/schemas/firestore.js";
|
|
44
|
+
/**
|
|
45
|
+
* Take a slot. Returns the booking id.
|
|
46
|
+
*
|
|
47
|
+
* Throws **409** when the slot is taken (a well-formed request against a
|
|
48
|
+
* world that moved — the same distinction the lottery config write makes for
|
|
49
|
+
* a pulled slot) and **400** for a past period or an unknown placement.
|
|
50
|
+
*/
|
|
51
|
+
export async function bookAdSlot(input) {
|
|
52
|
+
if (!isAdPeriod(input.period)) {
|
|
53
|
+
throw new ValidationError("Pick a month, as YYYY-MM.");
|
|
54
|
+
}
|
|
55
|
+
/*
|
|
56
|
+
* 🛑 The CURRENT period is bookable, a past one is not.
|
|
57
|
+
*
|
|
58
|
+
* Comparing period STARTS rather than "is the period over" is deliberate:
|
|
59
|
+
* booking the running month is a legitimate late sale, and the slot simply
|
|
60
|
+
* runs for whatever is left of it. Refusing it would mean the only
|
|
61
|
+
* bookable month is next month, which is not a rule anybody asked for.
|
|
62
|
+
*/
|
|
63
|
+
if (adPeriodStart(input.period) < adPeriodStart(adPeriodOf(new Date()))) {
|
|
64
|
+
throw new ValidationError("That month has already passed.");
|
|
65
|
+
}
|
|
66
|
+
const settings = await siteSettingsRepository.getSingleton();
|
|
67
|
+
const placement = settings?.adSettings?.placements?.find((p) => p.id === input.placementId);
|
|
68
|
+
if (!placement)
|
|
69
|
+
throw new NotFoundError("No such ad placement.");
|
|
70
|
+
/*
|
|
71
|
+
* A disabled placement renders nothing, so selling it would take money for
|
|
72
|
+
* a slot that cannot appear. An admin override does NOT bypass this —
|
|
73
|
+
* the escape hatch exists to fix a booking, not to sell a dead slot.
|
|
74
|
+
*/
|
|
75
|
+
if (!placement.enabled) {
|
|
76
|
+
throw new ValidationError("That placement is not currently sold.");
|
|
77
|
+
}
|
|
78
|
+
const priceRs = resolveSlotPrice(placement);
|
|
79
|
+
const db = getAdminDb();
|
|
80
|
+
const id = adBookingId(input.placementId, input.period);
|
|
81
|
+
const ref = db.collection(AD_BOOKING_COLLECTION).doc(id);
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const doc = {
|
|
84
|
+
placementId: input.placementId,
|
|
85
|
+
period: input.period,
|
|
86
|
+
advertiserUid: input.advertiserUid,
|
|
87
|
+
...(input.advertiserName ? { advertiserName: input.advertiserName } : {}),
|
|
88
|
+
priceRs,
|
|
89
|
+
paymentState: "unpaid",
|
|
90
|
+
creative: input.creative,
|
|
91
|
+
/*
|
|
92
|
+
* 🛑 Always `pending`, including on the admin path. An admin booking on
|
|
93
|
+
* an advertiser's behalf is still an unreviewed creative — letting the
|
|
94
|
+
* override imply approval would make the escape hatch the way around
|
|
95
|
+
* moderation, which is how every escape hatch eventually gets used.
|
|
96
|
+
*/
|
|
97
|
+
moderation: { state: "pending" },
|
|
98
|
+
createdAt: now,
|
|
99
|
+
updatedAt: now,
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
await db.runTransaction(async (tx) => {
|
|
103
|
+
/*
|
|
104
|
+
* `tx.create`, NOT `tx.set`. `set` overwrites, which would silently
|
|
105
|
+
* replace another advertiser's paid booking with this one — the exact
|
|
106
|
+
* failure the lock exists to prevent, spelled one method name apart.
|
|
107
|
+
*/
|
|
108
|
+
tx.create(ref, doc);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const normalized = normalizeError(error);
|
|
113
|
+
if (/already exists/i.test(normalized.message)) {
|
|
114
|
+
throw new ConflictError("That slot is already booked for this month.");
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
return id;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A moderator's verdict. **This is the only writer of `moderation.state`.**
|
|
122
|
+
*/
|
|
123
|
+
export async function moderateAdCreative(bookingId, input) {
|
|
124
|
+
const note = input.note?.trim();
|
|
125
|
+
if (!input.approve && (note?.length ?? 0) < 10) {
|
|
126
|
+
/*
|
|
127
|
+
* Enforced here AND in the Zod schema. The schema protects the route;
|
|
128
|
+
* this protects every other caller, and a rejection with no reason
|
|
129
|
+
* guarantees the advertiser resubmits the same creative (Root Cause
|
|
130
|
+
* #74's shape — a prediction with no mechanism is unenforced).
|
|
131
|
+
*/
|
|
132
|
+
throw new ValidationError("Say why it was rejected.");
|
|
133
|
+
}
|
|
134
|
+
const db = getAdminDb();
|
|
135
|
+
const ref = db.collection(AD_BOOKING_COLLECTION).doc(bookingId);
|
|
136
|
+
await db.runTransaction(async (tx) => {
|
|
137
|
+
const snap = await tx.get(ref);
|
|
138
|
+
if (!snap.exists)
|
|
139
|
+
throw new NotFoundError(BOOKING_NOT_FOUND);
|
|
140
|
+
const booking = snap.data();
|
|
141
|
+
if (booking.cancelledAt) {
|
|
142
|
+
throw new ConflictError("That booking was cancelled.");
|
|
143
|
+
}
|
|
144
|
+
tx.update(ref, {
|
|
145
|
+
"moderation.state": input.approve ? "approved" : "rejected",
|
|
146
|
+
"moderation.reviewedAt": new Date(),
|
|
147
|
+
"moderation.reviewedBy": input.reviewerUid,
|
|
148
|
+
...(note ? { "moderation.note": note } : {}),
|
|
149
|
+
updatedAt: new Date(),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Re-submit a rejected creative.
|
|
155
|
+
*
|
|
156
|
+
* 🛑 Resets moderation to `pending` in the SAME write. A creative edited
|
|
157
|
+
* while `approved` would otherwise go live unreviewed — change the image,
|
|
158
|
+
* keep the approval. That is the whole attack, and it needs no cleverness:
|
|
159
|
+
* submit something bland, get approved, edit.
|
|
160
|
+
*/
|
|
161
|
+
export async function resubmitAdCreative(bookingId, advertiserUid, creative) {
|
|
162
|
+
const db = getAdminDb();
|
|
163
|
+
const ref = db.collection(AD_BOOKING_COLLECTION).doc(bookingId);
|
|
164
|
+
await db.runTransaction(async (tx) => {
|
|
165
|
+
const snap = await tx.get(ref);
|
|
166
|
+
if (!snap.exists)
|
|
167
|
+
throw new NotFoundError(BOOKING_NOT_FOUND);
|
|
168
|
+
const booking = snap.data();
|
|
169
|
+
if (booking.advertiserUid !== advertiserUid) {
|
|
170
|
+
throw new NotFoundError(BOOKING_NOT_FOUND);
|
|
171
|
+
}
|
|
172
|
+
if (booking.cancelledAt)
|
|
173
|
+
throw new ConflictError("That booking was cancelled.");
|
|
174
|
+
tx.update(ref, {
|
|
175
|
+
creative,
|
|
176
|
+
"moderation.state": "pending",
|
|
177
|
+
"moderation.note": "",
|
|
178
|
+
updatedAt: new Date(),
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Cancel a booking. **The slot stays locked for its period** — see
|
|
184
|
+
* `AD_BOOKING_CANCELLED_KEEPS_SLOT`. Releasing it is a separate, deliberate
|
|
185
|
+
* admin act.
|
|
186
|
+
*/
|
|
187
|
+
export async function cancelAdBooking(bookingId, opts) {
|
|
188
|
+
const db = getAdminDb();
|
|
189
|
+
const ref = db.collection(AD_BOOKING_COLLECTION).doc(bookingId);
|
|
190
|
+
await db.runTransaction(async (tx) => {
|
|
191
|
+
const snap = await tx.get(ref);
|
|
192
|
+
if (!snap.exists)
|
|
193
|
+
throw new NotFoundError(BOOKING_NOT_FOUND);
|
|
194
|
+
const booking = snap.data();
|
|
195
|
+
if (!opts.byAdmin && booking.advertiserUid !== opts.actorUid) {
|
|
196
|
+
throw new NotFoundError(BOOKING_NOT_FOUND);
|
|
197
|
+
}
|
|
198
|
+
if (booking.cancelledAt)
|
|
199
|
+
return; // idempotent
|
|
200
|
+
tx.update(ref, {
|
|
201
|
+
cancelledAt: new Date(),
|
|
202
|
+
...(opts.reason ? { cancelledReason: opts.reason } : {}),
|
|
203
|
+
updatedAt: new Date(),
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 🛑 ADMIN ONLY, and it DELETES — this is the one path that frees a period.
|
|
209
|
+
*
|
|
210
|
+
* Cancelling keeps the lock because the advertiser paid for a month that may
|
|
211
|
+
* already be running. Releasing says "that month is for sale again", which
|
|
212
|
+
* is a commercial decision with a refund behind it, so it is separate,
|
|
213
|
+
* explicit, and logged rather than a side effect of a cancel button.
|
|
214
|
+
*/
|
|
215
|
+
export async function releaseAdSlot(placementId, period, actorUid) {
|
|
216
|
+
const db = getAdminDb();
|
|
217
|
+
const id = adBookingId(placementId, period);
|
|
218
|
+
const ref = db.collection(AD_BOOKING_COLLECTION).doc(id);
|
|
219
|
+
const snap = await ref.get();
|
|
220
|
+
if (!snap.exists)
|
|
221
|
+
throw new NotFoundError("No booking on that slot.");
|
|
222
|
+
const booking = snap.data();
|
|
223
|
+
if (booking.paymentState === "paid") {
|
|
224
|
+
throw new ConflictError("That booking is paid — refund it before releasing the slot.");
|
|
225
|
+
}
|
|
226
|
+
await ref.delete();
|
|
227
|
+
serverLogger.info("Ad slot released", { placementId, period, actorUid });
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* What should actually render in a placement right now.
|
|
231
|
+
*
|
|
232
|
+
* 🛑 ONE document read, by derived id — no query, no index, no scan. The
|
|
233
|
+
* slot's occupant for this month is computable from the placement and the
|
|
234
|
+
* date, which is the second reason the period model is worth its
|
|
235
|
+
* coarseness: the render path of an ad costs the same as reading one row.
|
|
236
|
+
*/
|
|
237
|
+
export async function liveBookingForPlacement(placementId, now = new Date()) {
|
|
238
|
+
const db = getAdminDb();
|
|
239
|
+
const id = adBookingId(placementId, adPeriodOf(now));
|
|
240
|
+
const snap = await db.collection(AD_BOOKING_COLLECTION).doc(id).get();
|
|
241
|
+
if (!snap.exists)
|
|
242
|
+
return null;
|
|
243
|
+
const booking = { id: snap.id, ...snap.data() };
|
|
244
|
+
return isAdBookingLive(booking, now) ? booking : null;
|
|
245
|
+
}
|
|
246
|
+
/** The moderation queue. */
|
|
247
|
+
export async function pendingAdBookings(limit = 50) {
|
|
248
|
+
const db = getAdminDb();
|
|
249
|
+
const snap = await db
|
|
250
|
+
.collection(AD_BOOKING_COLLECTION)
|
|
251
|
+
.where("moderation.state", "==", "pending")
|
|
252
|
+
.orderBy(AD_BOOKING_FIELDS.CREATED_AT, "asc")
|
|
253
|
+
.limit(limit)
|
|
254
|
+
.get();
|
|
255
|
+
return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
256
|
+
}
|
|
257
|
+
/** An advertiser's own bookings. */
|
|
258
|
+
export async function adBookingsForAdvertiser(advertiserUid, limit = 50) {
|
|
259
|
+
const db = getAdminDb();
|
|
260
|
+
const snap = await db
|
|
261
|
+
.collection(AD_BOOKING_COLLECTION)
|
|
262
|
+
.where(AD_BOOKING_FIELDS.ADVERTISER_UID, "==", advertiserUid)
|
|
263
|
+
.orderBy(AD_BOOKING_FIELDS.CREATED_AT, "desc")
|
|
264
|
+
.limit(limit)
|
|
265
|
+
.get();
|
|
266
|
+
return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The slot's monthly rate, from the placement itself.
|
|
270
|
+
*
|
|
271
|
+
* 🛑 Throws rather than defaulting to zero. A free booking that looks
|
|
272
|
+
* successful is worse than a refused one: the slot is taken, the invoice
|
|
273
|
+
* is ₹0, and nothing anywhere says a price was missing. An unpriced
|
|
274
|
+
* placement is one an admin has not put up for sale yet, and saying so is
|
|
275
|
+
* the honest answer.
|
|
276
|
+
*/
|
|
277
|
+
function resolveSlotPrice(placement) {
|
|
278
|
+
const rate = placement.priceRsPerPeriod;
|
|
279
|
+
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
|
|
280
|
+
throw new ValidationError("That placement has no price set — an admin must add one before it can be sold.");
|
|
281
|
+
}
|
|
282
|
+
return rate;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The rate card plus availability — what an `/advertise` page renders.
|
|
286
|
+
*
|
|
287
|
+
* 🛑 One read per OFFERED SLOT, and the slot count is bounded by
|
|
288
|
+
* `placements × months`, both small and both known. The id is derived, so
|
|
289
|
+
* this is a `getAll` of specific documents rather than a query — there is no
|
|
290
|
+
* scan, no index, and no page of results to cap.
|
|
291
|
+
*
|
|
292
|
+
* Unpriced and disabled placements are OMITTED rather than shown greyed out:
|
|
293
|
+
* a card for something that cannot be bought is a dead facet, and
|
|
294
|
+
* `brand-rows.ts` already records why that is worse than absent — "it
|
|
295
|
+
* renders as a card in the grid that opens an empty page".
|
|
296
|
+
*/
|
|
297
|
+
export async function adSlotOffers(months = 6, now = new Date()) {
|
|
298
|
+
const settings = await siteSettingsRepository.getSingleton();
|
|
299
|
+
const placements = (settings?.adSettings?.placements ?? []).filter((p) => p.enabled && typeof p.priceRsPerPeriod === "number" && p.priceRsPerPeriod > 0);
|
|
300
|
+
if (placements.length === 0)
|
|
301
|
+
return [];
|
|
302
|
+
const periods = [];
|
|
303
|
+
for (let i = 0; i < Math.max(1, Math.min(months, 12)); i += 1) {
|
|
304
|
+
periods.push(adPeriodOf(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1))));
|
|
305
|
+
}
|
|
306
|
+
const db = getAdminDb();
|
|
307
|
+
const refs = placements.flatMap((p) => periods.map((period) => db.collection(AD_BOOKING_COLLECTION).doc(adBookingId(p.id, period))));
|
|
308
|
+
const snaps = await db.getAll(...refs);
|
|
309
|
+
const taken = new Set(snaps.filter((s) => s.exists).map((s) => s.id));
|
|
310
|
+
return placements.map((p) => ({
|
|
311
|
+
placementId: p.id,
|
|
312
|
+
label: p.label,
|
|
313
|
+
priceRsPerPeriod: p.priceRsPerPeriod,
|
|
314
|
+
availability: periods.map((period) => ({
|
|
315
|
+
period,
|
|
316
|
+
/*
|
|
317
|
+
* A CANCELLED booking still occupies its month — the document is the
|
|
318
|
+
* lock and cancelling deliberately keeps it (see
|
|
319
|
+
* `AD_BOOKING_CANCELLED_KEEPS_SLOT`). Existence is therefore the right
|
|
320
|
+
* test, and reading the status to "free up" a cancelled month here
|
|
321
|
+
* would quietly undo that decision in the one place a buyer sees it.
|
|
322
|
+
*/
|
|
323
|
+
available: !taken.has(adBookingId(p.id, period)),
|
|
324
|
+
})),
|
|
325
|
+
}));
|
|
326
|
+
}
|