StudyBoost Docs

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_ledger table written from a unified recordAndDeductCredits() flow on BaseAiTool. Every AI tool deduction (and every dev-bypass call) writes one row with denormalized source metadata (source_label, tool_category, tokens_used, freeform metadata JSON).
  • 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_preferences row 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 /unauthorized 401 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 and w-full to 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

PathPhasePurpose
/settings1Redirects to /settings/usage
/settings/usage1AI credits tracker + activity feed
/settings/billing1<ComingSoon /> placeholder (see #104)
/settings/account2Avatar + profile form + change password
/settings/notifications2Notification preference toggles
/settings/security22FA / sessions / OAuth placeholders
/unauthorized2401 landing page (?next= preserved)

Auth gating

  • frontend/components/auth/auth-gate.tsx — client component that reads useAuthUser(). While loading, renders a spinner card. Once resolved, if !user, router.replace('/login?next=<encodeURIComponent(pathname)>'). Otherwise renders children.
  • frontend/app/(features)/settings/layout.tsx wraps 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):
    1. Avatar (uses profile_picture_url when set, otherwise initials from first_name / username) + name + email + plan badge.
    2. Mini credit bar (<CreditBar size="sm">) sourced from useSubscription().aiCreditsUsed / aiCreditLimit. When aiCreditLimit === null: renders "Unlimited" + sparkles icon, no bar.
    3. Five <Link> rows: Usage, Billing & Plan, Account, Notifications, Security.
    4. Inline <ThemeToggle /> from frontend/components/ui/theme-toggle.tsx.
    5. Log out — re-uses the existing LogoutConfirmModal.
  • Replaces the three buttons that previously sat at app-sidebar.tsx:276–313 (desktop) and app-sidebar.tsx:403–432 (mobile drawer).
  • Avatar URL resolution: relative /uploads/* paths are prefixed with backendUrl(…); absolute https:// URLs (GCS, OAuth provider) pass through unchanged.

Usage page composition

  • <BalanceCard> — big headline ("X / Y credits used"), reset countdown computed from subscription.currentPeriodEnd, "Manage plan" CTA that routes through useSubscription().openBillingPortal() for paid plans or to /pricing for Free. Reserves a min-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 actual byCategory keys in the API response, hashed against a 10-color palette so the same key always gets the same color. Three render states share the same h-44 area: skeleton (animated pulsing bars at varied heights), empty (CTA link to /ai), data.
  • <ActivityFeed>useInfiniteQuery-style cursor-paginated feed of ai_credit_ledger rows. Category filter chips populated from useUsageHistory(30) so chips track real-world tools instead of a hardcoded list. Loading state renders 6 skeleton rows inside a min-h-[480px] <ul> to reserve final page height.
  • <ActivityRow> — generic, not switch-on-category. The description is built by scanning metadata for 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> for source_kind=document, /ai-courses/<id> for source_kind=course, hidden otherwise.

Account page composition

  • <AvatarCard> — file picker with client-side type/size validation (JPEG/PNG/WebP/GIF ≤ 2 MB), optimistic preview via URL.createObjectURL, POST multipart/form-data to /users/me/avatar. On success calls refreshAuthUser() 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. Refreshes useAuthUser() 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, Planned chip (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 uses w-full py-6 lg:py-8 max-w-7xl mx-auto. The w-full is critical: without it the container collapsed to fit-content width and mx-auto re-centered per page, drifting the sub-nav x-position by ~150 px between tabs (Playwright-verified before / after).

Loading / empty / error states

ComponentLoadingEmptyError
Balance cardSkeleton bar inside fixed min-h-[220px]Free-tier copy + Upgrade CTAn/a — uses useSubscription cached state
Usage chartPulsing skeleton bars at h-44"No AI activity yet" + link to /aiInline text-urgency-red message
Activity feed6 skeleton rows in min-h-[480px]"No AI activity in this cycle yet" / category-specific copyCentered text-urgency-red line
Avatar uploadLoader2 spinner overlaying the avatarFirst time = initials fallbackToast Couldn't upload image · <reason>
Profile formn/an/aToast Couldn't save profile · <reason>
Password formLoader2 in submit buttonn/aToast Couldn't change password · Current password is incorrect
NotificationsSkeleton 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:

Tooltool_categorymetadata it logs
summarydocumentmode
flashcardsquizcardCount, prompt
study-guidequizsectionCount, focusTopic
qachatquery, sourceCount, retrievalMode
scenario-simulatorscenariorole, phase
trivia-gamegamegameType, difficulty, questionCount
crossword-gamegamegameType, size, clueMode, timerMode, wordCount
cloze-gamegamegameType, difficulty, mode, questionCount
typing-gamegamegameType, passageCount, estimatedDurationSeconds
word-search-gamegamegameType, difficulty, mode, gridSize, totalWords, fallbackUsed
word-unscramble-gamegamegameType, 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:

  1. Look up the active Stripe-linked subscription for the user.
  2. ensureUsageTx(tx, userId, planSlug) — find or create the current-cycle subscription_usages row.
  3. If amount > 0: atomic conditional UPDATE 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). If rows_affected = 0, throw BadRequestException('AI credit limit reached for current billing cycle') — the whole transaction rolls back, no ledger row is written.
  4. Insert ai_credit_ledger row with the entry's denormalized fields + usage_id foreign key.

New endpoints

MethodPathService methodNotes
GET/subscriptions/usage/ledger?cursor=&limit=&toolCategory=&toolId=&sourceKind=&sourceId=&from=&to=SubscriptionsService.listLedgerCursor pagination encoded as base64 <occurred_at ISO>|<id>. Default limit=20, max 100.
GET/subscriptions/usage/history?days=NSubscriptionsService.getUsageHistoryDaily buckets stacked by tool_category. days default 30, max 180.
PATCH/users/me/profileUsersService.updateProfileDiff 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-preferencesUsersService.getNotificationPreferencesLazy-create with defaults.
PATCH/users/me/notification-preferencesUsersService.updateNotificationPreferencesupsert so first call from an unprovisioned user still works.
POST/auth/change-passwordAuthService.changePasswordcurrentPassword 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; BadRequestException propagates to the caller.
  • Ledger insert hiccup (DB blip after the counter increment): BaseAiTool.run logs 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_url left unchanged.
  • GCS credentials misconfigured at boot: backend boots; first avatar upload returns 500 with a clear message; logs at startup explain.
  • buildLedgerEntry throws: BaseAiTool.run logs 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 IDDescriptionStepsInputExpected Result
FEAT-103-01Free user opens /settings/usage1. Sign in as Free user. 2. Click sidebar avatar pill → Usage.Free plan accountBalance 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-02Pro user with mid-cycle activity opens /settings/usage1. Sign in. 2. Visit /settings/usage.Pro plan, 842 credits used of 1500Bar shows 842 / 1500; activity feed populated; chart bars stacked by category; reset countdown copy correct.
FEAT-103-03Plus user with ai_credit_limit IS NULL1. Sign in. 2. Open sidebar drop-up + visit /settings/usage.Plus plan, unlimitedDrop-up shows "Unlimited credits" with sparkles icon — no progress bar; balance card shows "Unlimited" headline; no division by zero.
FEAT-103-04Sidebar user pill open / close1. Click pill. 2. Press Esc.Authenticated userPopover 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-05Sidebar collapsed (icon-only mode)1. Collapse sidebar. 2. Click avatar.Authenticated userPill shrinks to avatar only (name/email hidden via max-w-0 opacity-0); popover still opens correctly, anchored to the small pill.
FEAT-103-06Visit /settings while signed out1. Clear cookies. 2. Navigate to /settings/usage.No sessionAuthGate resolves !user and router.replaces to /login?next=%2Fsettings%2Fusage.
FEAT-103-07Run an AI tool, verify ledger row1. Sign in. 2. Generate a summary on a test document. 3. Open /settings/usage.Pro userNew 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-08Run AI course generation1. 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-09Hit cycle limit1. Set Pro user's ai_credit_limit=0 in DB. 2. Run any AI tool.Pro userTool'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-10Dev bypass writes zero-credit row1. Set DEV_BYPASS_SUBSCRIPTION=true. 2. Run an AI tool. 3. Check DB.Local devai_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-11Filter activity feed by category1. Open Usage feed. 2. Click "Games" chip.Pro user with mixed activityOnly tool_category='game' rows render. Subsequent Load more keeps the filter. "All" chip clears it.
FEAT-103-12Activity row with deleted source1. Create a ledger row pointing at a deleted document. 2. View feed.DB-seeded testRow still renders using source_label from the snapshot. "Open →" deep link is hidden. source_kind chip still shows the category.
FEAT-103-13Cursor pagination1. Seed >20 ledger rows. 2. Open feed. 3. Click "Load more".Pro userFirst page returns 20 + nextCursor. Click loads next page; rows appended without duplicates; nextCursor becomes null when exhausted.
FEAT-103-14Chart auto-discovers new category1. Backend introduces a hypothetical new tool_category='translation'. 2. Write a ledger row. 3. Open /settings/usage.Pro userChart 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-15Profile update with username collision1. Open /settings/account. 2. Try changing username to an existing user's username. 3. Save.Two users in DBBackend returns 400 "Username is already taken"; frontend toast surfaces the message; form keeps the typed value so the user can correct it.
FEAT-103-16Change password with wrong current1. /settings/account. 2. Enter wrong current password + valid new. 3. Submit.Existing email/password accountBackend 400 "Current password is incorrect"; toast surfaces it; new password not written.
FEAT-103-17Change password — same as current1. Enter current = new. 2. Submit.Same as above400 "New password must differ from current password".
FEAT-103-18Avatar upload happy path1. /settings/account. 2. Upload a 800×800 PNG (<2MB).Authenticated userOptimistic 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-19Avatar upload rejected — wrong type1. Try uploading a .txt file.Authenticated userMulter fileFilter rejects with BadRequestException('Only JPEG, PNG, WebP, or GIF images allowed'); frontend toast surfaces it; nothing written to GCS.
FEAT-103-20Avatar upload rejected — too large1. Try uploading a 5 MB JPEG.Authenticated userFrontend size guard fires first ("Image is too large"); if bypassed, multer returns 413; previous avatar untouched.
FEAT-103-21Avatar replace deletes previous1. Upload avatar A. 2. Upload avatar B.Authenticated userusers.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-22GCS not configured1. Unset GCP_CREDENTIALS_JSON. 2. Restart backend. 3. Try avatar upload.Misconfigured envBackend logs warning at boot; upload route returns 500 "Avatar storage is not configured on the server."; frontend shows toast.
FEAT-103-23Notification preferences default values1. Brand-new user. 2. Open /settings/notifications.First visitBackend 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-24Toggle preference — happy path1. Toggle "Marketing" on. 2. Reload page.Authenticated userPATCH /users/me/notification-preferences {marketing: true} returns 200. Reload shows the toggle still on.
FEAT-103-25Toggle preference — backend failure1. Mock /users/me/notification-preferences PATCH to 500. 2. Toggle a preference.Mocked failureOptimistic toggle reverts; toast shows "Couldn't save preference. Please try again."
FEAT-103-26Sub-nav x-position stable across tabs1. Visit Usage. 2. Visit Billing. 3. Visit Account. 4. Visit Notifications. 5. Visit Security.Authenticated usernav.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-27Settings page renders header without User Capsule1. Visit any /settings/* page.Authenticated userTop header right-side shows ThemeToggle + NotificationDropdown only; no User Capsule chip. Identity lives in the sidebar drop-up.
FEAT-103-28/unauthorized page renders1. Direct-load /unauthorized?next=%2Fsettings%2Fusage.Any statePage 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 of X / 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). Existing requiresPaymentAction flag is exposed by useSubscription for future work.
  • Period boundarysubscription.currentPeriodEnd is the Stripe-authoritative date; the existing webhook handler (FEAT-39) resets subscription_usages.ai_credits_used on invoice.payment_succeeded. No changes here.
  • Race between credit deduction and cached subscription read<BalanceCard> attaches a window.focus listener that calls useSubscription().refreshSubscription(), so tab-switching back from /ai after 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 when source_id IS NULL OR source_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 collisionupdateProfile runs a findFirst({ username, NOT: { user_id } }) precheck; collisions return BadRequestException('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 saving per 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_url at the moment of upload, so A's object will be cleaned up by B's upload.
  • GCS public-read assumptionpublicUrlFor() returns https://storage.googleapis.com/<bucket>/<path>. If the bucket switches to uniform bucket-level access without allUsers: roles/storage.objectViewer, swap to V4 signed URLs (single-line change in GcsStorageService.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, AuthThrottlerGuard all consumed as-is.
  • FEAT-33 (job queue) — course-generation.processor.ts reuses the queue; this issue only wraps its useAiCredit call.
  • FEAT-39 (Stripe billing) — useSubscription(), getMySubscriptionView, subscription_usages table 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/account is 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.publicUrlFor to 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.ts was removed but the backend/uploads/ directory remains in .gitignore for 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).