Skip to content

Commit 2bafe82

Browse files
ViktorSvertokaliudmylasovetovsLesiaUKR
authored
Hot fix (#376)
* (SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow (#364) * (SP: 2) [Frontend] Reduce Vercel variable costs via caching and analytics cleanup (#367) * perf(vercel): cut runtime costs via notification, blog cache, and analytics changes * perf(blog): remove server searchParams usage to preserve ISR * fix(build): align Netlify Node version and remove SpeedInsights import * chore(release): bump version to 1.0.4 * (SP: 2) [Frontend] Remove [locale] layout force-dynamic and move auth to client-side (#370) * refactor(frontend): remove locale layout dynamic auth and move header auth client-side * fix(frontend): prevent stale auth responses in useAuth and remove redundant dashboard dynamic layout * (SP: 2) [Frontend] Reduce auth overhead and sync auth state across tabs (#372) * refactor(frontend): remove locale layout dynamic auth and move header auth client-side * fix(frontend): prevent stale auth responses in useAuth and remove redundant dashboard dynamic layout * feat(frontend): sync auth state across tabs via BroadcastChannel * (SP: 2) [Frontend] Quizzes page ISR + client-side progress + GitHub stars cache (#371) * perf(quiz-flow): move quiz progress to client-side fetch, enable ISR for quizzes page - Move user progress fetch from SSR to client-side API (/api/quiz/progress) - Remove force-dynamic and getCurrentUser() from quizzes page - Add revalidate=300 for ISR caching - Use window.history.replaceState for tab URL sync (avoid Next.js navigation) - Add forceMount to TabsContent to prevent layout shift on tab switch - Fix nested <main> — use <section> inside DynamicGridBackground - Cache GitHub stars count in sessionStorage to avoid refetch + re-animation * perf: replace useRef with useState lazy initializer in GitHubStarButton Fixes React 19 react-hooks/refs ESLint error — useRef.current cannot be read during render. Uses useState(getStoredStars) to capture the sessionStorage value once on mount instead. * fix: stop star icon trembling on hover in GitHubStarButton * (SP: 1) [Frontend] Fix quiz timer flash and card layout shift on quizzes page (#373) * perf(quiz-flow): move quiz progress to client-side fetch, enable ISR for quizzes page - Move user progress fetch from SSR to client-side API (/api/quiz/progress) - Remove force-dynamic and getCurrentUser() from quizzes page - Add revalidate=300 for ISR caching - Use window.history.replaceState for tab URL sync (avoid Next.js navigation) - Add forceMount to TabsContent to prevent layout shift on tab switch - Fix nested <main> — use <section> inside DynamicGridBackground - Cache GitHub stars count in sessionStorage to avoid refetch + re-animation * perf: replace useRef with useState lazy initializer in GitHubStarButton Fixes React 19 react-hooks/refs ESLint error — useRef.current cannot be read during render. Uses useState(getStoredStars) to capture the sessionStorage value once on mount instead. * fix: stop star icon trembling on hover in GitHubStarButton * fix: eliminate quiz timer flash on language switch Remove Suspense boundary (loading.tsx) that unmounted QuizContainer during locale navigation. Synchronous session restore via useReducer lazy initializer and correct timer initialization via useState lazy initializer prevent any visible state reset on language switch * fix: replace quiz card layout shift with skeleton grid during progress load * chore(release): v1.0.5 * (SP: 3)[Shop][DB] Reduce Neon compute: throttle janitor + relax checkout polling + add sweep indexes (#375) * (SP: 3) [Backend] add internal janitor (jobs 1-4), claim/lease + runbook (G0-G6) * (SP: 3) [Backend] add provider selector, fix payments gating, i18n checkout errors * Add shop category images to public * (SP: 3) [Shop][Monobank] I1 structured logging: codes + logging safety checks * (SP: 3) [Shop][Monobank] Fail-closed non-browser origin posture for webhook + janitor (ORIGIN_BLOCKED) * (SP: 3) [Shop][Monobank] [Shop][Monobank] J gate: add orders status ownership test and pass all pre-prod invariants * (SP: 3) [Shop][Monobank] review fixes (tests, logging, success UI) * (SP: 1) [Shop][Monobank] Tighten webhook log-code typing; harden DB tests; minor security/log/UI cleanups * (SP: 1) [Shop][Monobank] harden Monobank webhook (origin/PII-safe logs) and remove duplicate sha256 hashing * (SP: 1) [Cart] adding route for user orders to cart page * (SP: 1) [Cart] fix after review cart mpage and adding index for orders * (SP: 1) [Cart] Fix cart orders summary auth rendering and return totalCount for orders badge * (SP: 1) [Cart] remove console.warn from CartPageClient to satisfy monobank logging safety invariant, namespace localStorage cart by user and reset on auth change * (SP: 1) [Cart] rehydrate per cartOwnerId (remove didHydrate coupling) * (SP: 2)[Backend] shop/shipping schema migrations foundation * (SP: 2)[Backend] shop/shipping public routes + np cache + sync * (SP: 2)[Backend] shop/shipping: shipping persistence + currency policy * (SP: 2)[Backend] shop/shipping: webhook apply + psp fields + enqueue shipping * (SP: 2)[Backend] shop/shipping: shipments worker + internal run + np mock * (SP: 2)[Backend] shop/shipping: admin+ui shipping actions * (SP: 2)[Backend] shop/shipping: retention + log sanitizer + metrics * (SP: 1)[Backend] stabilize Monobank janitor (job1/job3) and fix failing apply-outcomes tests * (SP: 1) [db]: add shop shipping core migration * (SP: 1) [FIX] resolve merge artifacts in order details page * (SP: 1) [FIX] apply post-review fixes for shipping and admin flows * (SP: 1) [FIX] align cart shipping imports (localeToCountry + availability reason code) * (SP: 1) [FIX] hard-block checkout when shipping disabled + i18n reason mapping * (SP: 1) [FIX] harden webhook enqueue + shipping worker + NP catalog + cart fail-closed * (SP: 1) [FIX] Initialize shippingMethodsLoading to true to avoid premature checkout. * (SP: 1) [FIX] migration 17 * (SP: 1) [DB] migrarion to testind DB and adjusting tests * (SP: 1)[DB] slow down restock janitor + enforce prod interval floor * (SP: 1) [DB] add order status lite view (opt-in) + instrumentation * (SP: 1) [DB] replace checkout success router.refresh polling with backoff API polling * (SP: 1) [DB] throttle sessions activity heartbeat + use count(*) (PK invariant) * (SP: 1)[DB] enforce production min intervals for internal shipping jobs * (SP: 1) [DB] add minimal partial indexes for orders sweeps + rollout notes * (SP: 1) [DB] refactor sweep claim step to FOR UPDATE SKIP LOCKED batching * (SP: 1)[DB]: slow janitor schedule to every 30 minutes * (SP: 1)[DB] increase polling delays for MonobankRedirectStatus * (SP: 1)[FIX] harden webhooks + fix SSR hydration + janitor/np gates + sweeps refactor * (SP: 1)[FIX] harden shipping enqueue gating + apply NP interval floor --------- Co-authored-by: Liudmyla Sovetovs <milkaegik@gmail.com> Co-authored-by: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com>
1 parent aa74add commit 2bafe82

27 files changed

Lines changed: 5564 additions & 520 deletions

.github/workflows/shop-janitor-restock-stale.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Shop janitor - restock stale orders
22

33
on:
44
schedule:
5-
- cron: "*/5 * * * *"
5+
- cron: "*/30 * * * *"
66
workflow_dispatch: {}
77

88
concurrency:

frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ const UI_STATE_TO_PAYMENT_STATUS_KEY = {
6969
const STATUS_TOKEN_KEY_PREFIX = 'shop:order-status-token:';
7070
const POLL_MAX_ATTEMPTS = 10;
7171
const POLL_MAX_DURATION_MS = 2 * 60 * 1000;
72-
const POLL_BASE_DELAY_MS = 1_500;
73-
const POLL_MAX_DELAY_MS = 12_000;
74-
const POLL_BUSY_RETRY_DELAY_MS = 250;
72+
const POLL_BASE_DELAY_MS = 3_000;
73+
const POLL_MAX_DELAY_MS = 15_000;
74+
const POLL_BUSY_RETRY_DELAY_MS = 1_000;
7575
const POLL_STOP_ERROR_CODES = new Set([
7676
'STATUS_TOKEN_REQUIRED',
7777
'STATUS_TOKEN_INVALID',
@@ -132,6 +132,27 @@ function normalizeToken(value: string | null | undefined): string | null {
132132
function parseOrderStatusPayload(payload: unknown): OrderStatusModel | null {
133133
if (!payload || typeof payload !== 'object') return null;
134134
const root = payload as Record<string, unknown>;
135+
136+
if (
137+
typeof root.id === 'string' &&
138+
root.id.trim() &&
139+
root.currency === 'UAH' &&
140+
typeof root.totalAmountMinor === 'number' &&
141+
Number.isFinite(root.totalAmountMinor) &&
142+
typeof root.paymentStatus === 'string' &&
143+
root.paymentStatus.trim() &&
144+
typeof root.itemsCount === 'number' &&
145+
Number.isFinite(root.itemsCount)
146+
) {
147+
return {
148+
id: root.id,
149+
currency: root.currency,
150+
totalAmountMinor: root.totalAmountMinor,
151+
paymentStatus: root.paymentStatus,
152+
itemsCount: root.itemsCount,
153+
};
154+
}
155+
135156
if (root.success !== true) return null;
136157

137158
const orderRaw = root.order;
@@ -181,6 +202,7 @@ async function fetchOrderStatus(args: {
181202
}): Promise<StatusResult> {
182203
try {
183204
const qp = new URLSearchParams();
205+
qp.set('view', 'lite');
184206
if (args.statusToken) {
185207
qp.set('statusToken', args.statusToken);
186208
}

frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx

Lines changed: 166 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,184 @@ import { useEffect, useRef } from 'react';
55

66
type Props = {
77
paymentStatus: string;
8-
maxMs?: number;
9-
intervalMs?: number;
108
};
119

12-
function isTerminal(status: string) {
13-
return status === 'paid' || status === 'failed' || status === 'refunded';
10+
const MAX_ATTEMPTS = 8;
11+
const MAX_DURATION_MS = 2 * 60 * 1000;
12+
const BASE_DELAY_MS = 2_000;
13+
const MAX_DELAY_MS = 15_000;
14+
const JITTER_RATIO = 0.2;
15+
const TERMINAL_STATUSES = new Set([
16+
'paid',
17+
'failed',
18+
'refunded',
19+
'needs_review',
20+
]);
21+
22+
type StatusFetchResult =
23+
| { ok: true; paymentStatus: string }
24+
| { ok: false; status: number; code: string };
25+
26+
function normalizeQueryValue(value: string | null): string | null {
27+
if (typeof value !== 'string') return null;
28+
const trimmed = value.trim();
29+
return trimmed.length ? trimmed : null;
30+
}
31+
32+
function isTerminal(status: string): boolean {
33+
return TERMINAL_STATUSES.has(status);
34+
}
35+
36+
function shouldStopOnError(status: number, code: string): boolean {
37+
if (status === 401 || status === 403) return true;
38+
if (status !== 400) return false;
39+
const normalized = code.trim().toUpperCase();
40+
return (
41+
normalized === 'STATUS_TOKEN_INVALID' ||
42+
normalized === 'INVALID_STATUS_TOKEN' ||
43+
normalized.endsWith('TOKEN_INVALID')
44+
);
45+
}
46+
47+
function getBackoffDelayMs(attempt: number): number {
48+
return Math.min(BASE_DELAY_MS * 2 ** Math.max(attempt - 1, 0), MAX_DELAY_MS);
49+
}
50+
51+
function withJitter(delayMs: number): number {
52+
const jitterMultiplier = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
53+
return Math.max(0, Math.floor(delayMs * jitterMultiplier));
54+
}
55+
56+
function getErrorCode(payload: unknown): string {
57+
if (!payload || typeof payload !== 'object') return 'INTERNAL_ERROR';
58+
const code = (payload as Record<string, unknown>).code;
59+
if (typeof code !== 'string') return 'INTERNAL_ERROR';
60+
const trimmed = code.trim();
61+
return trimmed.length ? trimmed : 'INTERNAL_ERROR';
62+
}
63+
64+
function parseLitePaymentStatus(payload: unknown): string | null {
65+
if (!payload || typeof payload !== 'object') return null;
66+
const root = payload as Record<string, unknown>;
67+
const paymentStatus = root.paymentStatus;
68+
if (typeof paymentStatus !== 'string') return null;
69+
const trimmed = paymentStatus.trim();
70+
return trimmed.length ? trimmed : null;
71+
}
72+
73+
async function fetchLiteOrderStatus(args: {
74+
orderId: string;
75+
tokenKey: string | null;
76+
tokenValue: string | null;
77+
signal: AbortSignal;
78+
}): Promise<StatusFetchResult> {
79+
const qp = new URLSearchParams();
80+
qp.set('view', 'lite');
81+
if (args.tokenKey && args.tokenValue) qp.set(args.tokenKey, args.tokenValue);
82+
83+
const endpoint = `/api/shop/orders/${encodeURIComponent(args.orderId)}/status?${qp.toString()}`;
84+
85+
const res = await fetch(endpoint, {
86+
method: 'GET',
87+
cache: 'no-store',
88+
headers: { 'Cache-Control': 'no-store' },
89+
credentials: 'same-origin',
90+
signal: args.signal,
91+
});
92+
93+
const body = await res.json().catch(() => ({}));
94+
if (!res.ok) {
95+
return { ok: false, status: res.status, code: getErrorCode(body) };
96+
}
97+
98+
const paymentStatus = parseLitePaymentStatus(body);
99+
if (!paymentStatus) {
100+
return { ok: false, status: 500, code: 'INVALID_STATUS_RESPONSE' };
101+
}
102+
103+
return { ok: true, paymentStatus };
14104
}
15105

16-
export default function OrderStatusAutoRefresh({
17-
paymentStatus,
18-
maxMs = 30_000,
19-
intervalMs = 1_500,
20-
}: Props) {
106+
export default function OrderStatusAutoRefresh({ paymentStatus }: Props) {
21107
const router = useRouter();
22-
const startedAtRef = useRef<number | null>(null);
108+
const didTerminalRefreshRef = useRef(false);
23109

24110
useEffect(() => {
25111
if (isTerminal(paymentStatus)) return;
26112

27-
if (startedAtRef.current == null) startedAtRef.current = Date.now();
113+
let cancelled = false;
114+
let timeoutId: number | null = null;
115+
let activeController: AbortController | null = null;
116+
const startedAtMs = Date.now();
117+
let attempts = 0;
118+
119+
const params = new URLSearchParams(window.location.search);
120+
const orderId = normalizeQueryValue(params.get('orderId'));
121+
if (!orderId) return;
122+
123+
const tokenKey = params.has('statusToken') ? 'statusToken' : null;
124+
const tokenValue =
125+
tokenKey === null ? null : normalizeQueryValue(params.get(tokenKey));
28126

29-
const id = window.setInterval(() => {
30-
const startedAt = startedAtRef.current ?? Date.now();
31-
if (Date.now() - startedAt > maxMs) {
32-
window.clearInterval(id);
33-
return;
127+
const wait = async (delayMs: number) =>
128+
new Promise<void>(resolve => {
129+
timeoutId = window.setTimeout(resolve, delayMs);
130+
});
131+
132+
const run = async () => {
133+
while (!cancelled) {
134+
if (attempts >= MAX_ATTEMPTS) return;
135+
if (Date.now() - startedAtMs >= MAX_DURATION_MS) return;
136+
137+
attempts += 1;
138+
const controller = new AbortController();
139+
activeController = controller;
140+
const result = await fetchLiteOrderStatus({
141+
orderId,
142+
tokenKey,
143+
tokenValue,
144+
signal: controller.signal,
145+
}).catch(
146+
(): StatusFetchResult => ({
147+
ok: false,
148+
status: 500,
149+
code: 'INTERNAL_ERROR',
150+
})
151+
);
152+
153+
if (cancelled) {
154+
return;
155+
}
156+
activeController = null;
157+
158+
if (result.ok) {
159+
if (isTerminal(result.paymentStatus)) {
160+
if (!didTerminalRefreshRef.current) {
161+
didTerminalRefreshRef.current = true;
162+
router.refresh();
163+
}
164+
return;
165+
}
166+
} else if (shouldStopOnError(result.status, result.code)) {
167+
return;
168+
}
169+
170+
if (attempts >= MAX_ATTEMPTS) return;
171+
if (Date.now() - startedAtMs >= MAX_DURATION_MS) return;
172+
173+
const delayMs = withJitter(getBackoffDelayMs(attempts));
174+
await wait(delayMs);
34175
}
35-
router.refresh();
36-
}, intervalMs);
176+
};
177+
178+
void run();
37179

38-
return () => window.clearInterval(id);
39-
}, [paymentStatus, router, maxMs, intervalMs]);
180+
return () => {
181+
cancelled = true;
182+
activeController?.abort();
183+
if (timeoutId !== null) window.clearTimeout(timeoutId);
184+
};
185+
}, [paymentStatus, router]);
40186

41187
return <span className="sr-only" aria-live="polite" />;
42188
}

frontend/app/api/sessions/activity/route.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ import { activeSessions } from '@/db/schema/sessions';
88

99
const SESSION_TIMEOUT_MINUTES = 15;
1010

11+
function getHeartbeatThrottleMs(): number {
12+
const raw = process.env.HEARTBEAT_THROTTLE_MS;
13+
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
14+
const fallback = 60_000;
15+
const floor = 1_000;
16+
if (!Number.isFinite(parsed)) return fallback;
17+
return Math.max(floor, parsed);
18+
}
19+
1120
export async function POST() {
1221
try {
1322
const cookieStore = await cookies();
@@ -17,15 +26,21 @@ export async function POST() {
1726
sessionId = randomUUID();
1827
}
1928

29+
const now = new Date();
30+
const heartbeatThreshold = new Date(
31+
now.getTime() - getHeartbeatThrottleMs()
32+
);
33+
2034
await db
2135
.insert(activeSessions)
2236
.values({
2337
sessionId,
24-
lastActivity: new Date(),
38+
lastActivity: now,
2539
})
2640
.onConflictDoUpdate({
2741
target: activeSessions.sessionId,
28-
set: { lastActivity: new Date() },
42+
set: { lastActivity: now },
43+
setWhere: lt(activeSessions.lastActivity, heartbeatThreshold),
2944
});
3045

3146
if (Math.random() < 0.05) {
@@ -44,7 +59,7 @@ export async function POST() {
4459

4560
const result = await db
4661
.select({
47-
total: sql<number>`count(distinct session_id)`,
62+
total: sql<number>`count(*)`,
4863
})
4964
.from(activeSessions)
5065
.where(gte(activeSessions.lastActivity, countThreshold));

0 commit comments

Comments
 (0)