Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
142 changes: 142 additions & 0 deletions frontend/actions/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use server';

import { and, asc, eq } from 'drizzle-orm';

import { db } from '@/db';
import { aiLearnedTerms } from '@/db/schema/ai';
import { getCurrentUser } from '@/lib/auth';
import type { ExplanationResponse } from '@/lib/ai/prompts';

function normalizeTerm(term: string): string {
return term.toLowerCase().trim();
}

export async function saveLearnedTerm(
term: string,
explanation: ExplanationResponse
): Promise<{ success: boolean; error?: string }> {
const user = await getCurrentUser();
if (!user) return { success: false, error: 'Unauthorized' };

const normalized = normalizeTerm(term);
if (!normalized) return { success: false, error: 'Invalid term' };

try {
await db
.insert(aiLearnedTerms)
.values({
userId: user.id,
term: normalized,
explanationUk: explanation.uk,
explanationEn: explanation.en,
explanationPl: explanation.pl,
sortOrder: 0,
})
.onConflictDoUpdate({
target: [aiLearnedTerms.userId, aiLearnedTerms.term],
set: {
explanationUk: explanation.uk,
explanationEn: explanation.en,
explanationPl: explanation.pl,
},
});

return { success: true };
} catch (error) {
console.error('[ai] Failed to save learned term:', error);
return { success: false, error: 'Failed to save term' };
}
}

export async function getLearnedTerms(): Promise<
| {
success: true;
terms: {
term: string;
explanationUk: string;
explanationEn: string;
explanationPl: string;
isHidden: boolean;
sortOrder: number;
}[];
}
| { success: false; error: string }
> {
const user = await getCurrentUser();
if (!user) return { success: false, error: 'Unauthorized' };

try {
const rows = await db
.select({
term: aiLearnedTerms.term,
explanationUk: aiLearnedTerms.explanationUk,
explanationEn: aiLearnedTerms.explanationEn,
explanationPl: aiLearnedTerms.explanationPl,
isHidden: aiLearnedTerms.isHidden,
sortOrder: aiLearnedTerms.sortOrder,
})
.from(aiLearnedTerms)
.where(eq(aiLearnedTerms.userId, user.id))
.orderBy(asc(aiLearnedTerms.sortOrder), asc(aiLearnedTerms.createdAt));

return { success: true, terms: rows };
} catch (error) {
console.error('[ai] Failed to fetch learned terms:', error);
return { success: false, error: 'Failed to fetch terms' };
}
}

