Feature: Settings page + AI credits tracker + sidebar user drop-up
Metadata
- Issue ID: FEAT-103
- Status: Done (Phase 1 + Phase 2 landed on
feature/103-settings-ai-credits-tracker, local-only at time of writing) - Owner: danrave1234
- Related PRs: pending
- Related issues: #103, #104 (Billing page — separate), #105 (OAuth + forgot-password — separate)
Builds on FEAT-31 (cookie auth), FEAT-39 (Stripe), FEAT-33 (job queue), FEAT-40 (notifications), and the existing GCS configuration documented in credits-subscription-plan.
Overview
A first-class Settings surface for StudyBoost, anchored by an AI Credits Tracker at /settings/usage. Before this work the sidebar Settings button at app-sidebar.tsx:291 was a dead placeholder, /settings did not exist, and AI credit deductions were aggregate-only (one counter per cycle) with no per-event attribution.
What shipped:
- Sidebar user drop-up — replaces the three loose footer buttons (Upgrade/Manage Plan, Settings, Logout) with a single ChatGPT-style user pill that opens a popover anchored above it. Pill shows avatar, name/email, plan badge, mini credit bar; popover shows full credit bar + section links + theme toggle + log out.
- Settings shell at
/settings/*with shared layout + sticky sub-nav (5 sections). - Usage page (
/settings/usage) — the headline feature. Big balance card with reset countdown, daily-credit-burn stacked bar chart, paginated activity feed with per-event metadata, category filter chips. Chart legend + filter chips are data-driven — new AI tools surface automatically with no frontend changes. - Per-event AI credit ledger — new
ai_credit_ledgertable written from a unifiedrecordAndDeductCredits()flow onBaseAiTool. Every AI tool deduction (and every dev-bypass call) writes one row with denormalized source metadata (source_label,tool_category,tokens_used, freeformmetadataJSON). - Account page — avatar upload (multipart → GCS
v2/avatars/<userId>-<ts>.<ext>), profile form (first/last name, username with uniqueness check, bio, location, university), change-password form with current-password verification + strength meter. - Notifications page — toggle preferences for 5 notification categories + 2 channels (email / in-app). Lazy-created
user_notification_preferencesrow per user. - Security page — placeholder UI for 2FA / Connected accounts / Active sessions (real implementation is tracked under #105 and a future security issue).
- Billing page — placeholder; real implementation is tracked under #104.
<AuthGate>wrapper + new/unauthorized401 page — all five settings tabs redirect signed-out users to/login?next=….- FeatureHeader cleanup — User Capsule chip removed from the top bar; identity now lives in the sidebar drop-up only.
- Layout stability fix — added
[scrollbar-gutter:stable]to the main scroller andw-fullto the settings container so the sub-nav stays anchored at the same x-position across every tab regardless of content height/width.
The credit/subscription policy is in credits-subscription-plan. Auth gating follows FEAT-31.
Frontend Behavior
Routes
| Path | Phase | Purpose |
|---|---|---|
/settings | 1 | Redirects to /settings/usage |
/settings/usage | 1 | AI credits tracker + activity feed |
/settings/billing | 1 | <ComingSoon /> placeholder (see #104) |
/settings/account | 2 | Avatar + profile form + change password |
/settings/notifications | 2 | Notification preference toggles |
/settings/security | 2 | 2FA / sessions / OAuth placeholders |
/unauthorized | 2 | 401 landing page (?next= preserved) |
Auth gating
frontend/components/auth/auth-gate.tsx— client component that readsuseAuthUser(). Whileloading, renders a spinner card. Once resolved, if!user,router.replace('/login?next=<encodeURIComponent(pathname)>'). Otherwise renders children.frontend/app/(features)/settings/layout.tsxwraps its body in<AuthGate>so all five tabs share the same gate.
Sidebar drop-up
frontend/components/sidebar/user-drop-up.tsx— a shadcn<Popover side="top" align="start">whose trigger is a user pill. Trigger pill collapses to avatar-only when the sidebar is in icon mode.- Popover contents (top → bottom):
- Avatar (uses
profile_picture_urlwhen set, otherwise initials fromfirst_name/username) + name + email + plan badge. - Mini credit bar (
<CreditBar size="sm">) sourced fromuseSubscription().aiCreditsUsed/aiCreditLimit. WhenaiCreditLimit === null: renders "Unlimited" + sparkles icon, no bar. - Five
<Link>rows: Usage, Billing & Plan, Account, Notifications, Security. - Inline
<ThemeToggle />fromfrontend/components/ui/theme-toggle.tsx. - Log out — re-uses the existing
LogoutConfirmModal.
- Avatar (uses
- Replaces the three buttons that previously sat at
app-sidebar.tsx:276–313(desktop) andapp-sidebar.tsx:403–432(mobile drawer). - Avatar URL resolution: relative
/uploads/*paths are prefixed withbackendUrl(…); absolutehttps://URLs (GCS, OAuth provider) pass through unchanged.
Usage page composition
<BalanceCard>— big headline ("X / Y credits used"), reset countdown computed fromsubscription.currentPeriodEnd, "Manage plan" CTA that routes throughuseSubscription().openBillingPortal()for paid plans or to/pricingfor Free. Reserves amin-h-[220px]so the loading skeleton matches the rendered card.<UsageHistoryChart>— inline SVG stacked bar chart. 7d / 30d / 90d range toggle. Categories + colors derived from the actualbyCategorykeys in the API response, hashed against a 10-color palette so the same key always gets the same color. Three render states share the sameh-44area: skeleton (animated pulsing bars at varied heights), empty (CTA link to/ai), data.<ActivityFeed>—useInfiniteQuery-style cursor-paginated feed ofai_credit_ledgerrows. Category filter chips populated fromuseUsageHistory(30)so chips track real-world tools instead of a hardcoded list. Loading state renders 6 skeleton rows inside amin-h-[480px]<ul>to reserve final page height.<ActivityRow>— generic, not switch-on-category. The description is built by scanningmetadatafor high-signal keys (gameType,difficulty,mode,stage,questionCount,cardCount, …) and surfacing up to 3 of whichever the tool happened to log. Color accent uses the same hash-to-palette mapping as the chart. Deep link:/documents/<id>forsource_kind=document,/ai-courses/<id>forsource_kind=course, hidden otherwise.
Account page composition
<AvatarCard>— file picker with client-side type/size validation (JPEG/PNG/WebP/GIF ≤ 2 MB), optimistic preview viaURL.createObjectURL, POSTmultipart/form-datato/users/me/avatar. On success callsrefreshAuthUser()so the sidebar drop-up picks up the new URL.<ProfileForm>— controlled fields for first/last name, username, bio (500-char counter), location, university. Tracks dirty state — Save button disabled until something changes. Sends only the diff via PATCH. Email rendered read-only with a "Re-verification required (coming soon)" note. RefreshesuseAuthUser()on save.<PasswordForm>— current/new/confirm with show/hide toggles, live strength meter (Weak/Okay/Strong via heuristic on length + character classes), inline validation for mismatch and too-short. POST/auth/change-password. Empties the form on success.
Notifications page composition
- Two card sections: Categories (product_updates, ai_completed, credit_warnings, marketing, weekly_digest) and Channels (inapp_enabled, email_enabled).
- Each row uses the local
<Switch>(a minimal accessible<button role="switch">mirroring shadcn API; not Radix — the codebase uses@base-ui/react, so we hand-rolled a tiny version). - Optimistic toggle with rollback on failure. PATCH per-key. Loading state renders skeleton rows that match the real row's
min-h-[76px]so the page doesn't grow on data arrival.
Security page composition
UI-only placeholders for issue #105 follow-on work:
- 2FA card — disabled "Set up 2FA" button,
Plannedchip (softer than "Coming soon"), copy explains it's on the roadmap without a firm ETA. - Connected accounts card — Google + Apple rows, both with disabled "Connect" buttons.
- Active sessions card — current device row + disabled "Revoke all other sessions" button.
Header / sidebar layout
frontend/components/layout/features-layout-client.tsx—<main>element uses[scrollbar-gutter:stable]so the scrollbar gutter is always reserved. Prevents horizontal shifting when content height varies between pages (Settings tabs are the most visible symptom).frontend/components/layout/feature-header.tsx— User Capsule chip removed. Header right-side is now just<ThemeToggle />+<NotificationDropdown />.frontend/app/(features)/settings/layout.tsx— outer container usesw-full py-6 lg:py-8 max-w-7xl mx-auto. Thew-fullis critical: without it the container collapsed to fit-content width andmx-autore-centered per page, drifting the sub-nav x-position by ~150 px between tabs (Playwright-verified before / after).
Loading / empty / error states
| Component | Loading | Empty | Error |
|---|---|---|---|
| Balance card | Skeleton bar inside fixed min-h-[220px] | Free-tier copy + Upgrade CTA | n/a — uses useSubscription cached state |
| Usage chart | Pulsing skeleton bars at h-44 | "No AI activity yet" + link to /ai | Inline text-urgency-red message |
| Activity feed | 6 skeleton rows in min-h-[480px] | "No AI activity in this cycle yet" / category-specific copy | Centered text-urgency-red line |
| Avatar upload | Loader2 spinner overlaying the avatar | First time = initials fallback | Toast Couldn't upload image · <reason> |
| Profile form | n/a | n/a | Toast Couldn't save profile · <reason> |
| Password form | Loader2 in submit button | n/a | Toast Couldn't change password · Current password is incorrect |
| Notifications | Skeleton rows matching min-h-[76px] | n/a (rows always render) | Toast Couldn't save preference. Please try again. |
Backend Behavior
Module / file changes
backend/prisma/migrations/
├── 202605170900_add_ai_credit_ledger/migration.sql ← Phase 1
└── 202605171000_add_user_settings_phase2/migration.sql ← Phase 2
backend/src/ai/
├── base-ai-tool.ts ← refactored to require buildLedgerEntry
├── ledger-entry.input.ts ← new
└── tools/**.tool.ts ← 11 tools updated
backend/src/subscriptions/
├── dto/list-ledger.dto.ts ← new
├── dto/usage-history.dto.ts ← new
├── subscriptions.controller.ts ← +2 endpoints
└── subscriptions.service.ts ← +recordAndDeductCredits, +listLedger, +getUsageHistory, +ensureUsageTx
backend/src/storage/
├── gcs-storage.service.ts ← new — uploadAvatar to v2/avatars/
└── storage.module.ts ← new (@Global)
backend/src/users/
├── dto/update-profile.dto.ts ← new
├── dto/update-notification-preferences.dto.ts ← new
├── users.controller.ts ← +4 endpoints (profile, avatar, get prefs, patch prefs)
└── users.service.ts ← +updateProfile, +updateProfilePictureUrl, +get/updateNotificationPreferences
backend/src/auth/
├── dto/change-password.dto.ts ← new
├── auth.controller.ts ← +POST /auth/change-password
├── auth.service.ts ← +changePassword, userProfileSelect now includes profile_picture_url/bio/location/university
└── auth.types.ts ← AuthUserProfile extended
backend/src/job-queue/processors/
└── course-generation.processor.ts ← deductCredit wrapped with recordAndDeductCredits (per stage)
backend/src/app.module.ts ← +StorageModule
Data model — Phase 1 (ai_credit_ledger)
CREATE TABLE ai_credit_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE NO ACTION,
usage_id UUID NULL REFERENCES subscription_usages(id) ON DELETE SET NULL,
tool_id VARCHAR(64) NOT NULL,
tool_category VARCHAR(32) NOT NULL,
credits INT NOT NULL DEFAULT 1,
tokens_used INT NULL,
model_used VARCHAR(128) NULL,
source_kind VARCHAR(32) NULL,
source_id UUID NULL,
source_label VARCHAR(255) NULL,
result_kind VARCHAR(32) NULL,
result_id UUID NULL,
metadata JSONB NULL DEFAULT '{}'::jsonb,
occurred_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_ai_credit_ledger_user_occurred ON ai_credit_ledger (user_id, occurred_at DESC);
CREATE INDEX idx_ai_credit_ledger_user_category ON ai_credit_ledger (user_id, tool_category, occurred_at);
CREATE INDEX idx_ai_credit_ledger_usage ON ai_credit_ledger (usage_id);
source_label is denormalized so deleted-source rows still render. source_id is intentionally polymorphic — no hard FK because it points at different tables (documents, generated_courses, game sessions). The frontend hides the deep link when source_id IS NULL or when source_kind === 'scope'.
subscription_usages.ai_credits_used remains the source of truth for the per-cycle limit check; the ledger is for attribution only.
Data model — Phase 2 (user_notification_preferences)
CREATE TABLE user_notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
product_updates BOOLEAN NOT NULL DEFAULT TRUE,
ai_completed BOOLEAN NOT NULL DEFAULT TRUE,
credit_warnings BOOLEAN NOT NULL DEFAULT TRUE,
marketing BOOLEAN NOT NULL DEFAULT FALSE,
weekly_digest BOOLEAN NOT NULL DEFAULT FALSE,
email_enabled BOOLEAN NOT NULL DEFAULT TRUE,
inapp_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
Defaults bias toward product-relevant categories (opt-in) and away from marketing (opt-out). Row is lazy-created on first GET so the frontend always has something to PATCH.
BaseAiTool refactor
Old flow:
async run(userId, dto) {
const result = await this.execute(userId, dto);
if (!isBypass) await this.subscriptions.useAiCredit(userId).catch(warn);
return result;
}
New flow:
protected abstract buildLedgerEntry(userId, dto, result): LedgerEntryInput;
async run(userId, dto) {
const result = await this.execute(userId, dto);
const isBypass = /* DEV_BYPASS_SUBSCRIPTION matches truthy values */;
const entry = await this.buildLedgerEntry(userId, dto, result);
const amount = isBypass ? 0 : (entry.credits ?? 1);
try {
await this.subscriptions.recordAndDeductCredits(userId, amount, entry);
} catch (err) {
if (err instanceof BadRequestException) throw err; // limit-exceeded propagates
this.logger.warn(...); // any other failure is warn-only
}
return result;
}
The limit-exceeded path now propagates (was silently swallowed before this refactor). The dev-bypass path still writes a credits=0 row so the Usage view is testable without burning real credits.
Every existing tool implements buildLedgerEntry:
| Tool | tool_category | metadata it logs |
|---|---|---|
summary | document | mode |
flashcards | quiz | cardCount, prompt |
study-guide | quiz | sectionCount, focusTopic |
qa | chat | query, sourceCount, retrievalMode |
scenario-simulator | scenario | role, phase |
trivia-game | game | gameType, difficulty, questionCount |
crossword-game | game | gameType, size, clueMode, timerMode, wordCount |
cloze-game | game | gameType, difficulty, mode, questionCount |
typing-game | game | gameType, passageCount, estimatedDurationSeconds |
word-search-game | game | gameType, difficulty, mode, gridSize, totalWords, fallbackUsed |
word-unscramble-game | game | gameType, difficulty, timerPerRound, roundCount |
The AI course-generation processor (backend/src/job-queue/processors/course-generation.processor.ts) is not a BaseAiTool — it has its own pipeline. The existing deductCredit(userId, step, courseId) helper now wraps subscriptions.recordAndDeductCredits per stage with metadata.stage (outline / module_lessons / module_exam / final_exam / mastery_analysis), so a single course generation writes 5+ ledger rows attributed to the same source_id (course UUID).
SubscriptionsService.recordAndDeductCredits
Single prisma.$transaction:
- Look up the active Stripe-linked subscription for the user.
ensureUsageTx(tx, userId, planSlug)— find or create the current-cyclesubscription_usagesrow.- If
amount > 0: atomic conditionalUPDATE subscription_usages SET ai_credits_used = ai_credits_used + $amount WHERE id = $usage AND (ai_credit_limit IS NULL OR ai_credits_used + $amount <= ai_credit_limit). Ifrows_affected = 0, throwBadRequestException('AI credit limit reached for current billing cycle')— the whole transaction rolls back, no ledger row is written. - Insert
ai_credit_ledgerrow with the entry's denormalized fields +usage_idforeign key.
New endpoints
| Method | Path | Service method | Notes |
|---|---|---|---|
GET | /subscriptions/usage/ledger?cursor=&limit=&toolCategory=&toolId=&sourceKind=&sourceId=&from=&to= | SubscriptionsService.listLedger | Cursor pagination encoded as base64 <occurred_at ISO>|<id>. Default limit=20, max 100. |
GET | /subscriptions/usage/history?days=N | SubscriptionsService.getUsageHistory | Daily buckets stacked by tool_category. days default 30, max 180. |
PATCH | /users/me/profile | UsersService.updateProfile | Diff payload; username uniqueness enforced. |
POST | /users/me/avatar (multipart, field file) | UsersService.updateProfilePictureUrl (after GcsStorageService.uploadAvatar) | 2 MB cap, JPEG/PNG/WebP/GIF; previous avatar under v2/ prefix best-effort deleted. |
GET | /users/me/notification-preferences | UsersService.getNotificationPreferences | Lazy-create with defaults. |
PATCH | /users/me/notification-preferences | UsersService.updateNotificationPreferences | upsert so first call from an unprovisioned user still works. |
POST | /auth/change-password | AuthService.changePassword | currentPassword bcrypt-verified; same-as-old rejected; new password ≥ 8 chars; sessions NOT revoked in this iteration (tracked under #105 follow-up). |
All routes (except /users/me/avatar's multipart handling) use the existing CookieAuthGuard. /auth/change-password re-uses the global AuthThrottlerGuard from FEAT-31 to rate-limit.
/auth/me extension
userProfileSelect in auth.service.ts now includes profile_picture_url, bio, location, university. AuthUserProfile in auth.types.ts extended to match. The frontend AuthUser type in hooks/use-auth-user.ts mirrors this. refreshAuthUser() is called after avatar upload + profile save so the sidebar drop-up renders the new picture immediately.
GCS storage
GcsStorageService reads GCP_PROJECT_ID, GCP_BUCKET_NAME, GCP_CREDENTIALS_JSON from env (the credentials JSON accepts either raw JSON or base64-encoded JSON so escaping doesn't bite). Logs a warning at startup if any of the three is missing and refuses uploads with InternalServerErrorException('Avatar storage is not configured on the server.') until they're set — the rest of the app boots fine without it.
Object path: v2/avatars/<userId>-<timestamp>.<ext>. Public URL: https://storage.googleapis.com/<bucket>/<path> (assumes public-read at the bucket level — switch publicUrlFor to V4 signed URLs if you move to uniform bucket-level access without public read). Previous avatars under the v2/ prefix are best-effort deleted to avoid orphaned objects.
Failure modes
- DB transaction rollback on limit-exceeded: ledger row is NOT inserted;
BadRequestExceptionpropagates to the caller. - Ledger insert hiccup (DB blip after the counter increment):
BaseAiTool.runlogs a warn but returns the result; the user already paid for the AI work, and the limit check itself was atomic with the counter. - GCS upload failure:
InternalServerErrorException('Failed to upload avatar')—users.profile_picture_urlleft unchanged. - GCS credentials misconfigured at boot: backend boots; first avatar upload returns
500with a clear message; logs at startup explain. buildLedgerEntrythrows:BaseAiTool.runlogs a warn, skips the ledger write + credit deduction, returns the result. (Trade-off: prefer "user got their result + no charge" over "user got their result + crash response".)
Course-generation processor wrap
course-generation.processor.ts previously called useAiCredit(userId) raw inside deductCredit(userId, step, courseId). New flow:
const course = await this.prisma.generated_courses.findUnique({
where: { id: courseId },
select: { title: true },
}).catch(() => null);
await this.subscriptions.recordAndDeductCredits(userId, 1, {
toolId: 'ai-course-generation',
toolCategory: 'course',
sourceKind: 'course',
sourceId: courseId,
sourceLabel: course?.title ?? undefined,
resultKind: 'generated_course',
resultId: courseId,
metadata: { stage: step },
});
So a 3-module × 3-lesson course generation now writes ~5 ledger rows — outline / module_lessons / module_exam / final_exam / mastery_analysis — all under the same source_id (course id), distinguishable by metadata.stage. The Usage activity row's describe() surfaces Stage: outline etc. directly from this metadata without any code change in the frontend.
QA Test Scenarios
| Scenario ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| FEAT-103-01 | Free user opens /settings/usage | 1. Sign in as Free user. 2. Click sidebar avatar pill → Usage. | Free plan account | Balance card shows 0 / 10 (or current free limit); chart empty state with link to /ai; activity feed empty with "No AI activity yet". |
| FEAT-103-02 | Pro user with mid-cycle activity opens /settings/usage | 1. Sign in. 2. Visit /settings/usage. | Pro plan, 842 credits used of 1500 | Bar shows 842 / 1500; activity feed populated; chart bars stacked by category; reset countdown copy correct. |
| FEAT-103-03 | Plus user with ai_credit_limit IS NULL | 1. Sign in. 2. Open sidebar drop-up + visit /settings/usage. | Plus plan, unlimited | Drop-up shows "Unlimited credits" with sparkles icon — no progress bar; balance card shows "Unlimited" headline; no division by zero. |
| FEAT-103-04 | Sidebar user pill open / close | 1. Click pill. 2. Press Esc. | Authenticated user | Popover opens above the pill on first click; closes on Esc and on outside click. Theme toggle inside doesn't close the menu when clicked. |
| FEAT-103-05 | Sidebar collapsed (icon-only mode) | 1. Collapse sidebar. 2. Click avatar. | Authenticated user | Pill shrinks to avatar only (name/email hidden via max-w-0 opacity-0); popover still opens correctly, anchored to the small pill. |
| FEAT-103-06 | Visit /settings while signed out | 1. Clear cookies. 2. Navigate to /settings/usage. | No session | AuthGate resolves !user and router.replaces to /login?next=%2Fsettings%2Fusage. |
| FEAT-103-07 | Run an AI tool, verify ledger row | 1. Sign in. 2. Generate a summary on a test document. 3. Open /settings/usage. | Pro user | New row appears in feed within ~5s with tool name = "Summary", denormalized document title, "−N credits". DB shows new ai_credit_ledger row with source_id = document id, tool_category = 'document', metadata.mode = 'detailed'. |
| FEAT-103-08 | Run AI course generation | 1. Generate a 3-module course. 2. Visit /settings/usage. | Pro user | ~5 ledger rows written under source_kind='course', all same source_id, differentiated by metadata.stage. Activity feed lists them chronologically. |
| FEAT-103-09 | Hit cycle limit | 1. Set Pro user's ai_credit_limit=0 in DB. 2. Run any AI tool. | Pro user | Tool's run() throws BadRequestException('AI credit limit reached…'). Transaction rolls back: no ledger row written, ai_credits_used unchanged. Frontend toast displays the message. |
| FEAT-103-10 | Dev bypass writes zero-credit row | 1. Set DEV_BYPASS_SUBSCRIPTION=true. 2. Run an AI tool. 3. Check DB. | Local dev | ai_credits_used unchanged. New ai_credit_ledger row with credits = 0 and full metadata. Frontend Usage feed renders the row with "0 credits" label. |
| FEAT-103-11 | Filter activity feed by category | 1. Open Usage feed. 2. Click "Games" chip. | Pro user with mixed activity | Only tool_category='game' rows render. Subsequent Load more keeps the filter. "All" chip clears it. |
| FEAT-103-12 | Activity row with deleted source | 1. Create a ledger row pointing at a deleted document. 2. View feed. | DB-seeded test | Row still renders using source_label from the snapshot. "Open →" deep link is hidden. source_kind chip still shows the category. |
| FEAT-103-13 | Cursor pagination | 1. Seed >20 ledger rows. 2. Open feed. 3. Click "Load more". | Pro user | First page returns 20 + nextCursor. Click loads next page; rows appended without duplicates; nextCursor becomes null when exhausted. |
| FEAT-103-14 | Chart auto-discovers new category | 1. Backend introduces a hypothetical new tool_category='translation'. 2. Write a ledger row. 3. Open /settings/usage. | Pro user | Chart legend includes a new "Translation" entry with a stable hash-derived color; stacked bar includes the new segment; filter chips include a "Translation" option. No frontend code change required. |
| FEAT-103-15 | Profile update with username collision | 1. Open /settings/account. 2. Try changing username to an existing user's username. 3. Save. | Two users in DB | Backend returns 400 "Username is already taken"; frontend toast surfaces the message; form keeps the typed value so the user can correct it. |
| FEAT-103-16 | Change password with wrong current | 1. /settings/account. 2. Enter wrong current password + valid new. 3. Submit. | Existing email/password account | Backend 400 "Current password is incorrect"; toast surfaces it; new password not written. |
| FEAT-103-17 | Change password — same as current | 1. Enter current = new. 2. Submit. | Same as above | 400 "New password must differ from current password". |
| FEAT-103-18 | Avatar upload happy path | 1. /settings/account. 2. Upload a 800×800 PNG (<2MB). | Authenticated user | Optimistic preview shown; POST /users/me/avatar returns 201 with profile_picture_url like https://storage.googleapis.com/studyboost.com/v2/avatars/<userId>-<ts>.png; users.profile_picture_url updated; sidebar drop-up re-renders with the new image (via refreshAuthUser). |
| FEAT-103-19 | Avatar upload rejected — wrong type | 1. Try uploading a .txt file. | Authenticated user | Multer fileFilter rejects with BadRequestException('Only JPEG, PNG, WebP, or GIF images allowed'); frontend toast surfaces it; nothing written to GCS. |
| FEAT-103-20 | Avatar upload rejected — too large | 1. Try uploading a 5 MB JPEG. | Authenticated user | Frontend size guard fires first ("Image is too large"); if bypassed, multer returns 413; previous avatar untouched. |
| FEAT-103-21 | Avatar replace deletes previous | 1. Upload avatar A. 2. Upload avatar B. | Authenticated user | users.profile_picture_url points at B's GCS object; A's object is best-effort deleted from GCS (verify with gsutil ls or console). Failure to delete A logs a warning but does NOT block the upload. |
| FEAT-103-22 | GCS not configured | 1. Unset GCP_CREDENTIALS_JSON. 2. Restart backend. 3. Try avatar upload. | Misconfigured env | Backend logs warning at boot; upload route returns 500 "Avatar storage is not configured on the server."; frontend shows toast. |
| FEAT-103-23 | Notification preferences default values | 1. Brand-new user. 2. Open /settings/notifications. | First visit | Backend lazy-creates a row with defaults (product_updates/ai_completed/credit_warnings/email_enabled/inapp_enabled = true; marketing/weekly_digest = false). UI reflects the defaults. |
| FEAT-103-24 | Toggle preference — happy path | 1. Toggle "Marketing" on. 2. Reload page. | Authenticated user | PATCH /users/me/notification-preferences {marketing: true} returns 200. Reload shows the toggle still on. |
| FEAT-103-25 | Toggle preference — backend failure | 1. Mock /users/me/notification-preferences PATCH to 500. 2. Toggle a preference. | Mocked failure | Optimistic toggle reverts; toast shows "Couldn't save preference. Please try again." |
| FEAT-103-26 | Sub-nav x-position stable across tabs | 1. Visit Usage. 2. Visit Billing. 3. Visit Account. 4. Visit Notifications. 5. Visit Security. | Authenticated user | nav.lg\:flex x-coord stays at the same px on every tab (Playwright-measured; was drifting by ~150 px before the w-full fix). |
| FEAT-103-27 | Settings page renders header without User Capsule | 1. Visit any /settings/* page. | Authenticated user | Top header right-side shows ThemeToggle + NotificationDropdown only; no User Capsule chip. Identity lives in the sidebar drop-up. |
| FEAT-103-28 | /unauthorized page renders | 1. Direct-load /unauthorized?next=%2Fsettings%2Fusage. | Any state | Page renders with "401 Unauthorized" badge, Sign In link points to /login?next=%2Fsettings%2Fusage, no crash regardless of auth state. |
Edge Cases
aiCreditLimit = null(Plus / enterprise) — never divides by zero.<CreditBar>renders "Unlimited" text + sparkles icon, no progress bar. Balance card headline shows "Unlimited" instead ofX / Y used.- Subscription in
past_due— Usage page stays accessible (read-only). No banner is added by this issue (that's tracked under the billing issue #104). ExistingrequiresPaymentActionflag is exposed byuseSubscriptionfor future work. - Period boundary —
subscription.currentPeriodEndis the Stripe-authoritative date; the existing webhook handler (FEAT-39) resetssubscription_usages.ai_credits_usedoninvoice.payment_succeeded. No changes here. - Race between credit deduction and cached subscription read —
<BalanceCard>attaches awindow.focuslistener that callsuseSubscription().refreshSubscription(), so tab-switching back from/aiafter burning a credit immediately re-renders the new total. - Multi-stage course generation — each stage writes its own ledger row. The Usage activity feed lists them chronologically; a future polish could group them by
source_id(course id) with a "View N stages" disclosure, but that's a follow-up. - Refunds / credit grants — admin-side adjustments should be writable as negative ledger rows with
tool_id='admin-adjustment',tool_category='other',metadata.reason. The schema allows this; no admin UI in this issue. - Source resource deleted after ledger entry written — feed renders the row using the snapshotted
source_label; the deep-link button is hidden whensource_id IS NULLORsource_kind='scope'. No 404 for the user. - Backfill — pre-existing credit usage from before this migration has no ledger rows. The Usage feed shows only post-migration activity; the existing aggregate counter is the source of truth for the headline number. No historical backfill is performed.
- Username collision —
updateProfileruns afindFirst({ username, NOT: { user_id } })precheck; collisions returnBadRequestException('Username is already taken'). - OAuth-only user changing password — change-password route currently rejects with
BadRequestException('No password set for this account…'). Set-initial-password support for OAuth users is tracked under #105. - Notification preference race (user toggles twice fast) — frontend tracks
savingper key and uses optimistic state with rollback. The two PATCHes both run; the last one wins. - Avatar upload race (user picks file B before A finishes) — second upload overrides the first. The previous-URL cleanup runs against
existing.profile_picture_urlat the moment of upload, so A's object will be cleaned up by B's upload. - GCS public-read assumption —
publicUrlFor()returnshttps://storage.googleapis.com/<bucket>/<path>. If the bucket switches to uniform bucket-level access withoutallUsers: roles/storage.objectViewer, swap to V4 signed URLs (single-line change inGcsStorageService.publicUrlFor). - Scrollbar gutter on Windows + Firefox —
[scrollbar-gutter:stable]is supported in Chrome 94+, Firefox 97+, Safari 16+. Falls back to standard scrollbar behavior on older browsers; the sub-nav x-shift would return for users on those, but the in-app browser audit shows everyone is on modern versions.
Notes
Feature flags
No new feature flags for this issue.
Dependencies
- FEAT-31 (secure-cookie auth + sessions) —
useAuthUser,CookieAuthGuard,AuthThrottlerGuardall consumed as-is. - FEAT-33 (job queue) —
course-generation.processor.tsreuses the queue; this issue only wraps itsuseAiCreditcall. - FEAT-39 (Stripe billing) —
useSubscription(),getMySubscriptionView,subscription_usagestable all reused. The webhook handlers that reset the cycle counter are untouched. - FEAT-40 (notifications) — preference flags are persisted but not yet wired into
NotificationsService.enqueueForUser()for gating; that's per-callsite work that happens as each notification type ships. credits-subscription-plan— tier limits / token-to-credit ratio.
Known limitations
- Account deletion is not implemented. The "Delete account" zone is not yet built — tracked as a follow-up.
- Email change with re-verification is not implemented. Email field on
/settings/accountis read-only. - Notification preferences are not yet enforced at the dispatcher level. Toggling "Marketing" off saves the preference but doesn't yet stop marketing emails from being sent — that gating gets added as each notification site is touched. The schema and endpoints are ready.
- Activity feed CSV export mentioned in the original spec is deferred.
- 2FA, OAuth account linking, active sessions inventory are placeholders only — tracked under #105 and follow-on security work.
- GCS public-read is assumed. If the bucket later uses uniform bucket-level access without public read, swap
GcsStorageService.publicUrlForto V4 signed URLs (one-line change). - Avatar storage costs — local-disk was the original Phase 1 implementation; Phase 2 swapped to GCS. The local-disk static-asset mount in
main.tswas removed but thebackend/uploads/directory remains in.gitignorefor safety.
Migration order
202605170900_add_ai_credit_ledger must run before any AI tool deduction is exercised (Phase 1). 202605171000_add_user_settings_phase2 must run before /settings/notifications is opened (Phase 2). Both are idempotent (use CREATE TABLE IF NOT EXISTS).