Skip to content

Commit e0d1e01

Browse files
ViktorSvertokaliudmylasovetovsLesiaUKR
authored
Release v1.0.6 (#380)
* (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 * (SP: 3) [SHOP] audit-driven e2e purchase readiness hardening (events, notifications, consent, returns) (#378) * (SP:3)[SHOP] add canonical payment/shipping/admin audit tables + dedupe helper with flagged atomic dual-write * (SP: 2)[SHOP] add INTL quote flow (request/offer/accept/decline), payment-init gate, and quote expiry/timeout sweeps * (SP: 3)[SHOP] introduce outbox-driven notifications with projector + worker (phase 3) * (SP: 3)[SHOP] add minimal returns/RMA lifecycle with atomic audit + canonical events (phase 4) * (SP: 3)[SHOP] enforce guest status-token lite-only access and audit token usage (phase 5) * (SP: 3)[SHOP] centralize transition guards and enforce across admin/webhook/worker flows (phase 6) :wq n * (SP:3)[SHOP]: enforce DATABASE_URL_LOCAL preflight + deterministic vitest config * (SP:3)[SHOP]: require canonical events in prod (fail-fast) * (SP:3)[SHOP]: implement notifications transport with retries + DLQ * (SP:3)[SHOP]: persist checkout legal consent artifact * (SP:1)[SHOP] add migration 0025 for consent + events + audit + prices * (SP:1)[SHOP] emit shipping_events for shipment worker transitions * (SP:2)[SHOP] audit admin product mutations (deduped) * (SP:2)[SHOP] make Monobank webhook retryable on transient apply failures * (SP:3) [SHOP] enforce guest status-token scopes across order actions * (SP:1) [SHOP] make product_prices the only write authority * (SP:1) [SHOP] add Playwright e2e smoke suite (local DB only) * (SP:3) [SHOP] explicitly reject exchanges (EXCHANGES_NOT_SUPPORTED) * (SP: 3)[FIX] harden audit + workers/tests; fix transitions/restock + webhook perf * (SP: 3)[FIX] harden local-db test safety and tighten shop reliability guards * (SP: 3)[FIX] fail-closed admin audit, refine shipments-worker outcomes/metrics, tighten quote+tests * (SP: 1)[FIX] harden shipments-worker claiming/leases and make audit+quote/test paths resilient * (SP: 1)[FIX] harden quote request errors/logging and sanitize requestId; document best-effort delete audit * feat(ui): add devops/cloud category icons and styles (#379) * chore(release): prepare v1.0.6 changelog * chore: bump version to 1.0.6 --------- Co-authored-by: Liudmyla Sovetovs <milkaegik@gmail.com> Co-authored-by: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com>
1 parent 2bafe82 commit e0d1e01

133 files changed

Lines changed: 47650 additions & 467 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,3 +782,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
782782
- Reduced server load by moving auth and progress logic to client
783783
- Improved ISR caching efficiency for quizzes page
784784
- Faster navigation and more stable UI during locale and tab changes
785+
786+
## [1.0.6] - 2026-03-01
787+
788+
### Added
789+
790+
- New learning categories with visual identity:
791+
- Django
792+
- Docker
793+
- Kubernetes
794+
- AWS
795+
- Azure
796+
- DevOps
797+
- Category-specific SVG icons with accent styling across tabs, pagination, and controls
798+
- Improved AWS icon readability in dark mode
799+
800+
### Shop (Production Readiness)
801+
802+
- Audit-driven end-to-end purchase hardening:
803+
- Canonical append-only event/audit system
804+
- Async email notifications via outbox worker
805+
- Persisted checkout legal consent (terms/privacy)
806+
- Returns & exchanges lifecycle support
807+
- Admin audit logs for product operations
808+
- Token-scoped guest order access
809+
- Improved Monobank webhook retry behavior
810+
- Added Playwright E2E coverage for Shop flows
811+
812+
### Performance & Infrastructure
813+
814+
- Reduced Neon compute usage:
815+
- Throttled background janitor jobs (every 30 min)
816+
- Partial indexes for order sweeps
817+
- SKIP LOCKED batching to reduce contention
818+
- Optimized checkout and payment status polling with backoff strategy
819+
- Lightweight order status view for faster client updates
820+
- Reduced session activity write frequency

frontend/.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ GMAIL_USER=
127127
# --- Shop / Internal
128128
# Optional public/base URL used by shop services/links
129129
SHOP_BASE_URL=
130+
SHOP_PRIVACY_VERSION=privacy-v1
131+
SHOP_TERMS_VERSION=terms-v1
130132

131133
# Required for signed shop status tokens (if status endpoint/token flow is enabled)
132134
SHOP_STATUS_TOKEN_SECRET=
@@ -163,4 +165,4 @@ TRUST_FORWARDED_HEADERS=0
163165
# emergency switch
164166
RATE_LIMIT_DISABLED=0
165167

166-
GROQ_API_KEY=
168+
GROQ_API_KEY=

frontend/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# testing
1414
/coverage
1515
/coverage-quiz
16+
/playwright-report
17+
/test-results
1618

1719
# next.js
1820
/.next/

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const POLL_BUSY_RETRY_DELAY_MS = 1_000;
7575
const POLL_STOP_ERROR_CODES = new Set([
7676
'STATUS_TOKEN_REQUIRED',
7777
'STATUS_TOKEN_INVALID',
78+
'STATUS_TOKEN_SCOPE_FORBIDDEN',
7879
'UNAUTHORIZED',
7980
'FORBIDDEN',
8081
]);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import crypto from 'node:crypto';
2+
3+
import { NextRequest, NextResponse } from 'next/server';
4+
5+
import {
6+
AdminApiDisabledError,
7+
AdminForbiddenError,
8+
AdminUnauthorizedError,
9+
requireAdminApi,
10+
} from '@/lib/auth/admin';
11+
import { logError, logWarn } from '@/lib/logging';
12+
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
13+
import { guardBrowserSameOrigin } from '@/lib/security/origin';
14+
import {
15+
InvalidPayloadError,
16+
OrderNotFoundError,
17+
} from '@/lib/services/errors';
18+
import { offerIntlQuote } from '@/lib/services/shop/quotes';
19+
import {
20+
intlQuoteOfferPayloadSchema,
21+
orderIdParamSchema,
22+
} from '@/lib/validation/shop';
23+
24+
function noStoreJson(body: unknown, init?: { status?: number }) {
25+
const res = NextResponse.json(body, { status: init?.status ?? 200 });
26+
res.headers.set('Cache-Control', 'no-store');
27+
return res;
28+
}
29+
30+
function mapQuoteErrorStatus(code: string): number {
31+
if (
32+
code === 'QUOTE_VERSION_CONFLICT' ||
33+
code === 'QUOTE_NOT_APPLICABLE' ||
34+
code === 'QUOTE_ALREADY_ACCEPTED'
35+
) {
36+
return 409;
37+
}
38+
if (code === 'QUOTE_INVALID_EXPIRY') return 422;
39+
return 400;
40+
}
41+
42+
export const runtime = 'nodejs';
43+
44+
export async function POST(
45+
request: NextRequest,
46+
context: { params: Promise<{ id: string }> }
47+
) {
48+
const requestId =
49+
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();
50+
const baseMeta = {
51+
requestId,
52+
route: request.nextUrl.pathname,
53+
method: request.method,
54+
};
55+
let orderIdForLog: string | null = null;
56+
57+
const blocked = guardBrowserSameOrigin(request);
58+
if (blocked) {
59+
blocked.headers.set('Cache-Control', 'no-store');
60+
return blocked;
61+
}
62+
63+
try {
64+
const admin = await requireAdminApi(request);
65+
const csrfRes = requireAdminCsrf(request, 'admin:orders:quote:offer');
66+
if (csrfRes) {
67+
csrfRes.headers.set('Cache-Control', 'no-store');
68+
return csrfRes;
69+
}
70+
71+
const parsedParams = orderIdParamSchema.safeParse(await context.params);
72+
if (!parsedParams.success) {
73+
return noStoreJson(
74+
{ code: 'INVALID_ORDER_ID', message: 'Invalid order id.' },
75+
{ status: 400 }
76+
);
77+
}
78+
orderIdForLog = parsedParams.data.id;
79+
80+
let rawBody: unknown;
81+
try {
82+
rawBody = await request.json();
83+
} catch {
84+
return noStoreJson(
85+
{ code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' },
86+
{ status: 400 }
87+
);
88+
}
89+
90+
const parsedBody = intlQuoteOfferPayloadSchema.safeParse(rawBody);
91+
if (!parsedBody.success) {
92+
return noStoreJson(
93+
{ code: 'INVALID_PAYLOAD', message: 'Invalid payload.' },
94+
{ status: 400 }
95+
);
96+
}
97+
98+
const payload = parsedBody.data;
99+
const result = await offerIntlQuote({
100+
orderId: orderIdForLog,
101+
requestId,
102+
actorUserId: typeof admin.id === 'string' ? admin.id : null,
103+
version: payload.version,
104+
currency: payload.currency,
105+
shippingQuoteMinor: payload.shippingQuoteMinor,
106+
expiresAt: payload.expiresAt ?? null,
107+
payload: payload.payload,
108+
});
109+
110+
return noStoreJson(
111+
{
112+
success: true,
113+
orderId: result.orderId,
114+
version: result.version,
115+
quoteStatus: result.quoteStatus,
116+
shippingQuoteMinor: result.shippingQuoteMinor,
117+
currency: result.currency,
118+
expiresAt: result.expiresAt.toISOString(),
119+
},
120+
{ status: 200 }
121+
);
122+
} catch (error) {
123+
if (error instanceof AdminApiDisabledError) {
124+
return noStoreJson(
125+
{ code: 'ADMIN_API_DISABLED', message: 'Admin API is disabled.' },
126+
{ status: 403 }
127+
);
128+
}
129+
if (error instanceof AdminUnauthorizedError) {
130+
return noStoreJson(
131+
{ code: error.code, message: 'Unauthorized.' },
132+
{ status: 401 }
133+
);
134+
}
135+
if (error instanceof AdminForbiddenError) {
136+
return noStoreJson({ code: error.code, message: 'Forbidden.' }, { status: 403 });
137+
}
138+
if (error instanceof OrderNotFoundError) {
139+
return noStoreJson({ code: error.code }, { status: 404 });
140+
}
141+
if (error instanceof InvalidPayloadError) {
142+
logWarn('admin_quote_offer_rejected', {
143+
...baseMeta,
144+
orderId: orderIdForLog,
145+
code: error.code,
146+
});
147+
return noStoreJson(
148+
{
149+
code: error.code,
150+
message: error.message,
151+
...(error.details ? { details: error.details } : {}),
152+
},
153+
{ status: mapQuoteErrorStatus(error.code) }
154+
);
155+
}
156+
157+
logError('admin_quote_offer_failed', error, {
158+
...baseMeta,
159+
orderId: orderIdForLog,
160+
code: 'ADMIN_QUOTE_OFFER_FAILED',
161+
});
162+
return noStoreJson(
163+
{ code: 'INTERNAL_ERROR', message: 'Unable to offer quote.' },
164+
{ status: 500 }
165+
);
166+
}
167+
}

frontend/app/api/shop/admin/products/[id]/route.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getAdminProductByIdWithPrices,
2626
updateProduct,
2727
} from '@/lib/services/products';
28+
import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit';
2829

2930
export const runtime = 'nodejs';
3031

@@ -320,7 +321,9 @@ export async function PATCH(
320321
let productIdForLog: string | null = null;
321322

322323
try {
323-
await requireAdminApi(request);
324+
const adminUser = await requireAdminApi(request);
325+
const actorUserId =
326+
adminUser && typeof adminUser.id === 'string' ? adminUser.id : null;
324327

325328
const rawParams = await context.params;
326329
const parsedParams = productIdParamSchema.safeParse(rawParams);
@@ -457,6 +460,51 @@ export async function PATCH(
457460
: undefined,
458461
});
459462

463+
try {
464+
await writeAdminAudit({
465+
actorUserId,
466+
action: 'product_admin_action.update',
467+
targetType: 'product',
468+
targetId: updated.id,
469+
requestId,
470+
payload: {
471+
productId: updated.id,
472+
slug: updated.slug,
473+
title: updated.title,
474+
badge: updated.badge,
475+
isActive: updated.isActive,
476+
isFeatured: updated.isFeatured,
477+
stock: updated.stock,
478+
},
479+
dedupeSeed: {
480+
domain: 'product_admin_action',
481+
action: 'update',
482+
requestId,
483+
productId: updated.id,
484+
slug: updated.slug,
485+
toBadge: updated.badge,
486+
toIsActive: updated.isActive,
487+
toIsFeatured: updated.isFeatured,
488+
toStock: updated.stock,
489+
},
490+
});
491+
} catch (auditError) {
492+
logWarn('admin_product_update_audit_failed', {
493+
...baseMeta,
494+
code: 'AUDIT_WRITE_FAILED',
495+
requestId,
496+
actorUserId,
497+
productId: updated.id,
498+
action: 'product_admin_action.update',
499+
message:
500+
auditError instanceof Error
501+
? auditError.message
502+
: String(auditError),
503+
durationMs: Date.now() - startedAtMs,
504+
});
505+
throw auditError;
506+
}
507+
460508
return noStoreJson({ success: true, product: updated }, { status: 200 });
461509
} catch (error) {
462510
if (error instanceof PriceConfigError) {
@@ -643,7 +691,9 @@ export async function DELETE(
643691
let productIdForLog: string | null = null;
644692

645693
try {
646-
await requireAdminApi(request);
694+
const adminUser = await requireAdminApi(request);
695+
const actorUserId =
696+
adminUser && typeof adminUser.id === 'string' ? adminUser.id : null;
647697

648698
const csrfRes = requireAdminCsrf(request, 'admin:products:delete');
649699
if (csrfRes) {
@@ -704,6 +754,39 @@ export async function DELETE(
704754

705755
await deleteProduct(productIdForLog);
706756

757+
try {
758+
await writeAdminAudit({
759+
actorUserId,
760+
action: 'product_admin_action.delete',
761+
targetType: 'product',
762+
targetId: productIdForLog,
763+
requestId,
764+
payload: {
765+
productId: productIdForLog,
766+
},
767+
dedupeSeed: {
768+
domain: 'product_admin_action',
769+
action: 'delete',
770+
requestId,
771+
productId: productIdForLog,
772+
},
773+
});
774+
} catch (auditError) {
775+
logWarn('admin_product_delete_audit_failed', {
776+
...baseMeta,
777+
code: 'AUDIT_WRITE_FAILED',
778+
requestId,
779+
actorUserId,
780+
productId: productIdForLog,
781+
action: 'product_admin_action.delete',
782+
message:
783+
auditError instanceof Error ? auditError.message : String(auditError),
784+
durationMs: Date.now() - startedAtMs,
785+
});
786+
// Delete is irreversible; keep success response to avoid misleading retries.
787+
// Audit failure is logged and should be monitored/alerted separately.
788+
}
789+
707790
return noStoreJson({ success: true }, { status: 200 });
708791
} catch (error) {
709792
if (error instanceof AdminApiDisabledError) {

0 commit comments

Comments
 (0)