export async function setTermHidden(
term: string,
isHidden: boolean
): Promise<{ success: boolean; error?: string }> {
const user = await getCurrentUser();
if (!user) return { success: false, error: 'Unauthorized' };

const normalized = normalizeTerm(term);

try {
await db
.update(aiLearnedTerms)
.set({ isHidden })
.where(
and(
eq(aiLearnedTerms.userId, user.id),
eq(aiLearnedTerms.term, normalized)
)
);

return { success: true };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (error) {
console.error('[ai] Failed to update term visibility:', error);
return { success: false, error: 'Failed to update term' };
}
}

export async function updateTermsOrder(
orderedTerms: string[]
): Promise<{ success: boolean; error?: string }> {
const user = await getCurrentUser();
if (!user) return { success: false, error: 'Unauthorized' };

try {
await Promise.all(
orderedTerms.map((term, index) =>
db
.update(aiLearnedTerms)
.set({ sortOrder: index })
.where(
and(
eq(aiLearnedTerms.userId, user.id),
eq(aiLearnedTerms.term, normalizeTerm(term))
)
)
)
);
Comment on lines +124 to +136
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the file
git ls-files frontend/actions/ | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 180


🏁 Script executed:

# Check the actual content around lines 123-135
cat -n frontend/actions/ai.ts | sed -n '110,145p'

Repository: DevLoversTeam/devlovers.net

Length of output: 1200


🏁 Script executed:

# Find database imports and setup in the file
rg "import.*db" frontend/actions/ai.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 146


🏁 Script executed:

# Look for database library setup
fd "*.ts" | xargs rg -l "db\.(transaction|update)" | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 467


🏁 Script executed:

# Check package.json for database dependencies
cat package.json | grep -A 20 "dependencies"

Repository: DevLoversTeam/devlovers.net

Length of output: 115


🏁 Script executed:

# Look for transaction usage patterns in the codebase
rg "db\.transaction\|\.transaction\(" frontend/ --type ts -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check the db setup/exports
cat frontend/db/index.ts 2>/dev/null || cat -n frontend/db.ts 2>/dev/null || find . -path "*/db/index.ts" -o -path "*/db.ts" | head -1 | xargs cat

Repository: DevLoversTeam/devlovers.net

Length of output: 2161


🏁 Script executed:

# Look at the db schema and setup
find . -name "index.ts" -path "*/db/*" | head -1 | xargs cat -n

Repository: DevLoversTeam/devlovers.net

Length of output: 2707


🏁 Script executed:

# Search for any transaction usage in the codebase
rg "transaction\(" . --type ts -B 2 -A 2

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check Drizzle ORM documentation and verify neon-http transaction support
# First, let's see if there are any existing transaction patterns
rg "db\." frontend/actions/ --type ts | grep -i "transaction\|batch" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Let's check if there's a package-lock.json or similar to see exact versions
find . -name "package-lock.json" -o -name "pnpm-lock.yaml" -o -name "yarn.lock" | head -1

Repository: DevLoversTeam/devlovers.net

Length of output: 99


🏁 Script executed:

# Check how other files handle multiple updates
rg "Promise\.all.*update\|batch.*update" frontend/actions/ --type ts -B 3 -A 3

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check the drizzle-orm version in package-lock.json
cat ./frontend/package-lock.json | grep -A 2 '"drizzle-orm"' | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 169


🏁 Script executed:

# Also check for `@neondatabase/serverless` version
cat ./frontend/package-lock.json | grep -A 2 '@neondatabase/serverless' | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 902


🏁 Script executed:

# Let's also check if there's any documentation or comments about transaction support
rg "transaction" frontend/db/ --type ts

Repository: DevLoversTeam/devlovers.net

Length of output: 697


🏁 Script executed:

# Check if there are other update operations in the codebase to see patterns used
rg "\.update\(" frontend/actions/ --type ts -B 1 -A 5 | head -50

Repository: DevLoversTeam/devlovers.net

Length of output: 1671


🏁 Script executed:

# Verify if drizzle-orm/neon-http supports transactions by checking documentation patterns
# Let's see if there are any workarounds or batch operations documented
rg "batch\|transaction" frontend/db/index.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check if there's any conditional logic for transaction handling
cat frontend/db/index.ts | grep -A 50 "export"

Repository: DevLoversTeam/devlovers.net

Length of output: 85


🌐 Web query:

drizzle-orm neon-http transaction support v0.45

💡 Result:

No, Drizzle ORM's neon-http driver does not support interactive transactions (like db.transaction) in v0.45.x. It explicitly throws "No transactions support in neon-http driver" as seen in recent issues from September 2025 with Drizzle v0.44.5. Use db.batch for non-interactive multi-statement batches instead, added in v0.29.4. For full transaction support (interactive with rollbacks/savepoints), switch to neon-serverless (WebSocket) driver. Official docs note neon-http is for "single, non-interactive transactions" and recommend neon-serverless for "session or interactive transaction support". No changelog entries in v0.45.0/0.45.1 mention adding transaction support to neon-http.

Citations:


🏁 Script executed:

# Check if there are any batch operations documented or used elsewhere
rg "\.batch\(" frontend/actions/ --type ts

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Let's check the actual error handling and transaction attempts in ai.ts more carefully
cat -n frontend/actions/ai.ts | sed -n '115,145p'

Repository: DevLoversTeam/devlovers.net

Length of output: 1008


🌐 Web query:

drizzle-orm batch method v0.45 documentation

💡 Result:

Drizzle ORM's db.batch method executes multiple SQL statements in a single batch call, supported for specific drivers: LibSQL (Turso), Neon, and D1 (Cloudflare). It reduces network round trips for better performance. For LibSQL, it's an implicit transaction: all succeed or all rollback. For D1, statements execute sequentially in auto-commit; failure aborts the sequence. Not supported for standard PostgreSQL/node-postgres due to protocol limitations (extended-query doesn't allow multiple statements). Usage example: const batchResponse = await db.batch([ db.insert(usersTable).values({ id: 1, name: 'John' }).returning({ id: usersTable.id }), db.update(usersTable).set({ name: 'Dan' }).where(eq(usersTable.id, 1)), db.query.usersTable.findMany({}), db.select.from(usersTable).where(eq(usersTable.id, 1)), db.select({ id: usersTable.id, invitedBy: usersTable.invitedBy }).from(usersTable), ]); Supported builders: db.all, db.get, db.values, db.run, db.execute, db.query.

.findMany, db.query.
.findFirst, db.select, db.update, db.insert, db.delete. BatchResponse is a tuple of results matching each statement's return type (e.g., arrays of rows, ResultSet, etc.). This API has been stable since early implementations (e.g., v0.28+), with no specific changes or new features in v0.45.0 (released Dec 2025, bug fixes only). Official docs at https://orm.drizzle.team/docs/batch-api apply to v0.45.x.

Citations:


Use db.batch() instead of Promise.all for atomic ordering updates.

The current Promise.all approach doesn't guarantee atomicity—if one update fails, earlier ones may already be committed, leaving the order inconsistent. However, db.transaction() is not supported by the neon-http driver used in production. Use db.batch() instead, which is supported by both the local PostgreSQL and Neon drivers and provides atomicity for Neon (implicit transaction).

💡 Suggested fix
   try {
-    await Promise.all(
-      orderedTerms.map((term, index) =>
-        db
-          .update(aiLearnedTerms)
-          .set({ sortOrder: index })
-          .where(
-            and(
-              eq(aiLearnedTerms.userId, user.id),
-              eq(aiLearnedTerms.term, normalizeTerm(term))
-            )
-          )
-      )
-    );
+    await db.batch(
+      orderedTerms.map((term, index) =>
+        db
+          .update(aiLearnedTerms)
+          .set({ sortOrder: index })
+          .where(
+            and(
+              eq(aiLearnedTerms.userId, user.id),
+              eq(aiLearnedTerms.term, normalizeTerm(term))
+            )
+          )
+      )
+    );

     return { success: true };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await Promise.all(
orderedTerms.map((term, index) =>
db
.update(aiLearnedTerms)
.set({ sortOrder: index })
.where(
and(
eq(aiLearnedTerms.userId, user.id),
eq(aiLearnedTerms.term, normalizeTerm(term))
)
)
)
);
await db.batch(
orderedTerms.map((term, index) =>
db
.update(aiLearnedTerms)
.set({ sortOrder: index })
.where(
and(
eq(aiLearnedTerms.userId, user.id),
eq(aiLearnedTerms.term, normalizeTerm(term))
)
)
)
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/actions/ai.ts` around lines 123 - 135, The batch of per-term updates
using Promise.all should be replaced with a single db.batch call to ensure
atomic ordering updates; locate the orderedTerms.map block that builds
db.update(aiLearnedTerms).set({ sortOrder: index
}).where(and(eq(aiLearnedTerms.userId, user.id), eq(aiLearnedTerms.term,
normalizeTerm(term)))) and instead collect those update queries into an array
and pass them to db.batch(...) so the Neon/local drivers run them atomically;
keep normalizeTerm, aiLearnedTerms, and user.id usage unchanged and return/await
the db.batch promise.


return { success: true };
} catch (error) {
console.error('[ai] Failed to update term order:', error);
return { success: false, error: 'Failed to update order' };
}
}
17 changes: 16 additions & 1 deletion frontend/components/auth/fields/PasswordField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import { useState } from 'react';

import { utf8ByteLength } from '@/lib/auth/password-bytes';
import {
PASSWORD_MAX_BYTES,
PASSWORD_MIN_LEN,
Expand Down Expand Up @@ -43,6 +44,13 @@ export function PasswordField({
return;
}

if (utf8ByteLength(input.value) > PASSWORD_MAX_BYTES) {
input.setCustomValidity(
t('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })
);
return;
}

if (input.validity.tooShort && minLength) {
input.setCustomValidity(t('validation.passwordTooShort', { minLength }));
return;
Expand All @@ -61,7 +69,14 @@ export function PasswordField({
};

const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
e.currentTarget.setCustomValidity('');
const input = e.currentTarget;
if (utf8ByteLength(input.value) > PASSWORD_MAX_BYTES) {
input.setCustomValidity(
t('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })
);
} else {
input.setCustomValidity('');
}
};

const resolvedPlaceholder = placeholder ?? t('password');
Expand Down
56 changes: 31 additions & 25 deletions frontend/components/dashboard/ExplainedTermsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,40 @@ import {
useState,
} from 'react';

import AIWordHelper from '@/components/q&a/AIWordHelper';
import { getCachedTerms } from '@/lib/ai/explainCache';
import {
getHiddenTerms,
hideTermFromDashboard,
unhideTermFromDashboard,
} from '@/lib/ai/hiddenTerms';
import { saveTermOrder, sortTermsByOrder } from '@/lib/ai/termOrder';
getLearnedTerms,
setTermHidden,
updateTermsOrder,
} from '@/actions/ai';
import AIWordHelper from '@/components/q&a/AIWordHelper';
import { setCachedExplanation } from '@/lib/ai/explainCache';

export function ExplainedTermsCard() {
const t = useTranslations('dashboard.explainedTerms');
const [terms, setTerms] = useState<string[]>([]);
const [hiddenTerms, setHiddenTerms] = useState<string[]>([]);

useEffect(() => {
const cached = getCachedTerms();
const hidden = getHiddenTerms();
startTransition(() => {
setTerms(
sortTermsByOrder(
cached.filter(term => !hidden.has(term.toLowerCase().trim()))
)
);
setHiddenTerms(
sortTermsByOrder(
cached.filter(term => hidden.has(term.toLowerCase().trim()))
)
);
getLearnedTerms().then(result => {
if (!result.success) return;
const visible: string[] = [];
const hidden: string[] = [];
for (const row of result.terms) {
setCachedExplanation(row.term, {
uk: row.explanationUk,
en: row.explanationEn,
pl: row.explanationPl,
});
if (row.isHidden) {
hidden.push(row.term);
} else {
visible.push(row.term);
}
}
startTransition(() => {
setTerms(visible);
setHiddenTerms(hidden);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}, []);
const [showMore, setShowMore] = useState(false);
Expand All @@ -59,19 +65,19 @@ export function ExplainedTermsCard() {
} | null>(null);

const handleRemoveTerm = (term: string) => {
hideTermFromDashboard(term);
setTerms(prevTerms => prevTerms.filter(t => t !== term));
setHiddenTerms(prevHidden => [...prevHidden, term]);
setTermHidden(term, true).catch(() => {});
};

const handleRestoreTerm = (term: string) => {
unhideTermFromDashboard(term);
setHiddenTerms(prevHidden => prevHidden.filter(t => t !== term));
setTerms(prevTerms => {
const updated = [...prevTerms, term];
saveTermOrder(updated);
updateTermsOrder(updated).catch(() => {});
return updated;
});
setTermHidden(term, false).catch(() => {});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

const handleDragStart = (index: number) => {
Expand All @@ -93,7 +99,7 @@ export function ExplainedTermsCard() {
const [dragged] = newTerms.splice(draggedIndex, 1);
newTerms.splice(targetIndex, 0, dragged);

saveTermOrder(newTerms);
updateTermsOrder(newTerms).catch(() => {});
return newTerms;
});

Expand Down Expand Up @@ -195,7 +201,7 @@ export function ExplainedTermsCard() {
const newTerms = [...prevTerms];
const [dragged] = newTerms.splice(fromIndex, 1);
newTerms.splice(toIndex, 0, dragged);
saveTermOrder(newTerms);
updateTermsOrder(newTerms).catch(() => {});
return newTerms;
});
}
Expand Down
17 changes: 17 additions & 0 deletions frontend/components/legal/LegalBackButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';

import { useRouter } from 'next/navigation';

export default function LegalBackButton({ label }: { label: string }) {
const router = useRouter();

return (
<button
type="button"
onClick={() => router.back()}
className="inline-flex text-sm text-slate-600 transition-colors hover:text-blue-600 dark:text-slate-300 dark:hover:text-white"
>
← {label}
</button>
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
11 changes: 3 additions & 8 deletions frontend/components/legal/LegalPageShell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getTranslations } from 'next-intl/server';

import { Link } from '@/i18n/routing';
import LegalBackButton from '@/components/legal/LegalBackButton';

type Props = {
title: string;
Expand All @@ -23,17 +23,12 @@ export default async function LegalPageShell({
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle,rgba(148,163,184,0.18)_1px,transparent_1px)] [background-size:22px_22px] opacity-0 dark:opacity-40"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle,rgba(148,163,184,0.18)_1px,transparent_1px)] bg-size-[22px_22px] opacity-0 dark:opacity-40"
/>

<div className="relative mx-auto max-w-4xl px-6 py-12 sm:py-16">
<header className="space-y-5">
<Link
href="/"
className="inline-flex text-sm text-slate-600 transition-colors hover:text-blue-600 dark:text-slate-300 dark:hover:text-white"
>
← {t('back')}
</Link>
<LegalBackButton label={t('back')} />

<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
{title}
Expand Down
18 changes: 18 additions & 0 deletions frontend/components/q&a/AIWordHelper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import React, { useCallback, useEffect, useRef, useState } from 'react';

import { saveLearnedTerm } from '@/actions/ai';
import { useAuth } from '@/hooks/useAuth';
import { Link } from '@/i18n/routing';
import {
Expand Down Expand Up @@ -203,6 +204,11 @@ export default function AIWordHelper({
}, [isOpen, validatedLocale]);

const fetchExplanation = useCallback(async () => {
if (term.length > 100) {
setError('TERM_TOO_LONG');
return;
}

const cached = getCachedExplanation(term);
if (cached) {
setExplanation(cached);
Expand Down Expand Up @@ -257,6 +263,9 @@ export default function AIWordHelper({
const data: ExplanationResponse = await response.json();
setExplanation(data);
setCachedExplanation(term, data);
saveLearnedTerm(term, data).catch(() => {
// DB sync failure is non-blocking; local cache still works
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't silently discard failed learned-term saves.

This is the write that makes the new learned-term sync durable for the explain flow. If it fails, the modal still looks successful locally but the term never shows up on the dashboard or another device, and the user gets no indication that persistence failed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/q`&a/AIWordHelper.tsx around lines 266 - 268, The current
silent catch on the saveLearnedTerm(term, data) call in AIWordHelper.tsx must be
changed to surface failures: update the catch block to log the error (include
the error object) and set a UI-visible failure state or invoke the existing
user-notification mechanism (toast/modal) so the user knows persistence failed;
optionally expose a retry action that re-calls saveLearnedTerm with the same
term/data and clear or update the state on success. Locate the
saveLearnedTerm(term, data).catch(...) invocation and replace the empty catch
with error handling that logs via your app logger and updates component state or
calls the notification helper so the dashboard sync problem is visible and
recoverable.

setRateLimitState({ isRateLimited: false, resetIn: 0, retryAttempts: 0 });
setServiceErrorState({
isServiceError: false,
Expand Down Expand Up @@ -761,6 +770,15 @@ export default function AIWordHelper({
)}
</button>
</>
) : error === 'TERM_TOO_LONG' ? (
<div className="text-center">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{messages.termTooLong.title}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{messages.termTooLong.hint}
</p>
</div>
) : (
<>
<p className="text-sm text-red-600 dark:text-red-400">
Expand Down
Loading
Loading