This commit is contained in:
2026-05-02 00:46:09 +07:00
parent dcbce863de
commit 54324e45d4
2 changed files with 157 additions and 1 deletions

View File

@@ -346,11 +346,22 @@ export function TestSession() {
[answers],
)
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
const handleSubmit = useCallback(() => {
submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed)
navigate({ to: '/toeic/result' })
}, [submitExam, navigate, totalSeconds, timeLeft, timeUsed])
// Manual click → confirm if any unanswered; auto (timer expire) skips confirm.
const requestSubmit = useCallback(() => {
if (totalQuestions > 0 && answeredCount < totalQuestions) {
setShowSubmitConfirm(true)
} else {
handleSubmit()
}
}, [totalQuestions, answeredCount, handleSubmit])
useEffect(() => {
if (parts.length === 0) return
const id = setInterval(() => {
@@ -398,7 +409,7 @@ export function TestSession() {
timeUsed={timeUsed}
totalQuestions={totalQuestions}
answeredCount={answeredCount}
onSubmit={handleSubmit}
onSubmit={requestSubmit}
/>
<div className="flex flex-1 overflow-hidden">
@@ -497,6 +508,49 @@ export function TestSession() {
onPrev={() => setCurrentPart(currentPartIndex - 1)}
onNext={() => setCurrentPart(currentPartIndex + 1)}
/>
{showSubmitConfirm && (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: 'rgba(15, 17, 20, 0.5)' }}
onClick={() => setShowSubmitConfirm(false)}
>
<div
className="w-full max-w-md rounded-2xl p-6 shadow-2xl"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
onClick={(e) => e.stopPropagation()}
>
<div
className="at-serif"
style={{ fontSize: 22, fontWeight: 500, letterSpacing: '-0.02em', color: 'var(--at-ink)', marginBottom: 8 }}
>
Nộp bài <i style={{ fontStyle: 'italic', color: 'var(--at-brand)' }}>ngay?</i>
</div>
<p style={{ fontSize: 14, color: 'var(--at-mute)', lineHeight: 1.55, marginBottom: 20 }}>
Bạn còn{' '}
<b style={{ color: 'var(--at-bad)' }}>{totalQuestions - answeredCount}</b>/{totalQuestions} câu
chưa trả lời. Các câu chưa trả lời sẽ tính {' '}
<b>bỏ qua</b> không điểm.
</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setShowSubmitConfirm(false)}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors hover:bg-[var(--at-line-2)]"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
Tiếp tục làm
</button>
<button
onClick={() => { setShowSubmitConfirm(false); handleSubmit() }}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white transition-[filter] hover:brightness-110"
style={{ background: '#e53935' }}
>
Nộp bài
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,102 @@
-- Migration 007: Fix gamification trigger that blocks auth signup
--
-- Problem: trigger `on_auth_user_created_gamification` fires after every
-- auth.users INSERT and writes to user_gamification + xu_transactions. If those
-- tables are missing or schema-drifted, the trigger raises and the entire
-- signup transaction rolls back → POST /auth/v1/signup returns 500.
--
-- Fix:
-- 1. Re-create gamification tables idempotently so the trigger has a target.
-- 2. Wrap inserts in BEGIN/EXCEPTION block so any future schema break logs a
-- warning instead of breaking auth signup.
-- 3. Replace the trigger function in place (DROP not needed because of CREATE
-- OR REPLACE).
--
-- Run in Supabase Dashboard → SQL Editor.
-- ============================================================
-- 1. Ensure tables exist (idempotent — same shape as 002_gamification.sql)
-- ============================================================
CREATE TABLE IF NOT EXISTS user_gamification (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
xp INT NOT NULL DEFAULT 0,
level TEXT NOT NULL DEFAULT 'beginner'
CHECK (level IN ('beginner', 'bronze', 'silver', 'gold', 'master')),
streak INT NOT NULL DEFAULT 0,
longest_streak INT NOT NULL DEFAULT 0,
last_active DATE,
xu INT NOT NULL DEFAULT 50,
freeze_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS xu_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type TEXT NOT NULL
CHECK (type IN (
'earn_welcome', 'earn_daily', 'earn_streak', 'earn_ads',
'spend_freeze', 'spend_writing', 'spend_test'
)),
amount INT NOT NULL,
balance INT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_xu_transactions_user_date
ON xu_transactions(user_id, created_at DESC);
-- Re-apply RLS (idempotent — OR REPLACE not supported on policies, so drop-then-create)
ALTER TABLE user_gamification ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can read own gamification" ON user_gamification;
DROP POLICY IF EXISTS "Users can insert own gamification" ON user_gamification;
DROP POLICY IF EXISTS "Users can update own gamification" ON user_gamification;
CREATE POLICY "Users can read own gamification"
ON user_gamification FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own gamification"
ON user_gamification FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own gamification"
ON user_gamification FOR UPDATE USING (auth.uid() = user_id);
ALTER TABLE xu_transactions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can read own xu transactions" ON xu_transactions;
DROP POLICY IF EXISTS "Users can insert own xu transactions" ON xu_transactions;
CREATE POLICY "Users can read own xu transactions"
ON xu_transactions FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own xu transactions"
ON xu_transactions FOR INSERT WITH CHECK (auth.uid() = user_id);
-- ============================================================
-- 2. Replace trigger function with one that NEVER blocks signup
-- ============================================================
-- Why the EXCEPTION wrapper:
-- A trigger error inside auth.users INSERT rolls back the whole transaction,
-- meaning a broken gamification side-effect kills the user's ability to sign
-- up. We swallow gamification failures and log a warning so signup always
-- succeeds; missing rows can be backfilled later.
CREATE OR REPLACE FUNCTION handle_new_user_gamification()
RETURNS TRIGGER AS $$
BEGIN
BEGIN
INSERT INTO user_gamification (user_id, xu)
VALUES (NEW.id, 50)
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO xu_transactions (user_id, type, amount, balance, description)
VALUES (NEW.id, 'earn_welcome', 50, 50, 'Welcome bonus');
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'gamification side-effect failed for user %: %', NEW.id, SQLERRM;
END;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================
-- 3. Re-attach the trigger (idempotent)
-- ============================================================
DROP TRIGGER IF EXISTS on_auth_user_created_gamification ON auth.users;
CREATE TRIGGER on_auth_user_created_gamification
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION handle_new_user_gamification();