feat(video): add processing progress tracking and downloadable toggle… - #176
Conversation
… to Video model - Added `processingStep`, `processingProgress`, and `downloadable` fields to the Video model in Prisma schema. - Implemented database migration to add new columns to the Video table. - Enhanced video processing service to update processing progress during transcoding. - Created a new VideoProcessingProgress component to display processing status and allow toggling of downloadability. - Updated video upload and metadata routes to handle new fields and ensure proper permissions. - Adjusted user plan checks to include new subscription statuses and limits. - Improved error handling and graceful degradation in various services and controllers.
There was a problem hiding this comment.
Sorry @Apexone11, you have reached your weekly rate limit of 1500000 diff characters.
Please try again later or upgrade to continue using Sourcery
There was a problem hiding this comment.
Pull request overview
This PR extends the video pipeline and subscription-aware UX by adding server-side processing progress fields to Video, exposing them via the API, and updating the frontend to show progress + a downloadability toggle. It also updates subscription/plan handling (incl. past_due), adjusts free-tier limits (AI/video), and includes some related feed/user improvements.
Changes:
- Add
processingStep,processingProgress, anddownloadabletoVideo(schema + migration) and update the video processor to persist progress updates. - Update frontend video surfaces (uploader + feed) to show processing progress and a “Download” affordance gated by
downloadable. - Expand “active” subscription status handling (e.g., include
past_due) and adjust plan limits/feature text across pricing/settings/AI.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/studyhub-app/src/pages/settings/SubscriptionTab.jsx | Derives an “effective plan” using API + session; updates plan display and free video duration text. |
| frontend/studyhub-app/src/pages/pricing/PricingPage.jsx | Uses an effective subscription fallback and treats past_due/trialing as active for Pro gating; updates video upload feature text. |
| frontend/studyhub-app/src/pages/feed/FeedCard.jsx | Adds ProBadge, adds download link + controlsList gating based on video.downloadable. |
| frontend/studyhub-app/src/pages/dashboard/DashboardPage.jsx | Styling/formatting cleanup; moves colors to CSS vars. |
| frontend/studyhub-app/src/components/video/VideoUploader.jsx | Replaces spinner with progress polling component + adds PATCH toggle for downloadable. |
| backend/src/modules/video/video.service.js | Persists processing progress steps during download/probe/transcode/finalize; tweaks ffmpeg preset speed. |
| backend/src/modules/video/video.routes.js | Refactors upload chunk handler formatting; adds PATCH /api/video/:id; includes new fields in formatVideoResponse. |
| backend/src/modules/users/users.controller.js | Formatting cleanup; fixes follow suggestions query to use userFollow. |
| backend/src/modules/studyGroups/studyGroups.controller.js | Adds private-group limit enforcement when changing privacy from public → private/invite_only. |
| backend/src/modules/sheets/sheets.fork.controller.js | Counts forks toward monthly upload quota for free users. |
| backend/src/modules/payments/payments.service.js | Treats incomplete subscriptions as free until confirmed; refines inactive handling. |
| backend/src/modules/payments/payments.routes.js | Adds /subscription/debug endpoint returning raw subscription info. |
| backend/src/modules/payments/payments.constants.js | Lowers free-tier AI daily messages constant. |
| backend/src/modules/feed/feed.discovery.controller.js | Switches fork counts to forkChildren and wraps blocked-id fetch in try/catch. |
| backend/src/modules/ai/ai.service.js | Adds donor daily-limit path (DB lookup); formatting changes. |
| backend/src/modules/ai/ai.constants.js | Adds donor daily limit constant. |
| backend/src/lib/userBadges.js | Treats past_due as active for plan enrichment; formatting cleanup. |
| backend/src/lib/getUserPlan.js | Centralizes active statuses (active, trialing, past_due). |
| backend/prisma/schema.prisma | Adds new Video fields for progress + downloadability. |
| backend/prisma/migrations/20260404000001_add_video_processing_progress/migration.sql | Migration to add the new Video columns. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // ── GET /subscription/debug — Raw DB record for debugging ──────────────── | ||
| router.get('/subscription/debug', paymentReadLimiter, requireAuth, async (req, res) => { | ||
| try { | ||
| const raw = await prisma.subscription.findUnique({ where: { userId: req.user.userId } }) | ||
| const planFromEnv = { | ||
| STRIPE_PRICE_ID_PRO: process.env.STRIPE_PRICE_ID_PRO ? 'set' : 'MISSING', | ||
| STRIPE_PRICE_ID_PRO_YEARLY: process.env.STRIPE_PRICE_ID_PRO_YEARLY ? 'set' : 'MISSING', | ||
| } | ||
| res.json({ raw: raw || null, envStatus: planFromEnv, userId: req.user.userId }) | ||
| } catch (error) { | ||
| res.status(500).json({ error: error.message }) |
There was a problem hiding this comment.
/subscription/debug returns the full Subscription row (including Stripe customer/subscription/price IDs) to any authenticated user. This leaks sensitive billing identifiers and should be removed or gated (e.g., only in non-production and/or admin-only) and return a redacted shape if it must exist.
| // ── GET /subscription/debug — Raw DB record for debugging ──────────────── | |
| router.get('/subscription/debug', paymentReadLimiter, requireAuth, async (req, res) => { | |
| try { | |
| const raw = await prisma.subscription.findUnique({ where: { userId: req.user.userId } }) | |
| const planFromEnv = { | |
| STRIPE_PRICE_ID_PRO: process.env.STRIPE_PRICE_ID_PRO ? 'set' : 'MISSING', | |
| STRIPE_PRICE_ID_PRO_YEARLY: process.env.STRIPE_PRICE_ID_PRO_YEARLY ? 'set' : 'MISSING', | |
| } | |
| res.json({ raw: raw || null, envStatus: planFromEnv, userId: req.user.userId }) | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }) | |
| // ── GET /subscription/debug — Redacted debug info (non-production only) ── | |
| router.get('/subscription/debug', paymentReadLimiter, requireAuth, async (req, res) => { | |
| try { | |
| if (process.env.NODE_ENV === 'production') { | |
| return sendError(res, 404, 'Not found.', ERROR_CODES.NOT_FOUND) | |
| } | |
| const raw = await prisma.subscription.findUnique({ where: { userId: req.user.userId } }) | |
| const planFromEnv = { | |
| STRIPE_PRICE_ID_PRO: process.env.STRIPE_PRICE_ID_PRO ? 'set' : 'MISSING', | |
| STRIPE_PRICE_ID_PRO_YEARLY: process.env.STRIPE_PRICE_ID_PRO_YEARLY ? 'set' : 'MISSING', | |
| } | |
| const redacted = raw | |
| ? { | |
| id: raw.id, | |
| exists: true, | |
| status: raw.status ?? null, | |
| plan: raw.plan ?? null, | |
| interval: raw.interval ?? null, | |
| cancelAtPeriodEnd: raw.cancelAtPeriodEnd ?? null, | |
| currentPeriodEnd: raw.currentPeriodEnd ?? null, | |
| createdAt: raw.createdAt ?? null, | |
| updatedAt: raw.updatedAt ?? null, | |
| } | |
| : null | |
| res.json({ subscription: redacted, envStatus: planFromEnv }) | |
| } catch (error) { | |
| captureError(error, { context: 'payments.subscriptionDebug' }) | |
| log.error({ err: error }, 'Failed to get subscription debug info') | |
| sendError(res, 500, 'Failed to retrieve subscription debug info.', ERROR_CODES.INTERNAL) |
| // Check active subscription | ||
| try { | ||
| const sub = await prisma.subscription.findUnique({ | ||
| where: { userId: req.user.userId }, | ||
| select: { plan: true, status: true }, | ||
| }) | ||
| if (sub && sub.status === 'active') { | ||
| userPlan = sub.plan | ||
| } | ||
| } catch (e) { | ||
| } catch { | ||
| // Graceful degradation: treat as free on error | ||
| } |
There was a problem hiding this comment.
Plan detection here still treats only status === 'active' as Pro; elsewhere in this PR you treat trialing/past_due as active. This mismatch can incorrectly apply free-tier upload limits to trialing/past_due users. Consider using the shared getUserPlan() helper or checking all active statuses consistently.
| // Determine user plan (mirror of logic in routes) | ||
| try { | ||
| const sub = await prisma.subscription.findUnique({ | ||
| where: { userId: video.userId }, | ||
| select: { plan: true, status: true }, | ||
| }) | ||
| if (sub && sub.status === 'active') { | ||
| userPlan = sub.plan | ||
| } | ||
| } catch (e) { | ||
| } catch { |
There was a problem hiding this comment.
Same as upload/init: duration validation only treats status === 'active' as Pro, but other parts of the codebase now consider trialing/past_due active. This can incorrectly fail/limit processing for trialing/past_due users; prefer the shared getUserPlan() helper or ACTIVE_STATUSES.includes(sub.status) here too.
| if (req.body.title !== undefined) updates.title = String(req.body.title).slice(0, 200) | ||
| if (req.body.description !== undefined) | ||
| updates.description = String(req.body.description).slice(0, 2000) | ||
| if (req.body.downloadable !== undefined) updates.downloadable = Boolean(req.body.downloadable) |
There was a problem hiding this comment.
updates.downloadable = Boolean(req.body.downloadable) will coerce non-boolean values unexpectedly (e.g. the string "false" becomes true). Validate that req.body.downloadable is a boolean (or explicitly parse accepted string values) and reject invalid inputs with 400.
| if (req.body.downloadable !== undefined) updates.downloadable = Boolean(req.body.downloadable) | |
| if (req.body.downloadable !== undefined) { | |
| if (typeof req.body.downloadable === 'boolean') { | |
| updates.downloadable = req.body.downloadable | |
| } else if (req.body.downloadable === 'true') { | |
| updates.downloadable = true | |
| } else if (req.body.downloadable === 'false') { | |
| updates.downloadable = false | |
| } else { | |
| return res.status(400).json({ error: 'Invalid downloadable value.' }) | |
| } | |
| } |
| <video | ||
| src={streamUrl} | ||
| poster={thumbnailUrl || undefined} | ||
| controls | ||
| playsInline | ||
| preload="metadata" | ||
| controlsList={video.downloadable === false ? 'nodownload' : undefined} | ||
| style={{ width: '100%', display: 'block', maxHeight: 500 }} |
There was a problem hiding this comment.
This UI relies on video.downloadable coming from the feed API, but the backend feed formatter currently only selects/returns thumbnailR2Key, variants, hlsManifestR2Key, etc. Without adding downloadable to the feed video payload, controlsList and the download gating will never activate.
| {video.downloadable !== false && streamUrl && ( | ||
| <a | ||
| href={streamUrl} |
There was a problem hiding this comment.
The "Download" link uses the same signed /api/video/:id/stream URL used for playback. If downloadable is meant to be enforced, this should ideally call a dedicated download endpoint that checks video.downloadable (and possibly sets appropriate headers), while /stream remains for playback.
| {video.downloadable !== false && streamUrl && ( | |
| <a | |
| href={streamUrl} | |
| {video.downloadable !== false && video.id && ( | |
| <a | |
| href={`${API}/api/video/${video.id}/download`} |
| return | ||
| } | ||
| if (data.processingStep) setStep(data.processingStep) | ||
| if (data.processingProgress) setPct(data.processingProgress) |
There was a problem hiding this comment.
Progress polling ignores legitimate 0 values because it only updates when data.processingProgress is truthy. Use an explicit null/undefined check (e.g. data.processingProgress != null) so the UI can reflect 0% and other falsy values correctly.
| if (data.processingProgress) setPct(data.processingProgress) | |
| if (data.processingProgress != null) setPct(data.processingProgress) |
| .map((sheet) => { | ||
| const ageHours = (now - new Date(sheet.createdAt).getTime()) / (1000 * 60 * 60) | ||
| const recencyBoost = Math.max(0, 1 - ageHours / DISCOVERY_RECENCY_DECAY_HOURS) | ||
| const score = (sheet.stars || 0) * 3 + (sheet._count.forks || 0) * 5 + recencyBoost * 10 | ||
| const score = | ||
| (sheet.stars || 0) * 3 + (sheet._count.forkChildren || 0) * 5 + recencyBoost * 10 | ||
| return { ...sheet, _score: score } |
There was a problem hiding this comment.
This controller switches scoring to use _count.forkChildren, but later in the same pipeline the response mapping still reads _count.forks for forkCount (so it will always be 0). Update the mapper to use _count.forkChildren consistently with the query selection.
| const score = | ||
| (sheet.stars || 0) * 3 + | ||
| (sheet._count.comments || 0) * 2 + | ||
| (sheet._count.forks || 0) * 5 + | ||
| (sheet._count.forkChildren || 0) * 5 + | ||
| recencyBoost * 10 | ||
| return { ...sheet, _score: score } |
There was a problem hiding this comment.
Same consistency issue for the trending score path: _count.forkChildren is used in scoring, but downstream mapping still uses _count.forks when producing forkCount. Align the response mapping with the selected _count field.
Clears 3 advisories (2 high) that landed after round 3. **Lockfile-only** — no manifest changes. | Alert | Package | Advisory floor | Now at | |---|---|---|---| | #179, #182 (high) | postcss | 8.5.18 | **8.5.25** (all 3 lockfiles) | | #180 (medium) | tar | 7.5.21 | **7.5.22** (root + frontend) | ## Not included: react-router (deliberate) The 2 remaining high alerts (#176, #177) are `react-router`, and the fix is **8.3.0 — a major bump from 7.18.1**. CLAUDE.md lists React Router among the majors that require an explicit founder approval, so it is held out of this PR. It touches every route in the app and deserves its own PR with a full route smoke pass. Dependabot has it open as #450/#451. Also open and awaiting the same call: #446 recharts 2→3, #447 @vitejs/plugin-react 5→6, #448 @testing-library/jest-dom 6→7, #449 rollup-plugin-visualizer 6→7 — all majors. ## Validation - Backend: lint ✅ · build ✅ · tests ✅ **3541 passed** - Frontend: lint ✅ 0 errors · build ✅ · **906 passed** - Release-log entry added (CI gate) - `playwright-smoke` remains the known-red baseline (red on main since 2026-06-02) 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by Sourcery Update dependency lockfiles to address recent security advisories for postcss and tar, and document the changes in the release log. Enhancements: - Record security round 4 dependency updates and remaining react-router advisories in the v2.3.0 release log entry. Chores: - Upgrade postcss to 8.5.25 and tar to 7.5.22 across all lockfiles to clear three new security advisories.
…, jest-dom 7, visualizer 7 (#457) Clears the **last 2 open high-severity alerts** (#176, #177 — react-router). Supersedes Dependabot #446–#451. ## react-router 7.18.1 → 8.3.0 (the breaking one) **v8 removes the `react-router-dom` package entirely.** In v7 everything DOM-specific had already collapsed into `react-router`; `react-router-dom` was kept only as a v6-compat convenience, and v8 drops it. - Swapped all **225 importing files** (src + tests, including `vi.mock`/`vi.importActual` targets) to `react-router`. - Every symbol this app uses is exported from `react-router` in v8: `BrowserRouter`, `MemoryRouter`, `Routes`, `Route`, `Link`, `NavLink`, `Navigate`, `useNavigate`, `useLocation`, `useParams`, `useSearchParams`, `useBlocker`. - The app never used `RouterProvider`/`HydratedRouter` (those move to `react-router/dom`), so there are no other import-path changes. **v8 raises its Node floor to 22.22.0**, so: - frontend `engines.node` → `>=22.22.0` - every workflow's `setup-node` → **22** (was 20) - added a root **`.node-version`** so Cloudflare Pages builds on 22 rather than defaulting to an older runtime and tripping the engine check — this was the one silent deploy risk in the upgrade. **Breaking changes that do NOT apply here** (checked against the v8.0.0 changelog): ESM-only publish, middleware always-on + `RouterContextProvider`, `meta` `data`→`loaderData` rename, `hasErrorBoundary` removal, and the `future.v8_*` flag removals all affect data-router/framework mode. This app is declarative `BrowserRouter` with no loaders, actions, or meta. ## Other majors | Package | From → To | |---|---| | recharts | 2.15.4 → **3.10.1** | | @vitejs/plugin-react | 5.1.1 → **6.0.4** | | @testing-library/jest-dom | 6.9.1 → **7.0.0** | | rollup-plugin-visualizer | 6.0.11 → **7.0.1** | ## Validation - Backend: lint ✅ · build ✅ · tests ✅ **3541 passed** - Frontend: lint ✅ **0 errors** · build ✅ (charts + editor chunks build fine on recharts 3 / plugin-react 6) · **906 passed, zero failures** - All three lockfiles regenerated together from a root install; `react-router-dom` is gone from every lockfile and `react-router@8.3.0` is the only entry - Release-log entry added (CI gate) After this merges, **Dependabot alerts should be at 0**. `playwright-smoke` remains the known-red baseline (red on main since 2026-06-02). 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by Sourcery Upgrade routing and frontend tooling dependencies and align Node version requirements across the app and CI. Bug Fixes: - Resolve outstanding high-severity dependency alerts related to react-router. Enhancements: - Migrate all frontend and test imports from react-router-dom to react-router v8. - Update recharts, @vitejs/plugin-react, @testing-library/jest-dom, and rollup-plugin-visualizer to their latest major versions. Build: - Raise frontend Node engine requirement to >=22.22.0 and add a root .node-version file for consistent runtime selection. CI: - Switch all GitHub Actions workflows to use Node 22 for backend, frontend, mobile, nightly, and integrity jobs. Documentation: - Add release-log entry documenting the security-focused dependency upgrades and Node version change. Tests: - Adjust routing-related tests and mocks to target react-router instead of react-router-dom while keeping coverage intact. Chores: - Regenerate root and workspace lockfiles to reflect the new dependency versions and removal of react-router-dom.
… to Video model
processingStep,processingProgress, anddownloadablefields to the Video model in Prisma schema.