Skip to content
Merged
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
ec0827e
(SP: 3) [Backend] add internal janitor (jobs 1-4), claim/lease + runb…
liudmylasovetovs Feb 13, 2026
be244cb
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 13, 2026
3cadf6d
(SP: 3) [Backend] add provider selector, fix payments gating, i18n ch…
liudmylasovetovs Feb 13, 2026
88f52d3
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 13, 2026
ea3a437
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 13, 2026
bfadfe7
Add shop category images to public
liudmylasovetovs Feb 14, 2026
9f93a52
(SP: 3) [Shop][Monobank] I1 structured logging: codes + logging safet…
liudmylasovetovs Feb 14, 2026
1d1852b
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 14, 2026
2ff41a8
(SP: 3) [Shop][Monobank] Fail-closed non-browser origin posture for w…
liudmylasovetovs Feb 14, 2026
a89fc6a
(SP: 3) [Shop][Monobank] [Shop][Monobank] J gate: add orders status o…
liudmylasovetovs Feb 14, 2026
dd1a02f
(SP: 3) [Shop][Monobank] review fixes (tests, logging, success UI)
liudmylasovetovs Feb 15, 2026
155b172
(SP: 1) [Shop][Monobank] Tighten webhook log-code typing; harden DB t…
liudmylasovetovs Feb 15, 2026
1bc435a
(SP: 1) [Shop][Monobank] harden Monobank webhook (origin/PII-safe log…
liudmylasovetovs Feb 15, 2026
0a7b943
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 15, 2026
eb42103
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 16, 2026
cb08926
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 17, 2026
4e694a4
(SP: 1) [Cart] adding route for user orders to cart page
liudmylasovetovs Feb 17, 2026
64b482e
(SP: 1) [Cart] fix after review cart mpage and adding index for orders
liudmylasovetovs Feb 17, 2026
3094c75
(SP: 1) [Cart] Fix cart orders summary auth rendering and return tota…
liudmylasovetovs Feb 17, 2026
d11d3dd
(SP: 1) [Cart] remove console.warn from CartPageClient to satisfy mon…
liudmylasovetovs Feb 17, 2026
349929c
(SP: 1) [Cart] rehydrate per cartOwnerId (remove didHydrate coupling)
liudmylasovetovs Feb 17, 2026
951d77c
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 17, 2026
d16b5f9
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 17, 2026
38a2dbc
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 20, 2026
0efa712
(SP: 2)[Backend] shop/shipping schema migrations foundation
liudmylasovetovs Feb 21, 2026
3e049f4
(SP: 2)[Backend] shop/shipping public routes + np cache + sync
liudmylasovetovs Feb 22, 2026
53b4bae
(SP: 2)[Backend] shop/shipping: shipping persistence + currency policy
liudmylasovetovs Feb 22, 2026
4b5f9c4
(SP: 2)[Backend] shop/shipping: webhook apply + psp fields + enqueue …
liudmylasovetovs Feb 22, 2026
df35f19
(SP: 2)[Backend] shop/shipping: shipments worker + internal run + np …
liudmylasovetovs Feb 22, 2026
9660abb
(SP: 2)[Backend] shop/shipping: admin+ui shipping actions
liudmylasovetovs Feb 22, 2026
dbc7dc4
(SP: 2)[Backend] shop/shipping: retention + log sanitizer + metrics
liudmylasovetovs Feb 22, 2026
679bc36
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 22, 2026
262f05e
(SP: 1)[Backend] stabilize Monobank janitor (job1/job3) and fix faili…
liudmylasovetovs Feb 24, 2026
195546f
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 24, 2026
3d78c05
(SP: 1) [db]: add shop shipping core migration
liudmylasovetovs Feb 24, 2026
c77e191
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 24, 2026
96e2303
(SP: 1) [FIX] resolve merge artifacts in order details page
liudmylasovetovs Feb 24, 2026
ab54c24
(SP: 1) [FIX] apply post-review fixes for shipping and admin flows
liudmylasovetovs Feb 24, 2026
444aa4e
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 24, 2026
047f705
(SP: 1) [FIX] align cart shipping imports (localeToCountry + availabi…
liudmylasovetovs Feb 24, 2026
313ff9b
(SP: 1) [FIX] hard-block checkout when shipping disabled + i18n reaso…
liudmylasovetovs Feb 25, 2026
06f9dcf
(SP: 1) [FIX] harden webhook enqueue + shipping worker + NP catalog +…
liudmylasovetovs Feb 25, 2026
6ef9ca8
(SP: 1) [FIX] Initialize shippingMethodsLoading to true to avoid prem…
liudmylasovetovs Feb 25, 2026
e4bf2f4
(SP: 1) [FIX] migration 17
liudmylasovetovs Feb 25, 2026
dfebf56
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 25, 2026
341e6bf
(SP: 1) [DB] migrarion to testind DB and adjusting tests
liudmylasovetovs Feb 26, 2026
d6c3323
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 26, 2026
6114697
(SP: 1)[DB] slow down restock janitor + enforce prod interval floor
liudmylasovetovs Feb 26, 2026
1b8d350
(SP: 1) [DB] add order status lite view (opt-in) + instrumentation
liudmylasovetovs Feb 26, 2026
f333a70
(SP: 1) [DB] replace checkout success router.refresh polling with bac…
liudmylasovetovs Feb 26, 2026
9b9cd68
(SP: 1) [DB] throttle sessions activity heartbeat + use count(*) (PK …
liudmylasovetovs Feb 26, 2026
ebcd725
(SP: 1)[DB] enforce production min intervals for internal shipping jobs
liudmylasovetovs Feb 26, 2026
220852e
(SP: 1) [DB] add minimal partial indexes for orders sweeps + rollout …
liudmylasovetovs Feb 26, 2026
d220dca
(SP: 1) [DB] refactor sweep claim step to FOR UPDATE SKIP LOCKED batc…
liudmylasovetovs Feb 26, 2026
c9a4785
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 26, 2026
2151edd
(SP: 1)[DB]: slow janitor schedule to every 30 minutes
liudmylasovetovs Feb 26, 2026
4553145
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 26, 2026
64ca6a6
(SP: 1)[DB] increase polling delays for MonobankRedirectStatus
liudmylasovetovs Feb 26, 2026
d758782
(SP: 1)[FIX] harden webhooks + fix SSR hydration + janitor/np gates +…
liudmylasovetovs Feb 27, 2026
513bfbe
(SP: 1)[FIX] harden shipping enqueue gating + apply NP interval floor
liudmylasovetovs Feb 27, 2026
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,180 @@ 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',
'canceled',
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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(() => ({ ok: false, status: 500, code: 'INTERNAL_ERROR' }));

if (cancelled) {
controller.abort();
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" />;
}
11 changes: 8 additions & 3 deletions frontend/app/api/sessions/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { db } from '@/db';
import { activeSessions } from '@/db/schema/sessions';

const SESSION_TIMEOUT_MINUTES = 15;
const HEARTBEAT_THROTTLE_MS = 60_000;

export async function POST() {
try {
Expand All @@ -17,15 +18,19 @@ export async function POST() {
sessionId = randomUUID();
}

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

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 +49,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
33 changes: 25 additions & 8 deletions frontend/app/api/shop/internal/orders/restock-stale/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,14 @@ function parseRequestedMinIntervalSeconds(
function getEnvMinIntervalSeconds(): number {
if (process.env.NODE_ENV === 'test') return 0;

const fallback = process.env.NODE_ENV === 'production' ? 300 : 60;
const fallback = process.env.NODE_ENV === 'production' ? 900 : 60;
const n = toFiniteNumber(process.env.INTERNAL_JANITOR_MIN_INTERVAL_SECONDS);
const v = n === null ? fallback : n;

return clampInt(v, 0, MIN_INTERVAL_SECONDS_MAX);
}

type GateRow = { next_allowed_at: unknown };
type GateRow = { next_allowed_at: unknown; updated_at: unknown };

function normalizeDate(x: unknown): Date | null {
if (!x) return null;
Expand Down Expand Up @@ -230,23 +230,29 @@ async function acquireJobSlot(params: {
last_run_id = ${runId}::uuid,
updated_at = now()
WHERE internal_job_state.next_allowed_at <= now()
RETURNING next_allowed_at
RETURNING next_allowed_at, updated_at
`);

const rows = (res as any).rows ?? [];
if (rows.length > 0) return { ok: true as const };
if (rows.length > 0) {
return {
ok: true as const,
lastRunTs: normalizeDate(rows[0]?.updated_at),
};
}

const res2 = await db.execute<GateRow>(sql`
SELECT next_allowed_at
SELECT next_allowed_at, updated_at
FROM internal_job_state
WHERE job_name = ${jobName}
LIMIT 1
`);

const rows2 = (res2 as any).rows ?? [];
const nextAllowedAt = normalizeDate(rows2[0]?.next_allowed_at);
const lastRunTs = normalizeDate(rows2[0]?.updated_at);

return { ok: false as const, nextAllowedAt };
return { ok: false as const, nextAllowedAt, lastRunTs };
}

export async function POST(request: NextRequest) {
Expand Down Expand Up @@ -388,20 +394,23 @@ export async function POST(request: NextRequest) {
const envMinIntervalSeconds = getEnvMinIntervalSeconds();
const requestedMinIntervalSeconds = requestedMinIntervalParsed;

const minIntervalSeconds = Math.max(
const effectiveIntervalSeconds = Math.max(
envMinIntervalSeconds,
requestedMinIntervalSeconds
);
const minIntervalSeconds = effectiveIntervalSeconds;

const runId = crypto.randomUUID();
const jobName = baseMeta.jobName;
const workerId = `janitor:${runId}`;

const gate = await acquireJobSlot({
jobName,
effectiveMinIntervalSeconds: minIntervalSeconds,
effectiveMinIntervalSeconds: effectiveIntervalSeconds,
runId,
});
const nowTs = new Date().toISOString();
const lastRunTs = gate.lastRunTs ? gate.lastRunTs.toISOString() : null;

if (!gate.ok) {
const retryAfterSeconds = gate.nextAllowedAt
Expand All @@ -416,6 +425,10 @@ export async function POST(request: NextRequest) {
runId,
workerId,
retryAfterSeconds,
effectiveIntervalSeconds,
gateDecision: 'skipped',
nowTs,
lastRunTs,
minIntervalSeconds,
});

Expand Down Expand Up @@ -479,6 +492,10 @@ export async function POST(request: NextRequest) {
batchSize: policy.batchSize,
appliedPolicy: policy,
maxRuntimeMs,
effectiveIntervalSeconds,
gateDecision: 'ran',
nowTs,
lastRunTs,
minIntervalSeconds,
runtimeMs: Date.now() - startedAtMs,
});
Expand Down
Loading