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
143 changes: 143 additions & 0 deletions frontend/actions/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
'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);
if (!normalized) return { success: false, error: 'Invalid term' };

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

return { success: true };
} 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
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
129 changes: 81 additions & 48 deletions frontend/components/dashboard/ExplainedTermsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,52 @@ 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[]>([]);
const [loadError, setLoadError] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);

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) {
setLoadError(true);
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);
setIsInitialLoad(false);
});
})
.catch(() => {
setLoadError(true);
setIsInitialLoad(false);
});
}, []);
const [showMore, setShowMore] = useState(false);
const [selectedTerm, setSelectedTerm] = useState<string | null>(null);
Expand All @@ -59,18 +76,28 @@ export function ExplainedTermsCard() {
} | null>(null);

const handleRemoveTerm = (term: string) => {
hideTermFromDashboard(term);
setTerms(prevTerms => prevTerms.filter(t => t !== term));
setHiddenTerms(prevHidden => [...prevHidden, term]);
const prevTerms = terms;
const prevHidden = hiddenTerms;
setTerms(prevTerms.filter(t => t !== term));
setHiddenTerms([...prevHidden, term]);
setTermHidden(term, true).catch(() => {
setTerms(prevTerms);
setHiddenTerms(prevHidden);
});
Comment on lines 78 to +86
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

Refetch authoritative state on mutation failure instead of restoring captured arrays.

Each .catch() restores a snapshot taken before the request. If another hide/restore/reorder succeeds while an earlier request is still in flight, a late failure will wipe out that newer change. The restore path is riskier still because setTermHidden() and updateTermsOrder() can partially succeed, leaving the server ahead of the UI after the rollback. Prefer a single server action for restore+reorder, or re-run getLearnedTerms() in the failure path.

Also applies to: 86-98, 115-123, 216-223

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

In `@frontend/components/dashboard/ExplainedTermsCard.tsx` around lines 75 - 83,
The current .catch() paths in handlers like handleRemoveTerm (and the similar
blocks around setTermHidden/updateTermsOrder and restore handlers) roll back
local arrays captured earlier, which can clobber later successful changes;
instead, remove the snapshot-restore logic and on any mutation failure call the
authoritative refetch function getLearnedTerms() (or invoke a single server
endpoint that performs restore+reorder) so the UI is reconciled with server
state; update the catch blocks for setTermHidden and updateTermsOrder calls to
await getLearnedTerms() (or the combined restore+reorder action) rather than
resetting the previous terms/hiddenTerms arrays.

};

const handleRestoreTerm = (term: string) => {
unhideTermFromDashboard(term);
setHiddenTerms(prevHidden => prevHidden.filter(t => t !== term));
setTerms(prevTerms => {
const updated = [...prevTerms, term];
saveTermOrder(updated);
return updated;
const prevTerms = terms;
const prevHidden = hiddenTerms;
const updatedTerms = [...prevTerms, term];
setTerms(updatedTerms);
setHiddenTerms(prevHidden.filter(t => t !== term));
Promise.all([
setTermHidden(term, false),
updateTermsOrder(updatedTerms),
]).catch(() => {
setTerms(prevTerms);
setHiddenTerms(prevHidden);
});
};

Expand All @@ -88,16 +115,15 @@ export function ExplainedTermsCard() {
return;
}

setTerms(prevTerms => {
const newTerms = [...prevTerms];
const [dragged] = newTerms.splice(draggedIndex, 1);
newTerms.splice(targetIndex, 0, dragged);

saveTermOrder(newTerms);
return newTerms;
});

const prevTerms = [...terms];
const newTerms = [...terms];
const [dragged] = newTerms.splice(draggedIndex, 1);
newTerms.splice(targetIndex, 0, dragged);
setTerms(newTerms);
setDraggedIndex(null);
updateTermsOrder(newTerms).catch(() => {
setTerms(prevTerms);
});
};

const touchDragIndex = useRef<number | null>(null);
Expand Down Expand Up @@ -191,12 +217,13 @@ export function ExplainedTermsCard() {
const toIndex = dragTargetIndex.current;

if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) {
setTerms(prevTerms => {
const newTerms = [...prevTerms];
const [dragged] = newTerms.splice(fromIndex, 1);
newTerms.splice(toIndex, 0, dragged);
saveTermOrder(newTerms);
return newTerms;
const prevTerms = [...termsRef.current];
const newTerms = [...termsRef.current];
const [dragged] = newTerms.splice(fromIndex, 1);
newTerms.splice(toIndex, 0, dragged);
setTerms(newTerms);
updateTermsOrder(newTerms).catch(() => {
setTerms(prevTerms);
});
}

Expand Down Expand Up @@ -255,7 +282,13 @@ export function ExplainedTermsCard() {
</div>
</div>

{hasTerms ? (
{loadError ? (
<div className="py-6 text-center">
<p className="text-sm text-red-500 dark:text-red-400">
{t('loadError')}
</p>
</div>
) : hasTerms ? (
<>
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
{t('termCount', { count: terms.length })}
Expand Down Expand Up @@ -318,7 +351,7 @@ export function ExplainedTermsCard() {
})}
</div>
</>
) : (
) : !isInitialLoad ? (
<div className="py-6 text-center">
<p className="text-sm font-medium text-gray-600 dark:text-gray-300">
{t('empty')}
Expand All @@ -327,7 +360,7 @@ export function ExplainedTermsCard() {
{t('emptyHint')}
</p>
</div>
)}
) : null}

{/* Explained Terms Section */}
<div className="mt-6">
Expand Down
25 changes: 25 additions & 0 deletions frontend/components/legal/LegalBackButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import { useRouter } from 'next/navigation';

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

const handleBack = () => {
if (window.history.length > 1) {
router.back();
} else {
router.push('/');
}
};

return (
<button
type="button"
onClick={handleBack}
className="inline-flex text-sm text-slate-600 transition-colors hover:text-blue-600 dark:text-slate-300 dark:hover:text-white"
>
← {label}
</button>
);
}
Loading
Loading