Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2c77e2a
(SP: 3) [Backend] add Nova Poshta shipping foundation + checkout pers…
liudmylasovetovs Feb 25, 2026
4dd23b2
(SP: 2) [Frontend] Reduce Vercel variable costs via caching and analy…
ViktorSvertoka Feb 25, 2026
cf43f36
fix(build): align Netlify Node version and remove SpeedInsights import
ViktorSvertoka Feb 26, 2026
e07941e
chore(release): bump version to 1.0.4
ViktorSvertoka Feb 26, 2026
006b39b
Merge remote-tracking branch 'origin/main' into develop
ViktorSvertoka Feb 26, 2026
6e3526f
(SP: 2) [Frontend] Remove [locale] layout force-dynamic and move auth…
ViktorSvertoka Feb 26, 2026
9130df1
(SP: 2) [Frontend] Reduce auth overhead and sync auth state across t…
ViktorSvertoka Feb 26, 2026
3a59b4e
(SP: 2) [Frontend] Quizzes page ISR + client-side progress + GitHub s…
LesiaUKR Feb 26, 2026
d6697f4
(SP: 1) [Frontend] Fix quiz timer flash and card layout shift on quiz…
LesiaUKR Feb 26, 2026
fe667af
chore(release): v1.0.5
ViktorSvertoka Feb 26, 2026
b94a599
Merge branch 'main' into develop
ViktorSvertoka Feb 26, 2026
ffca8ca
(SP: 3)[Shop][DB] Reduce Neon compute: throttle janitor + relax check…
liudmylasovetovs Feb 27, 2026
bf6d333
(SP: 3) [SHOP] audit-driven e2e purchase readiness hardening (events,…
liudmylasovetovs Feb 28, 2026
fee0978
feat(ui): add devops/cloud category icons and styles (#379)
ViktorSvertoka Mar 1, 2026
aa5d692
chore(release): prepare v1.0.6 changelog
ViktorSvertoka Mar 1, 2026
5409e13
chore: bump version to 1.0.6
ViktorSvertoka Mar 1, 2026
e216a39
Merge branch 'main' into develop
ViktorSvertoka Mar 1, 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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -782,3 +782,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- Reduced server load by moving auth and progress logic to client
- Improved ISR caching efficiency for quizzes page
- Faster navigation and more stable UI during locale and tab changes

## [1.0.6] - 2026-03-01

### Added

- New learning categories with visual identity:
- Django
- Docker
- Kubernetes
- AWS
- Azure
- DevOps
- Category-specific SVG icons with accent styling across tabs, pagination, and controls
- Improved AWS icon readability in dark mode

### Shop (Production Readiness)

- Audit-driven end-to-end purchase hardening:
- Canonical append-only event/audit system
- Async email notifications via outbox worker
- Persisted checkout legal consent (terms/privacy)
- Returns & exchanges lifecycle support
- Admin audit logs for product operations
- Token-scoped guest order access
- Improved Monobank webhook retry behavior
- Added Playwright E2E coverage for Shop flows

### Performance & Infrastructure

- Reduced Neon compute usage:
- Throttled background janitor jobs (every 30 min)
- Partial indexes for order sweeps
- SKIP LOCKED batching to reduce contention
- Optimized checkout and payment status polling with backoff strategy
- Lightweight order status view for faster client updates
- Reduced session activity write frequency
4 changes: 3 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ GMAIL_USER=
# --- Shop / Internal
# Optional public/base URL used by shop services/links
SHOP_BASE_URL=
SHOP_PRIVACY_VERSION=privacy-v1
SHOP_TERMS_VERSION=terms-v1

# Required for signed shop status tokens (if status endpoint/token flow is enabled)
SHOP_STATUS_TOKEN_SECRET=
Expand Down Expand Up @@ -163,4 +165,4 @@ TRUST_FORWARDED_HEADERS=0
# emergency switch
RATE_LIMIT_DISABLED=0

GROQ_API_KEY=
GROQ_API_KEY=
2 changes: 2 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# testing
/coverage
/coverage-quiz
/playwright-report
/test-results

# next.js
/.next/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const POLL_BUSY_RETRY_DELAY_MS = 1_000;
const POLL_STOP_ERROR_CODES = new Set([
'STATUS_TOKEN_REQUIRED',
'STATUS_TOKEN_INVALID',
'STATUS_TOKEN_SCOPE_FORBIDDEN',
'UNAUTHORIZED',
'FORBIDDEN',
]);
Expand Down
167 changes: 167 additions & 0 deletions frontend/app/api/shop/admin/orders/[id]/quote/offer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import crypto from 'node:crypto';

import { NextRequest, NextResponse } from 'next/server';

import {
AdminApiDisabledError,
AdminForbiddenError,
AdminUnauthorizedError,
requireAdminApi,
} from '@/lib/auth/admin';
import { logError, logWarn } from '@/lib/logging';
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
import { guardBrowserSameOrigin } from '@/lib/security/origin';
import {
InvalidPayloadError,
OrderNotFoundError,
} from '@/lib/services/errors';
import { offerIntlQuote } from '@/lib/services/shop/quotes';
import {
intlQuoteOfferPayloadSchema,
orderIdParamSchema,
} from '@/lib/validation/shop';

function noStoreJson(body: unknown, init?: { status?: number }) {
const res = NextResponse.json(body, { status: init?.status ?? 200 });
res.headers.set('Cache-Control', 'no-store');
return res;
}

function mapQuoteErrorStatus(code: string): number {
if (
code === 'QUOTE_VERSION_CONFLICT' ||
code === 'QUOTE_NOT_APPLICABLE' ||
code === 'QUOTE_ALREADY_ACCEPTED'
) {
return 409;
}
if (code === 'QUOTE_INVALID_EXPIRY') return 422;
return 400;
}

export const runtime = 'nodejs';

export async function POST(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const requestId =
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();
const baseMeta = {
requestId,
route: request.nextUrl.pathname,
method: request.method,
};
let orderIdForLog: string | null = null;

const blocked = guardBrowserSameOrigin(request);
if (blocked) {
blocked.headers.set('Cache-Control', 'no-store');
return blocked;
}

try {
const admin = await requireAdminApi(request);
const csrfRes = requireAdminCsrf(request, 'admin:orders:quote:offer');
if (csrfRes) {
csrfRes.headers.set('Cache-Control', 'no-store');
return csrfRes;
}

const parsedParams = orderIdParamSchema.safeParse(await context.params);
if (!parsedParams.success) {
return noStoreJson(
{ code: 'INVALID_ORDER_ID', message: 'Invalid order id.' },
{ status: 400 }
);
}
orderIdForLog = parsedParams.data.id;

let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return noStoreJson(
{ code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' },
{ status: 400 }
);
}

const parsedBody = intlQuoteOfferPayloadSchema.safeParse(rawBody);
if (!parsedBody.success) {
return noStoreJson(
{ code: 'INVALID_PAYLOAD', message: 'Invalid payload.' },
{ status: 400 }
);
}

const payload = parsedBody.data;
const result = await offerIntlQuote({
orderId: orderIdForLog,
requestId,
actorUserId: typeof admin.id === 'string' ? admin.id : null,
version: payload.version,
currency: payload.currency,
shippingQuoteMinor: payload.shippingQuoteMinor,
expiresAt: payload.expiresAt ?? null,
payload: payload.payload,
});

return noStoreJson(
{
success: true,
orderId: result.orderId,
version: result.version,
quoteStatus: result.quoteStatus,
shippingQuoteMinor: result.shippingQuoteMinor,
currency: result.currency,
expiresAt: result.expiresAt.toISOString(),
},
{ status: 200 }
);
} catch (error) {
if (error instanceof AdminApiDisabledError) {
return noStoreJson(
{ code: 'ADMIN_API_DISABLED', message: 'Admin API is disabled.' },
{ status: 403 }
);
}
if (error instanceof AdminUnauthorizedError) {
return noStoreJson(
{ code: error.code, message: 'Unauthorized.' },
{ status: 401 }
);
}
if (error instanceof AdminForbiddenError) {
return noStoreJson({ code: error.code, message: 'Forbidden.' }, { status: 403 });
}
if (error instanceof OrderNotFoundError) {
return noStoreJson({ code: error.code }, { status: 404 });
}
if (error instanceof InvalidPayloadError) {
logWarn('admin_quote_offer_rejected', {
...baseMeta,
orderId: orderIdForLog,
code: error.code,
});
return noStoreJson(
{
code: error.code,
message: error.message,
...(error.details ? { details: error.details } : {}),
},
{ status: mapQuoteErrorStatus(error.code) }
);
}

logError('admin_quote_offer_failed', error, {
...baseMeta,
orderId: orderIdForLog,
code: 'ADMIN_QUOTE_OFFER_FAILED',
});
return noStoreJson(
{ code: 'INTERNAL_ERROR', message: 'Unable to offer quote.' },
{ status: 500 }
);
}
}
87 changes: 85 additions & 2 deletions frontend/app/api/shop/admin/products/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getAdminProductByIdWithPrices,
updateProduct,
} from '@/lib/services/products';
import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit';

export const runtime = 'nodejs';

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

try {
await requireAdminApi(request);
const adminUser = await requireAdminApi(request);
const actorUserId =
adminUser && typeof adminUser.id === 'string' ? adminUser.id : null;

const rawParams = await context.params;
const parsedParams = productIdParamSchema.safeParse(rawParams);
Expand Down Expand Up @@ -457,6 +460,51 @@ export async function PATCH(
: undefined,
});

try {
await writeAdminAudit({
actorUserId,
action: 'product_admin_action.update',
targetType: 'product',
targetId: updated.id,
requestId,
payload: {
productId: updated.id,
slug: updated.slug,
title: updated.title,
badge: updated.badge,
isActive: updated.isActive,
isFeatured: updated.isFeatured,
stock: updated.stock,
},
dedupeSeed: {
domain: 'product_admin_action',
action: 'update',
requestId,
productId: updated.id,
slug: updated.slug,
toBadge: updated.badge,
toIsActive: updated.isActive,
toIsFeatured: updated.isFeatured,
toStock: updated.stock,
},
});
} catch (auditError) {
logWarn('admin_product_update_audit_failed', {
...baseMeta,
code: 'AUDIT_WRITE_FAILED',
requestId,
actorUserId,
productId: updated.id,
action: 'product_admin_action.update',
message:
auditError instanceof Error
? auditError.message
: String(auditError),
durationMs: Date.now() - startedAtMs,
});
throw auditError;
}
Comment on lines +491 to +506
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Audit failure causes 500 error even though product update succeeded.

When writeAdminAudit fails (line 491-505), the error is rethrown and eventually returns a 500 response. However, updateProduct at line 455 already succeeded. This creates a confusing user experience:

  • Client receives 500 error
  • Client may retry the update
  • But the product was actually updated

This is inconsistent with the DELETE handler (lines 774-788) which logs audit failures but still returns success.

🔧 Recommended fix

Follow the same pattern as DELETE - log the failure but return success:

       try {
         await writeAdminAudit({
           actorUserId,
           action: 'product_admin_action.update',
           // ... rest of args
         });
       } catch (auditError) {
         logWarn('admin_product_update_audit_failed', {
           ...baseMeta,
           code: 'AUDIT_WRITE_FAILED',
           // ... rest of meta
         });
-        throw auditError;
+        // Update is complete; keep success response to avoid misleading retries.
+        // Audit failure is logged and should be monitored/alerted separately.
       }

       return noStoreJson({ success: true, product: updated }, { status: 200 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/shop/admin/products/`[id]/route.ts around lines 491 - 506,
The catch block for writeAdminAudit currently rethrows auditError causing a 500
even though updateProduct succeeded; remove the throw and follow the DELETE
handler pattern by logging the audit failure (using logWarn as already done) but
returning a successful response containing the updated product (or appropriate
200/204) after updateProduct completes; specifically, in the route where
updateProduct and writeAdminAudit are called, delete the `throw auditError` and
ensure the function returns the same success payload/status it would when
auditing succeeds so clients see the update as successful.


return noStoreJson({ success: true, product: updated }, { status: 200 });
} catch (error) {
if (error instanceof PriceConfigError) {
Expand Down Expand Up @@ -643,7 +691,9 @@ export async function DELETE(
let productIdForLog: string | null = null;

try {
await requireAdminApi(request);
const adminUser = await requireAdminApi(request);
const actorUserId =
adminUser && typeof adminUser.id === 'string' ? adminUser.id : null;

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

await deleteProduct(productIdForLog);

try {
await writeAdminAudit({
actorUserId,
action: 'product_admin_action.delete',
targetType: 'product',
targetId: productIdForLog,
requestId,
payload: {
productId: productIdForLog,
},
dedupeSeed: {
domain: 'product_admin_action',
action: 'delete',
requestId,
productId: productIdForLog,
},
});
} catch (auditError) {
logWarn('admin_product_delete_audit_failed', {
...baseMeta,
code: 'AUDIT_WRITE_FAILED',
requestId,
actorUserId,
productId: productIdForLog,
action: 'product_admin_action.delete',
message:
auditError instanceof Error ? auditError.message : String(auditError),
durationMs: Date.now() - startedAtMs,
});
// Delete is irreversible; keep success response to avoid misleading retries.
// Audit failure is logged and should be monitored/alerted separately.
}

return noStoreJson({ success: true }, { status: 200 });
} catch (error) {
if (error instanceof AdminApiDisabledError) {
Expand Down
Loading