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
2 changes: 1 addition & 1 deletion .github/workflows/shop-janitor-restock-stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Shop janitor - restock stale orders

on:
schedule:
- cron: "*/5 * * * *"
- cron: "*/30 * * * *"
workflow_dispatch: {}

concurrency:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ const UI_STATE_TO_PAYMENT_STATUS_KEY = {
const STATUS_TOKEN_KEY_PREFIX = 'shop:order-status-token:';
const POLL_MAX_ATTEMPTS = 10;
const POLL_MAX_DURATION_MS = 2 * 60 * 1000;
const POLL_BASE_DELAY_MS = 1_500;
const POLL_MAX_DELAY_MS = 12_000;
const POLL_BUSY_RETRY_DELAY_MS = 250;
const POLL_BASE_DELAY_MS = 3_000;
const POLL_MAX_DELAY_MS = 15_000;
const POLL_BUSY_RETRY_DELAY_MS = 1_000;
const POLL_STOP_ERROR_CODES = new Set([
'STATUS_TOKEN_REQUIRED',
'STATUS_TOKEN_INVALID',
Expand Down Expand Up @@ -132,6 +132,27 @@ function normalizeToken(value: string | null | undefined): string | null {
function parseOrderStatusPayload(payload: unknown): OrderStatusModel | null {
if (!payload || typeof payload !== 'object') return null;
const root = payload as Record<string, unknown>;

if (
typeof root.id === 'string' &&
root.id.trim() &&
root.currency === 'UAH' &&
typeof root.totalAmountMinor === 'number' &&
Number.isFinite(root.totalAmountMinor) &&
typeof root.paymentStatus === 'string' &&
root.paymentStatus.trim() &&
typeof root.itemsCount === 'number' &&
Number.isFinite(root.itemsCount)
) {
return {
id: root.id,
currency: root.currency,
totalAmountMinor: root.totalAmountMinor,
paymentStatus: root.paymentStatus,
itemsCount: root.itemsCount,
};
}

if (root.success !== true) return null;

const orderRaw = root.order;
Expand Down Expand Up @@ -181,6 +202,7 @@ async function fetchOrderStatus(args: {
}): Promise<StatusResult> {
try {
const qp = new URLSearchParams();
qp.set('view', 'lite');
if (args.statusToken) {
qp.set('statusToken', args.statusToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,184 @@ import { useEffect, useRef } from 'react';

type Props = {
paymentStatus: string;
maxMs?: number;
intervalMs?: number;
};

function isTerminal(status: string) {
return status === 'paid' || status === 'failed' || status === 'refunded';
const MAX_ATTEMPTS = 8;
const MAX_DURATION_MS = 2 * 60 * 1000;
const BASE_DELAY_MS = 2_000;
const MAX_DELAY_MS = 15_000;
const JITTER_RATIO = 0.2;
const TERMINAL_STATUSES = new Set([
'paid',
'failed',
'refunded',
'needs_review',
]);

type StatusFetchResult =
| { ok: true; paymentStatus: string }
| { ok: false; status: number; code: string };

function normalizeQueryValue(value: string | null): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}

function isTerminal(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}

function shouldStopOnError(status: number, code: string): boolean {
if (status === 401 || status === 403) return true;
if (status !== 400) return false;
const normalized = code.trim().toUpperCase();
return (
normalized === 'STATUS_TOKEN_INVALID' ||
normalized === 'INVALID_STATUS_TOKEN' ||
normalized.endsWith('TOKEN_INVALID')
);
}

function getBackoffDelayMs(attempt: number): number {
return Math.min(BASE_DELAY_MS * 2 ** Math.max(attempt - 1, 0), MAX_DELAY_MS);
}

function withJitter(delayMs: number): number {
const jitterMultiplier = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
return Math.max(0, Math.floor(delayMs * jitterMultiplier));
}

function getErrorCode(payload: unknown): string {
if (!payload || typeof payload !== 'object') return 'INTERNAL_ERROR';
const code = (payload as Record<string, unknown>).code;
if (typeof code !== 'string') return 'INTERNAL_ERROR';
const trimmed = code.trim();
return trimmed.length ? trimmed : 'INTERNAL_ERROR';
}

function parseLitePaymentStatus(payload: unknown): string | null {
if (!payload || typeof payload !== 'object') return null;
const root = payload as Record<string, unknown>;
const paymentStatus = root.paymentStatus;
if (typeof paymentStatus !== 'string') return null;
const trimmed = paymentStatus.trim();
return trimmed.length ? trimmed : null;
}

async function fetchLiteOrderStatus(args: {
orderId: string;
tokenKey: string | null;
tokenValue: string | null;
signal: AbortSignal;
}): Promise<StatusFetchResult> {
const qp = new URLSearchParams();
qp.set('view', 'lite');
if (args.tokenKey && args.tokenValue) qp.set(args.tokenKey, args.tokenValue);

const endpoint = `/api/shop/orders/${encodeURIComponent(args.orderId)}/status?${qp.toString()}`;

const res = await fetch(endpoint, {
method: 'GET',
cache: 'no-store',
headers: { 'Cache-Control': 'no-store' },
credentials: 'same-origin',
signal: args.signal,
});

const body = await res.json().catch(() => ({}));
if (!res.ok) {
return { ok: false, status: res.status, code: getErrorCode(body) };
}

const paymentStatus = parseLitePaymentStatus(body);
if (!paymentStatus) {
return { ok: false, status: 500, code: 'INVALID_STATUS_RESPONSE' };
}

return { ok: true, paymentStatus };
}

export default function OrderStatusAutoRefresh({
paymentStatus,
maxMs = 30_000,
intervalMs = 1_500,
}: Props) {
export default function OrderStatusAutoRefresh({ paymentStatus }: Props) {
const router = useRouter();
const startedAtRef = useRef<number | null>(null);
const didTerminalRefreshRef = useRef(false);

useEffect(() => {
if (isTerminal(paymentStatus)) return;

if (startedAtRef.current == null) startedAtRef.current = Date.now();
let cancelled = false;
let timeoutId: number | null = null;
let activeController: AbortController | null = null;
const startedAtMs = Date.now();
let attempts = 0;

const params = new URLSearchParams(window.location.search);
const orderId = normalizeQueryValue(params.get('orderId'));
if (!orderId) return;

const tokenKey = params.has('statusToken') ? 'statusToken' : null;
const tokenValue =
tokenKey === null ? null : normalizeQueryValue(params.get(tokenKey));

const id = window.setInterval(() => {
const startedAt = startedAtRef.current ?? Date.now();
if (Date.now() - startedAt > maxMs) {
window.clearInterval(id);
return;
const wait = async (delayMs: number) =>
new Promise<void>(resolve => {
timeoutId = window.setTimeout(resolve, delayMs);
});

const run = async () => {
while (!cancelled) {
if (attempts >= MAX_ATTEMPTS) return;
if (Date.now() - startedAtMs >= MAX_DURATION_MS) return;

attempts += 1;
const controller = new AbortController();
activeController = controller;
const result = await fetchLiteOrderStatus({
orderId,
tokenKey,
tokenValue,
signal: controller.signal,
}).catch(
(): StatusFetchResult => ({
ok: false,
status: 500,
code: 'INTERNAL_ERROR',
})
);

if (cancelled) {
return;
}
activeController = null;

if (result.ok) {
if (isTerminal(result.paymentStatus)) {
if (!didTerminalRefreshRef.current) {
didTerminalRefreshRef.current = true;
router.refresh();
}
return;
}
} else if (shouldStopOnError(result.status, result.code)) {
return;
}

if (attempts >= MAX_ATTEMPTS) return;
if (Date.now() - startedAtMs >= MAX_DURATION_MS) return;

const delayMs = withJitter(getBackoffDelayMs(attempts));
await wait(delayMs);
}
router.refresh();
}, intervalMs);
};

void run();

return () => window.clearInterval(id);
}, [paymentStatus, router, maxMs, intervalMs]);
return () => {
cancelled = true;
activeController?.abort();
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}, [paymentStatus, router]);

return <span className="sr-only" aria-live="polite" />;
}
21 changes: 18 additions & 3 deletions frontend/app/api/sessions/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import { activeSessions } from '@/db/schema/sessions';

const SESSION_TIMEOUT_MINUTES = 15;

function getHeartbeatThrottleMs(): number {
const raw = process.env.HEARTBEAT_THROTTLE_MS;
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
const fallback = 60_000;
const floor = 1_000;
if (!Number.isFinite(parsed)) return fallback;
return Math.max(floor, parsed);
}

export async function POST() {
try {
const cookieStore = await cookies();
Expand All @@ -17,15 +26,21 @@ export async function POST() {
sessionId = randomUUID();
}

const now = new Date();
const heartbeatThreshold = new Date(
now.getTime() - getHeartbeatThrottleMs()
);

await db
.insert(activeSessions)
.values({
sessionId,
lastActivity: new Date(),
lastActivity: now,
})
.onConflictDoUpdate({
target: activeSessions.sessionId,
set: { lastActivity: new Date() },
set: { lastActivity: now },
setWhere: lt(activeSessions.lastActivity, heartbeatThreshold),
});

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

const result = await db
.select({
total: sql<number>`count(distinct session_id)`,
total: sql<number>`count(*)`,
})
.from(activeSessions)
.where(gte(activeSessions.lastActivity, countThreshold));
Expand Down
Loading