Conversation
… missing insights edge
postAnalytics always called /{postId}/insights, but that edge only exists on
page feed posts. Video/reel and story posts store a bare id whose node has no
insights edge, so Facebook returned "(#100) Tried accessing nonexisting field
(insights)" — surfaced in prod as "Error fetching Facebook post analytics:
ApplicationFailure: Unknown Error".
postAnalytics now branches on the stored releaseId shape (the only available
discriminator, since no post type is persisted for analytics):
- Feed post ({pageid}_{postid}, contains "_") -> unchanged: /{postId}/insights
with the existing metrics/mapping. No regression.
- Video/reel (bare numeric id) -> /{videoId}/video_insights on v23.0, mapped to
AnalyticsData: total_video_impressions -> Impressions, total_video_views ->
Views, total_video_reactions_by_type_total -> Reactions (metric names verified
against the current v23.0 docs).
- Story (bare id, no usable insights via this path) -> clean [], no error.
The video call uses plain fetch (not this.fetch) so a "(#100) nonexisting field
(video_insights)" / empty response yields a quiet [] instead of throwing. A
"nonexisting field" error stays silent (expected for stories); any other error
(bad metric name, token, permissions) is logged via console.warn so a silent
empty result is diagnosable without breaking the statistics page. postAnalytics
still never throws.
Tested locally against a freshly connected Facebook page: published a video post,
confirmed its releaseId resolves to a real video node, and that
/{videoId}/video_insights returns {"data":[]} (no data yet) with NO
"(#100) nonexisting field" or "must be one of" error — i.e. the old crash is gone,
the metric names are valid, and the app returns a clean [] for the no-data-yet case.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
… hijack the personal channel Co-Authored-By: Claude Fable 5 <[email protected]>
…fetime_metrics postAnalytics requests IMPRESSION, PIN_CLICK, OUTBOUND_CLICK and SAVE, but read the results from lifetime_metrics. Pinterest returns period metrics in summary_metrics; lifetime_metrics only ever carries lifetime metric types (TOTAL_COMMENTS / TOTAL_REACTIONS), and a request cannot mix lifetime and non-lifetime types. So lifetime_metrics was always empty and every Pinterest pin showed no stats. Verified against the live API: published a real pin, pre-fix the method returned [], post-fix it returns all four metrics. Co-Authored-By: Claude Fable 5 <[email protected]>
…image sizes TikTok returns fail_reason `picture_size_check_failed` when media violates its size rules, and we mapped it to "Video must be at least 720p, Picture must no exceed 1080p". Both halves were wrong or unhelpful: TikTok documents no minimum for photos, "1080p" never said which dimension, and the 720p video claim has no basis in the docs. A customer hit this on TikTok carousels created through the public API. Her images were 941x1672 (valid), but each failing carousel also contained a 1086x1448 image - 6px over the 1080 limit on the shorter side - and a single oversized image fails the entire carousel. TikTok never reports which image failed, so the generic message sent her resizing images that were already fine. Verified against the real API on a connected TikTok channel, uploading through the public API so the media reaches TikTok at its original dimensions: 480x640 -> PUBLISHED (disproves the "at least 720p" claim) 320x320 -> FAILED (picture_size_check_failed, confirms the 360 floor) 5000x5000 -> PUBLISHED (TikTok does not enforce its documented 4096 max) The message therefore claims only what was verified: the 1080 image ceiling and the 360 video floor. The documented 4096 ceiling is omitted because TikTok accepted a 5000x5000 video. `checkValidity` now measures photos up front and names the offending image, so the failure surfaces at save time instead of hours later at publish. It mirrors the existing Pinterest check and reuses `getImageDimensions`. Videos are not pre-checked: there is no ffmpeg dependency server-side, so video size still only surfaces at publish through the corrected message. Already-scheduled carousels are not re-validated - `checkValidity` only runs on create/update - so existing invalid posts will still fail at publish, now with the corrected message. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…connection A Facebook channel can end up holding the user's token instead of the page's: if the page is only visible through a Business portfolio and was never ticked in the OAuth page list, Graph returns no access_token for it, fetchPageInformation passes undefined to the update, and Prisma leaves the between-steps user token in place. The channel then looks connected while every publish is rejected with "(gitroomhq#200) Unpublished posts must be posted to a page as the page itself". That error was unmapped, so it surfaced as a generic "Unknown Error" on every post while the channel kept reporting as healthy - the user had no way to tell a token problem from a content problem. Map it to refresh-token so the channel is flagged, the reconnect notification is sent, and the post is not retried against a token that can never work. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…sage Facebook rejects page publishes with an OAuthException code 200 when the connected account lacks pages_manage_posts / pages_read_engagement or sufficient page role. This previously fell through handleErrors to the 'Unknown Error' placeholder, so the failure email (and, once merged, the curated calendar tooltip from gitroomhq#1868) showed nothing actionable. Per Meta's docs, codes 200-299 are API Permission errors, so the (gitroomhq#200) marker always denotes a permissions problem. As a side effect, the preset retry helper no longer treats these as 'Unknown Error', avoiding a wasted second publish attempt. Co-Authored-By: Claude Fable 5 <[email protected]>
…ssage too The same Graph (gitroomhq#200) OAuthException surfaces on the Instagram (Facebook-login) provider when the connected user lacks sufficient permissions on the Facebook Page linked to the Instagram account. Map it the same way as in the Facebook provider (non-retryable bad-body), placed before the generic '190,' rule; instagram-standalone delegates to the same handleErrors. Co-Authored-By: Claude Fable 5 <[email protected]>
The Reddit DTO advertised type as link/self/image/video/videogif, but the provider only implements self, link and media (upload the first attached file). Other values were forwarded to Reddit as kind without a file and always failed with BAD_URL. Validate type with @isin and fix the public API / MCP schema description. Also initialise url to '' when a subreddit is picked in the composer, so the URL input is controlled from mount (fixes the React uncontrolled to controlled warning) and empty link URLs are validated. Co-Authored-By: Claude Fable 5 <[email protected]>
…ling
After 5 failed preparation attempts the provider threw a generic
"Could not prepare the post for Bluesky" BadBody with an empty json
payload, swallowing the real reason (e.g. the video service rejecting
an oversized upload). Include the underlying error message in both the
BadBody json ({"message": ...}) and the user-facing failure message,
so the bell notification and failure email tell the user what actually
went wrong.
Co-Authored-By: Claude Fable 5 <[email protected]>
The subreddit picker stores Reddit's search result url verbatim
("/r/Name/"), and the sr normalization only removed the "/r/" prefix,
so submits were sent with a trailing slash ("name/"). Reddit accepts
that shape intermittently but sometimes rejects it with
SUBREDDIT_NOEXIST even though the community exists. Strip leading and
trailing slashes at both normalization sites.
Co-Authored-By: Claude Fable 5 <[email protected]>
Public-API users sometimes type "r/name" without the leading slash, which the '/r/' string replace left untouched. Normalize both prefix shapes with one regex so every input form resolves to the bare name. Co-Authored-By: Claude Fable 5 <[email protected]>
Crove is first-party DOS, so checkout must grant the same user_plans row used by app.dos.ai. Co-authored-by: Cursor <[email protected]>
Meta's Threads API counts the 500-character post limit in UTF-8 bytes (https://developers.facebook.com/docs/threads/posts), while Postiz counted UTF-16 code units, so a post with curly quotes, accented letters or emoji could pass the editor counter and the server validation and then be rejected by Threads at publish time. A thread whose second part was affected published only its first part. Adds a countLength helper (X keeps twitter-text weighted length, Threads uses UTF-8 byte length, everything else plain length) and uses it in the /posts/valid check and in the editor character pill, so both sides flag the same text. The global-mode pill now shows the count for the channel whose limit it displays instead of the raw length. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TEjfd79TJocNAFULswHkXV
Adds activeOrgsBySource to /admin/stats (distinct orgs with a QUEUE, PUBLISHED or ERROR post in the range, grouped by Post.creationMethod) and renders it as a summary card and table on the admin stats page.
…in state Neynar retired Sign In With Neynar, so the hosted popup used for Farcaster login and channel connect no longer completes. Replace it with Neynar managed signers: the backend creates a signer, signs the EIP-712 signed key request with the app custody mnemonic (viem), registers it with Neynar and renders the approval deep link as a QR (qrcode) since the link is a bare 302 to the farcaster:// scheme. The frontend polls the new public /auth/farcaster/signer routes and builds the same base64 blob the popup produced, so authenticate(), the auth provider, the stored token format and existing users/channels are unchanged. @neynar/react is removed. Also thread the oauth_state nonce through the Farcaster login button: since the Apple login change, checkExists rejects logins without a matching state cookie, which broke Farcaster login independently of the SIWN retirement. New env vars: NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC, NEYNAR_SPONSOR_SIGNERS. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
…of hardcoded v23.0 Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01B31McizpPZ7MhPdHSmbpt2
A thrown fetch inside the async generator ended the for-await loop and left the modal spinning with no timeout. Catch it and keep polling, as the Moltbook status poll does; the 10 minute cap still applies. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
POST /auth/farcaster/signer is unauthenticated and creates a signer at Neynar on every call. The global throttler guard only covers the public posts API, so add a route-level guard keyed by the forwarded client address and cap the route at 10 requests per hour per address. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
GET /auth/farcaster/signer is unauthenticated and calls Neynar on every request. Apply the same per-address guard as the create route, with a ceiling of 1000 per hour so the modal's 2s poll over a 10 minute window still fits several approvals. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
…ed-signers feat(farcaster): migrate from SIWN to Neynar managed signers, fix login state
After the managed-signer migration, clicking Farcaster on the login and signup pages opened a new tab to the approval link, which only redirects to the farcaster:// scheme and stays blank on desktop, while the QR was squeezed into the narrow provider button slot. The approval view now opens straight in a modal on the login and signup pages and replaces the intermediate button step in the add/reconnect channel modal. No tab opens; a Copy Farcaster link button replaces the Open in Farcaster link. The auth layout mounts the shared modal manager and the toaster, so errors on those pages are visible. A response without an approval link (for example the rate limit) now shows the failure toaster instead of leaving the modal empty, and the signer creation limit is raised from 10 to 30 per hour per address. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
fix(farcaster): show the approval QR in a modal instead of a blank tab
…nsights fix(facebook): video/reel analytics via video_insights (no more nonexisting-field crash)
fix: count Threads post length in UTF-8 bytes
…stats feat(admin-stats): active orgs per post creation source
@mastra/pg checks for a column against a schema snapshot and then runs a plain ALTER TABLE ADD COLUMN, so instances migrating at the same time race and the loser throws 'column "organizationId" ... already exists' into the first user request. Init now runs when the Mastra instance is created and retries once against the already migrated schema. Co-Authored-By: Claude Fable 5.1 <[email protected]>
prisma-db-push runs with --accept-data-loss on deploy, so anything Mastra created that the schema did not mirror (37 tables, 31 columns) was dropped and rebuilt by @mastra/pg on the next boot, which is where instances raced on ALTER TABLE ADD COLUMN. The mastra_* models are now introspected from a database migrated by @mastra/pg 1.25, so db push creates them before the backend starts and Mastra's init finds nothing to alter. Existing mirrored columns are unchanged; migrate diff against a migrated DB is empty. Co-Authored-By: Claude Fable 5.1 <[email protected]>
The mastra_* tables are now mirrored in schema.prisma, so prisma db push creates them before the backend starts and the init race cannot happen. Co-Authored-By: Claude Fable 5.1 <[email protected]>
…atGPT MCP has no way to send a file from the user's device, so add an MCP Apps (SEP-1865) widget: uploadWidgetTool renders ui://postiz/upload, the widget gets a short-lived ticket through the app-only uploadWidgetTicketTool and posts the file to /media-widget/upload, then reports the media back to the model. uploadWidgetStatusTool is the host-independent fallback. The widget tools are mcpOnly, so the in-app agent keeps its current toolset. Co-Authored-By: Claude Fable 5.1 <[email protected]>
feat(mcp): upload widget for uploading local files from Claude and ChatGPT
…rsonate feat: superuser public API endpoints
…include-deleted header
The standalone OAuth bridge worker (sso.crove.com / beta-sso.crove.com) was deprecated in favor of the central PKCE bridge embedded in api.dos.me. Removes the worker app, its deploy workflow, lockfile entries, and active-doc rows. Live traffic sampled at zero (60s tail) before removal; login flow no longer references the worker.
…hatGPT app, OAuth hardening, provider fixes) Conflict resolutions: - .env.example: keep Crove rebranded reference (incl. DOS billing block) - staging-conflicts.yml: keep deleted (upstream-only staging infra) - wallet.provider.tsx: keep deleted (Solana dead-code removal, PR #34) - copilot.controller: upstream NodeHttp endpoint + copilotCors + new CopilotKit request shape, plus our OpenAI-compatible env support - oauth.controller: keep our revoke endpoint + their client_secret_basic - auth layout: our branding/i18n + their MantineWrapper/Toaster - load.tools.service: their mcpOnly filter + AgentToolInterface, our DOSClaw alias map and branded agent - start.mcp: both (brand + MCP Apps upload widget) - sentry: their tracesSampler/profiling quota fix + our bootstrap telemetry scrubbers - schema.prisma: keep fork architecture (Mastra owns its tables; no prisma mirror) - package.json/lockfile: 3-way semantic merge + regenerated lockfile
Review findings on PR #39: - build.yml: drop unconditional 'Test SSO Worker (vitest)' step that would fail every push after apps/crove-sso is removed - package.json: drop typecheck:sso / test:sso scripts - deploy-beta.ps1 / deploy-prod.ps1: rewrite without SSO steps (Branding Guard + compose contract + container instructions only) - build-all.ps1: drop SSO step, renumber to [1/3]..[3/3] - scripts/deploy-sso.ps1: delete - branding-guard.ts: drop dead apps/crove-sso/ strict prefix - docs/cicd.md: update deploy script purposes, drop Deploy SSO row - docs/README.md: drop crove-sso from repo tree - .github/copilot-instructions.md: drop crove-sso directory entry - docs/sso-integration.md: mark as historical design record, point to the api.dos.me PKCE bridge as the current implementation
…script - build-all.ps1: remove stale LASTEXITCODE guard from the deleted SSO step (stale non-zero exit from a prior native command in the same PowerShell session would abort the build with 'SSO Test failed') - dev.ps1: remove the dead -Mode sso branch and its ValidateSet entry
chore(sso): remove deprecated crove-sso Cloudflare Worker
…us fields - load.tools.service: drop stray >>>>>>> marker left by resolution - start.mcp: dedupe agents map (keep branded 3-alias version) - schema.prisma: take upstream Media status/processingError fields (required by the MCP upload widget pipeline) while keeping the no-Mastra-tables fork architecture
chore(sync): merge upstream main (Mastra 1.67, CopilotKit 1.72, MCP ChatGPT app, provider fixes)
| window.location.href = `/auth?provider=FARCASTER&code=${code}`; | ||
| const state = await (await fetch('/auth/oauth/FARCASTER')).text(); | ||
| window.location.href = `/auth?provider=FARCASTER&code=${encodeURIComponent(code)}&state=${state}`; | ||
| }, []); |
| t('link_copied_to_clipboard', 'Link copied to clipboard'), | ||
| 'success' | ||
| ); | ||
| }, [approvalUrl]); |
| return () => { | ||
| activeSigner.current = ''; | ||
| }; | ||
| }, []); |
| <img | ||
| src={qrCode} | ||
| alt="" | ||
| className="w-[200px] h-[200px] rounded-[8px] bg-white" | ||
| /> |
| } | ||
| return subscription?.subscriptionTier; | ||
| }, [subscription, initialChannels, monthlyOrYearly, period]); | ||
| }, [subscription, initialChannels, monthlyOrYearly, period, sharedDosBilling]); |
There was a problem hiding this comment.
⏱️ Adversarial Review completed (Model: qwen3.8-27b)
⚠️ Input diff exceeded 30000 chars and was truncated before review.
🔍 Verified Adversarial Review Findings
🟡 IMPORTANT
apps/backend/src/api/routes/media.controller.ts:167-178: Ghost file creation on successful multipart upload completion- Failure Trace:
- Client initiates a multipart upload to S3/R2 and completes it via
POST /media/upload-server/complete-multipart-upload. handleR2Uploadexecutes the S3completeMultipartUploadAPI call.- In many S3-compatible implementations (or if the library is configured to pipe the response),
handleR2Uploadmay send the response headers/body (e.g., a 200 OK with XML or a 302 redirect) before returning. This setsres.headersSent = true. - The condition
if (endpoint !== 'complete-multipart-upload' || res.headersSent)evaluates totruebecauseres.headersSentistrue. - The function returns
uploadimmediately. - The code block below (lines 167-178) is skipped. This block is responsible for calling
this._mediaService.saveUploadedFileto persist the media record in the database. - Result: The file exists in S3 storage but has no corresponding record in the database. The user cannot see or use the file in the UI, resulting in data loss/orphaned storage.
- Client initiates a multipart upload to S3/R2 and completes it via
- Actionable Fix:
The logic should ensure thatsaveUploadedFileis called regardless of whether headers were sent, or the response sending should be deferred until after the database save. A safer pattern is to check if the response was successfully sent with a 2xx status, or to always attempt the save and only send the final JSON response if headers haven't been sent (logging a warning if they have). Alternatively, ensurehandleR2Uploaddoes not send the response forcomplete-multipart-uploadand lets the controller handle the final response.
- Failure Trace:
🛡️ Dismissed Claims
apps/backend/src/api/routes/media.widget.controller.ts:36-40IDOR risk: TheUploadWidgetAuthMiddlewareis explicitly applied toMediaWidgetControllerinapi.module.ts(consumer.apply(UploadWidgetAuthMiddleware).forRoutes(MediaWidgetController)). While the middleware implementation is not in the diff, the claim assumes it is insecure or missing. Without evidence that the middleware fails to bind the session to the user/org, this is a speculative concern about unprovided code. The controller correctly uses@GetOrgFromRequest()which relies on the authenticated context established by the middleware.apps/backend/src/api/routes/auth.controller.ts:302-310Missing permission guard: The endpoint isPOST /auth/farcaster/signer. TheAuthControlleris part of theauthenticatedControllerlist inapi.module.ts, meaning it is protected byAuthMiddleware. The claim that it lacks authorization is partially true (no specific role check), but creating a signer is typically a user-level action, not an org-admin action. The throttling (limit: 30, ttl: 3600000) provides rate limiting. Without evidence that signer creation is a privileged operation requiring specific org permissions (e.g., billing or admin), this is a design choice rather than a clear security vulnerability. The primary risk (abuse) is mitigated by throttling.
There was a problem hiding this comment.
Code Review
This pull request removes the Cloudflare Worker SSO bridge in favor of a centralized PKCE bridge via api.dos.me, integrates a media normalization pipeline using RunPod Serverless and Temporal workflows, and introduces an MCP upload widget toolset. It also adds DOS shared billing integration, improves Farcaster authentication via a QR code/link approval flow, and updates several social providers (Bluesky, Facebook, Instagram, Pinterest, Reddit, TikTok) to enhance error handling and feature support. Feedback on these changes highlights critical runtime issues, including stream type mismatches in the upload validation utility, Prisma compilation errors in the media repository due to non-unique fields in where clauses, potential TypeError crashes in the Facebook and Pinterest providers, and unhandled promise rejections in the TikTok provider.
| export async function uploadStreamToStorage( | ||
| storage: IUploadProvider, | ||
| webStream: ReadableStream, | ||
| declaredSize = 0 | ||
| ) { | ||
| let sniffed: ReadableStream & { fileType?: { mime: string; ext: string } }; | ||
| try { | ||
| sniffed = await fileTypeStream(webStream); | ||
| } catch (err) { | ||
| throw new BadRequestException('Failed to read file', { cause: err }); | ||
| } | ||
| const detected = sniffed.fileType; | ||
| if (!detected || !UPLOAD_ALLOWED_MIME.has(detected.mime)) { | ||
| await sniffed.cancel(); | ||
| throw new BadRequestException('Unsupported file type.'); | ||
| } | ||
|
|
||
| const maxSize = getMaxSize(detected.mime); | ||
| if (declaredSize > maxSize) { | ||
| await sniffed.cancel(); | ||
| throw new BadRequestException('File is too large.'); | ||
| } | ||
|
|
||
| // pipeline (not pipe) so failing the cap also tears down the source | ||
| const body = pipeline( | ||
| Readable.fromWeb(sniffed as any), | ||
| maxSizeStream(maxSize), | ||
| () => {} | ||
| ); | ||
|
|
There was a problem hiding this comment.
There are two stream type mismatches here that will cause runtime crashes:
fileTypeStreamexpects a Node.jsReadablestream, but is passed a webReadableStream(webStream).sniffedreturned byfileTypeStreamis a Node.jsReadablestream, but is passed toReadable.fromWeb(which expects a webReadableStream).
To fix this, convert webStream to a Node.js stream first using Readable.fromWeb(webStream as any), pass that to fileTypeStream, and then pass the resulting sniffed stream directly to pipeline without wrapping it in Readable.fromWeb.
export async function uploadStreamToStorage(
storage: IUploadProvider,
webStream: ReadableStream,
declaredSize = 0
) {
const nodeStream = Readable.fromWeb(webStream as any);
let sniffed: Readable & { fileType?: { mime: string; ext: string } };
try {
sniffed = await fileTypeStream(nodeStream);
} catch (err) {
throw new BadRequestException('Failed to read file', { cause: err });
}
const detected = sniffed.fileType;
if (!detected || !UPLOAD_ALLOWED_MIME.has(detected.mime)) {
sniffed.destroy();
throw new BadRequestException('Unsupported file type.');
}
const maxSize = getMaxSize(detected.mime);
if (declaredSize > maxSize) {
sniffed.destroy();
throw new BadRequestException('File is too large.');
}
// pipeline (not pipe) so failing the cap also tears down the source
const body = pipeline(
sniffed,
maxSizeStream(maxSize),
() => {}
);| startProcessing(org: string, id: string) { | ||
| return this._media.model.media.update({ | ||
| where: { id, organizationId: org }, | ||
| data: { status: 'processing', processingError: null }, | ||
| select: { id: true, status: true }, | ||
| }); |
There was a problem hiding this comment.
Prisma's update method where parameter only accepts unique fields. Since organizationId is not a unique field or part of a unique compound index with id, this will cause a TypeScript compilation error. Since id is already a globally unique CUID, you can safely remove organizationId from the where clause.
| startProcessing(org: string, id: string) { | |
| return this._media.model.media.update({ | |
| where: { id, organizationId: org }, | |
| data: { status: 'processing', processingError: null }, | |
| select: { id: true, status: true }, | |
| }); | |
| startProcessing(org: string, id: string) { | |
| return this._media.model.media.update({ | |
| where: { id }, | |
| data: { status: 'processing', processingError: null }, | |
| select: { id: true, status: true }, | |
| }); | |
| } |
| finishProcessing( | ||
| org: string, | ||
| id: string, | ||
| data: { name?: string; path?: string; fileSize?: number; error?: string } | ||
| ) { | ||
| return this._media.model.media.update({ | ||
| where: { id, organizationId: org }, | ||
| data: { | ||
| ...(data.name ? { name: data.name } : {}), | ||
| ...(data.path ? { path: data.path } : {}), | ||
| ...(data.fileSize ? { fileSize: data.fileSize } : {}), | ||
| status: data.error ? 'failed' : 'ready', | ||
| processingError: data.error || null, | ||
| }, | ||
| select: { id: true, status: true }, | ||
| }); |
There was a problem hiding this comment.
Prisma's update method where parameter only accepts unique fields. Since organizationId is not a unique field or part of a unique compound index with id, this will cause a TypeScript compilation error. Since id is already a globally unique CUID, you can safely remove organizationId from the where clause.
| finishProcessing( | |
| org: string, | |
| id: string, | |
| data: { name?: string; path?: string; fileSize?: number; error?: string } | |
| ) { | |
| return this._media.model.media.update({ | |
| where: { id, organizationId: org }, | |
| data: { | |
| ...(data.name ? { name: data.name } : {}), | |
| ...(data.path ? { path: data.path } : {}), | |
| ...(data.fileSize ? { fileSize: data.fileSize } : {}), | |
| status: data.error ? 'failed' : 'ready', | |
| processingError: data.error || null, | |
| }, | |
| select: { id: true, status: true }, | |
| }); | |
| finishProcessing( | |
| org: string, | |
| id: string, | |
| data: { name?: string; path?: string; fileSize?: number; error?: string } | |
| ) { | |
| return this._media.model.media.update({ | |
| where: { id }, | |
| data: { | |
| ...(data.name ? { name: data.name } : {}), | |
| ...(data.path ? { path: data.path } : {}), | |
| ...(data.fileSize ? { fileSize: data.fileSize } : {}), | |
| status: data.error ? 'failed' : 'ready', | |
| processingError: data.error || null, | |
| }, | |
| select: { id: true, status: true }, | |
| }); | |
| } |
| if (!postId.includes('_')) { | ||
| return this.videoPostAnalytics(accessToken, postId, today); | ||
| } |
There was a problem hiding this comment.
If postId is null or undefined (which is possible since releaseId is nullable in the database), postId.includes will throw a TypeError and crash the analytics worker. We should add a defensive check to return [] immediately if postId is falsy.
if (!postId) {
return [];
}
if (!postId.includes('_')) {
return this.videoPostAnalytics(accessToken, postId, today);
}| origin: [ | ||
| process.env.FRONTEND_URL, | ||
| 'http://localhost:6274', | ||
| ...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []), | ||
| ], |
There was a problem hiding this comment.
If process.env.FRONTEND_URL is undefined, the array will contain undefined, which is invalid for CORS origin configuration and can cause runtime errors or unexpected CORS behavior. It is safer to conditionally add it only if defined.
| origin: [ | |
| process.env.FRONTEND_URL, | |
| 'http://localhost:6274', | |
| ...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []), | |
| ], | |
| origin: [ | |
| ...(process.env.FRONTEND_URL ? [process.env.FRONTEND_URL] : []), | |
| 'http://localhost:6274', | |
| ...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []), | |
| ], |
| <datalist id="customer-name-options"> | ||
| {(data?.map((p: any) => p.name) || []).map((name: string) => ( | ||
| <option key={name} value={name} /> | ||
| ))} | ||
| </datalist> |
There was a problem hiding this comment.
If multiple customers share the same name, using key={name} will result in duplicate key warnings in React, which can cause rendering bugs. It is safer to use the map index or a unique ID as the key.
| <datalist id="customer-name-options"> | |
| {(data?.map((p: any) => p.name) || []).map((name: string) => ( | |
| <option key={name} value={name} /> | |
| ))} | |
| </datalist> | |
| <datalist id="customer-name-options"> | |
| {(data?.map((p: any) => p.name) || []).map((name: string, index: number) => ( | |
| <option key={index} value={name} /> | |
| ))} | |
| </datalist> |
| const metrics = data.all; | ||
|
|
||
| if (metrics.lifetime_metrics) { | ||
| const lifetimeMetrics = metrics.lifetime_metrics; | ||
| // The requested metric types are period metrics: Pinterest returns them | ||
| // in summary_metrics, never in lifetime_metrics (that only ever carries | ||
| // TOTAL_COMMENTS / TOTAL_REACTIONS). | ||
| if (metrics.summary_metrics) { |
There was a problem hiding this comment.
If data or data.all is null/undefined, accessing metrics.summary_metrics will throw a TypeError. We should use optional chaining data?.all and metrics?.summary_metrics to prevent runtime crashes.
| const metrics = data.all; | |
| if (metrics.lifetime_metrics) { | |
| const lifetimeMetrics = metrics.lifetime_metrics; | |
| // The requested metric types are period metrics: Pinterest returns them | |
| // in summary_metrics, never in lifetime_metrics (that only ever carries | |
| // TOTAL_COMMENTS / TOTAL_REACTIONS). | |
| if (metrics.summary_metrics) { | |
| const metrics = data?.all; | |
| // The requested metric types are period metrics: Pinterest returns them | |
| // in summary_metrics, never in lifetime_metrics (that only ever carries | |
| // TOTAL_COMMENTS / TOTAL_REACTIONS). | |
| if (metrics?.summary_metrics) { |
| if (firstItems?.every((p) => (p?.path?.indexOf?.('mp4') ?? -1) === -1)) { | ||
| const dimensions = await Promise.all( | ||
| firstItems?.map((p) => this.getImageDimensions(p?.path)) ?? [] | ||
| ); | ||
| const tooBig = dimensions.findIndex( | ||
| (p) => Math.min(p?.width ?? 0, p?.height ?? 0) > 1080 | ||
| ); |
There was a problem hiding this comment.
If getImageDimensions throws an error (e.g., if the file is not found, corrupt, or network fails), Promise.all will reject and crash the entire validation/publishing process. We should wrap the call in a try-catch block to handle individual rejections gracefully, and update the tooBig check to handle null values.
if (firstItems?.every((p) => (p?.path?.indexOf?.('mp4') ?? -1) === -1)) {
const dimensions = await Promise.all(
firstItems?.map(async (p) => {
try {
return await this.getImageDimensions(p?.path);
} catch {
return null;
}
}) ?? []
);
const tooBig = dimensions.findIndex(
(p) => p && Math.min(p.width ?? 0, p.height ?? 0) > 1080
);
Promote dev to main
DOS_SHARED_BILLING=truepost.Media.status/processingError— DOS.Me#809 lands the columns on the shared prod project), OAuth client_secret_basic + email claims for DCR, super-admin org scoping, provider fixesDOS_SHARED_BILLING=true,DOS_ME_API_URL=https://api.dos.me,DOS_ME_INTERNAL_API_KEY+ Media columns migration applied before deployDeploy order