chore: sync with upstream Postiz (2026-09-22 evening) - #59
Conversation
…g soft-deleted tags Deleting a tag now hard-deletes its TagsPosts rows (org-scoped soft-delete runs first), the save-path tag matching filters deletedAt so a recreated same-name tag can no longer attach the old dead tag alongside it, and the post read queries filter soft-deleted tags out of the tags include so already-orphaned assignments stop rendering immediately and self-heal on the next save. Tested e2e on a local run: reproduced the stuck remnant and the doubled label on main (delete tag assigned to 2 posts -> assignments and calendar label survive; recreate same name -> both dead and live tag attach), then verified on this branch that orphaned assignments stop rendering immediately, re-saving a post drops the orphan row, deleting a live tag removes its assignments, and a recreated same-name tag attaches exactly once - all confirmed in both the calendar UI and the DB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…caching an empty result analytics() destructured only `data` from the page insights response, so any Graph error body (rate limit, permission, invalid metric) silently became [] and checkAnalytics cached that empty result for an hour. Log the error and throw, so checkAnalytics skips the cache write and the next call retries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The MCP server is stateless, so the GET that streamable-HTTP clients send to open a server-to-client stream is not a supported route: nothing is ever pushed on it. It is still about a quarter of MCP traffic (roughly 180k-214k of ~750k requests per day), and each one became a Sentry transaction that stayed open for the life of the held connection (avg ~5 min, p95 15 min). Sentry usage is already near its limits, which is why gitroomhq#2082 recently cut trace sampling from 100% to 20%. There is nothing to learn from traces of an unsupported route, so tracesSampler now returns 0 for GET on /mcp, /mcp/:id and the /mcp-oauth* mounts. POST traffic on the same routes, the legacy /sse/:id transport and the /.well-known discovery routes keep the existing sample rates. Pairs with the change that answers these GETs with 405. Deploy this one after that change has been verified in Sentry, because the verification reads the GET transactions this commit removes. Testing: - Called the real tracesSampler with request contexts shaped like the http instrumentation passes them (method and URL via normalizedRequest and via span attributes only): GET on the MCP routes returns 0; POST on the same routes 0.2; unrelated GETs 0.2; analytics 0.01; legacy /sse 0.2; /.well-known/.../mcp-oauth discovery 0.2; /mcpfoo 0.2. - Ran the backend against a Sentry development environment and sent 140+ GETs and 60+ POSTs to /mcp, /mcp/:id and /mcp-oauth-claude: zero GET transactions recorded, POST /mcp and POST /mcp/:id recorded as before. Same result on SDK 10.45.0 and 10.56.0. - Backend type-check passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The release URL for every Google My Business post was a hardcoded https://business.google.com/locations/<locationId> link, which Google answers with a 404, even for the logged-in profile owner. That link is what the "published" notification email and the preview point to. localPosts.create returns a searchUrl for the created post; use it as the release URL and keep the old link only as a fallback when Google does not send one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…get-traces fix(sentry): stop tracing GET requests to the MCP streamable routes
…ch-url fix(gmb): use Google's searchUrl as the post release URL
…ost schema
Hashnode retired gql.hashnode.com on 2026-05-13: every request now gets a
301 to an announcement page, so connecting a channel failed with "Invalid
credentials" for every key, and publishing failed for existing channels.
- point the three calls (authenticate, publications, post) at
gql-beta.hashnode.com
- send tags as { slug } (PublishPostTagInput no longer has id); the slug is
looked up by objectID from the existing tags list, so stored posts keep
working
- send the cover as coverImage (coverImageOptions was removed from
PublishPostInput)
- read the GraphQL errors array and throw BadBody with Hashnode's message.
Hashnode answers HTTP 200 with data: null for refusals such as a
publication without an active Pro plan, which used to surface as a
TypeError, get retried, and end as "couldn't confirm it was published"
Fixes gitroomhq#1737
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A response without a post (data: null with no errors array, or a null publishPost/post) threw a TypeError on destructuring, which is retried and re-sends the publish mutation. Treat it as BadBody like the errors case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ration fix(hashnode): move to the live GraphQL endpoint and current publishPost schema
Add POST /public/v1/clipping, GET /public/v1/clipping and GET /public/v1/clipping/:id to the public API, calling the existing ClippingService. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-api feat(public-api): clipping endpoints
…cs-swallowed-errors fix(facebook): surface Graph API errors in page analytics instead of caching an empty result
A customer reported that their Facebook posts show no statistics at all; every one of their Facebook posts is a reel. The video_insights edge answers a reel with an empty data array for total_video_impressions, total_video_views and total_video_reactions_by_type_total, so videoPostAnalytics returned [] and the statistics modal stayed empty. Verified against a real reel: the same request with fb_reels_total_plays, post_video_likes_by_reaction_type and post_video_social_actions returns values, and Graph simply omits whichever metrics do not apply to the node, so both sets are now requested in one call. Regular videos keep their existing cases; the new ones map to Plays, Reactions and Engagement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wvGQeFVP1UKfi9VAYYBYK
…sights fix(facebook): request the reels metrics in video post analytics
…it search The composer's "Search Subreddit" box sent the typed text verbatim to Reddit's subreddit search. A pasted reddit.com/r/<name> URL matched nothing, so there was no result to pick and the subreddit setting was saved empty, which then failed validation with "value should not be null or undefined". When the text contains r/<name>, search by the name alone and put the exact subreddit first in the results if it exists. Plain text still sends the same search request as before, and the exact lookup only swallows a not-found response so token refresh keeps working. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…post published
Slack answers HTTP 200 with { ok: false, error } when it rejects a
message. post() and comment() destructured ts off that body and returned
status 'posted' regardless, so a rejected post (e.g. invalid_blocks for
an mp4 in an image block) was marked PUBLISHED with an empty release
URL and nothing in the channel.
Check the response after both chat.postMessage calls: auth errors throw
RefreshToken, ratelimited is retried, everything else throws BadBody
with Slack's error code and detail lines as the message.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Slack provider sends every attachment as a Block Kit image block, which Slack only accepts for png / jpg / gif. An mp4 made chat.postMessage reject the whole message, and the post was still shown as published. Add a checkValidity override so a Slack post or comment with an mp4 attachment fails validation with "No video support for Slack, only images" in the composer, the public API and the MCP schedule tool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mirror the subreddit_type === 'public' filter of the search path so a pasted r/name that points at a private, restricted or archived subreddit is not offered as a destination the post would fail on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y-name feat(reddit): resolve a pasted r/name or subreddit URL in the subreddit search
…known Error" Adds handleErrors branches for 190/459 (checkpoint), 190/492 (no Page role), the 190 missing pages_* permissions body, 100/33 object does not exist, and Facebook's HTML outage page, so users see the reason and the right outcome (fail, reconnect, or retry) instead of "Unknown Error".
"error_subcode":33 as a substring also matched 330 and 331; the same held for 459 and 492. Test the three subcodes with a trailing word boundary instead.
Reddit can return several error entries at once. A terminal error next to RATELIMIT would reject every resubmit, so retrying burns the whole pending budget before the user sees the real reason. Retry only when every entry is RATELIMIT. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… rejection Reject a board name in the settings DTO at scheduling time and fix the over-escaped handleErrors match so Pinterest's board-id rejection is curated instead of surfacing as Unknown Error.
…-retry fix(reddit): retry the submit after a RATELIMIT rejection instead of failing
…appings-main fix(facebook): map five recurring Graph API rejections instead of "Unknown Error"
Route post, comment and login through this.fetch with a handleErrors mapping so Lemmy rejections fail once with a readable message (rate limits retry, bad credentials flag the channel); the login failure is rebuilt without the request body so the stored password is never persisted.
VK answers HTTP 200 with { error } on failures, so wall.post/wall.createComment rejections were stored as completed with postId undefined. Map code 5 to RefreshToken, 6/9/29 to a retryable error, everything else to BadBody with VK's error_msg.
…id-main fix(pinterest): validate board as a numeric id and map the board-name rejection
…rrors twitter-api-v2 401s reach handleErrors via runInConcurrent with status 200, so the generic 401 rule never fired and the post failed as Unknown Error without flagging the channel.
A non-2xx from /api/v3/search threw a TypeError on communities.map; it now fails through the same handleErrors mapping as post and comment.
…ry-message-main fix(x): readable message when Too Many Requests retries are exhausted
…rrors-main fix(vk): surface VK API errors instead of marking posts completed
Locked account, crypto addresses, invalid media ids, 10-minute video, Premium-only articles and empty tweet now map to bad-body with a message instead of Unknown Error.
Dribbble 4xx responses on shot creation were plain AxiosErrors: the workflow retried them to exhaustion and recorded "Could not publish after several attempts", and the response body with Dribbble's actual rejection reason was discarded. Map 4xx (except 429) to BadBody with the response body persisted in the failure details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188yxtbWi6wtfJg7ydjUpfG
…ling-main fix(lemmy): surface Lemmy API errors instead of crashing on post_view
…efresh-token-main fix(x): mark the channel for reconnect on Unauthorized media upload errors
…main # Conflicts: # libraries/nestjs-libraries/src/integrations/social/x.provider.ts
…-main fix(x): map remaining known error responses to readable messages
…-mapping-main fix(dribbble): map 4xx shot rejections to non-retryable BadBody errors
# Conflicts: # apps/frontend/src/components/onboarding/onboarding.modal.tsx # apps/frontend/src/components/public-api/public.component.tsx # chatgpt-app-submission.json # libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts # libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
- reddit.provider.ts: the fork's broker-based generateAuthUrl uses makeId(32) for the state; the upstream merge replaced the import with makeSecureId. - public.component.tsx: drop the upstream mcpConnectorUrls table (claude.ai/directory/postiz etc.) entirely - it routes users to the upstream cloud and the fork gates connector cards via brandConfig URLs (fail closed). Onboarding modal's unused import removed.
There was a problem hiding this comment.
⏱️ Code Review completed (44 files · 102,084 chars · 3 PR unit(s))
ℹ️ Full-Context Analysis: Analyzed all changed files in a unified context pass to preserve cross-file type definitions, imports, and caller contracts. Deducted 3 PR units.
⚠️ PR diff exceeded maximum review ceiling (3 batches / ~90,000 chars) - lower-priority files were skipped.
⏱️ Adversarial Review completed (Model: qwen3.8-27b)
🔍 Verified Adversarial Review Findings
📋 Findings Summary (1 inline finding)
- 🟡 IMPORTANT
libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts:177-180: Tag mapping producesundefinedslug for unknown tags (💡 1-click suggestion on diff)
💡 1-Click Suggestions Ready: Go to the Files changed tab to review and apply 1 suggestion directly with 1-click commit.
🛡️ Dismissed Claims
- Hashnode cover image field change (
coverImagevscoverImageOptions): The diff changes the endpoint fromgql.hashnode.comtogql-beta.hashnode.com. Thegql-betaendpoint is the newer API version which uses thecoverImagestring field directly, whereas the oldergqlendpoint usedcoverImageOptions. Since the endpoint and the field name are changed together to match the new API contract, this is a valid migration, not a bug.
| ? { originalArticleURL: settings.canonical } | ||
| : {}), | ||
| contentMarkdown: postDetails?.[0].message, | ||
| tags: settings.tags.map((tag: any) => ({ id: tag.value })), | ||
| tags: settings.tags.map((tag: any) => ({ |
There was a problem hiding this comment.
🟡 IMPORTANT: Tag mapping produces undefined slug for unknown tags
Failure Trace:
- User selects a tag in the UI. The
tag.valueis anobjectIDstring.
2. The code executestags.find((t) => t.objectID === tag.value)?.slug.
3. Iftag.valuedoes not match anyobjectIDin the statictagsarray (e.g., due to a mismatch, a new tag added to Hashnode not yet in the local list, or a typo),findreturnsundefined.
4. The resulting object is{ slug: undefined }.
5. This object is included in thetagsarray of the GraphQL mutation payload.
6. When sent to the Hashnode API, theundefinedvalue is either stripped (resulting in an empty object{}which may fail validation) or causes a GraphQL validation error becauseslugis required. This breaks post creation or results in tags being silently dropped.
| ? { originalArticleURL: settings.canonical } | |
| : {}), | |
| contentMarkdown: postDetails?.[0].message, | |
| tags: settings.tags.map((tag: any) => ({ id: tag.value })), | |
| tags: settings.tags.map((tag: any) => ({ | |
| tags: settings.tags | |
| .map((tag: any) => ({ | |
| slug: tags.find((t) => t.objectID === tag.value)?.slug, | |
| })) | |
| .filter((t) => t.slug), |
There was a problem hiding this comment.
Code Review
This pull request introduces several enhancements, including the integration of clipping tools, the addition of a comprehensive Postiz Cloud vs. Open-source comparison in the README, and the implementation of a cryptographically secure makeSecureId utility to replace makeId for sensitive tokens and credentials across multiple social providers. It also strengthens error handling for various social integrations (such as Facebook, Lemmy, Slack, and X), refines tag deletion in the database, and adds support for both .mov and .mp4 video uploads. The code review feedback highlights three key areas for improvement: first, the page query parameter in public.integrations.controller.ts should be explicitly parsed as an integer using NestJS pipes to prevent runtime type mismatches; second, in reddit.provider.ts, a safety check should be added to ensure about.data is defined before accessing its nested properties to avoid potential crashes; and third, in hashnode.provider.ts, undefined slugs should be filtered out during tag mapping to prevent sending empty objects that would fail Hashnode's GraphQL validation.
| @Get('/clipping') | ||
| getClippings( | ||
| @GetOrgFromRequest() org: Organization, | ||
| @Query('page') page: number |
There was a problem hiding this comment.
The page query parameter is typed as number, but without a transformation pipe or explicit parsing, NestJS will receive it as a string at runtime (or undefined if not provided). This can cause runtime errors or unexpected behavior (like NaN offset calculations) when passed to the database service. Consider using ParseIntPipe along with DefaultValuePipe to ensure it is always a valid integer. Note: You will need to import ParseIntPipe and DefaultValuePipe from @nestjs/common.
| @Query('page') page: number | |
| @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number |
| if ( | ||
| about?.kind !== 't5' || | ||
| about.data.subreddit_type !== 'public' || | ||
| about.data.submission_type === 'image' | ||
| ) { |
There was a problem hiding this comment.
If the about response is returned but the data property is missing or undefined, accessing about.data.subreddit_type will throw a TypeError: Cannot read properties of undefined (reading 'subreddit_type') and crash the execution. You should safely guard against a missing data object before accessing its nested properties.
| if ( | |
| about?.kind !== 't5' || | |
| about.data.subreddit_type !== 'public' || | |
| about.data.submission_type === 'image' | |
| ) { | |
| if ( | |
| about?.kind !== 't5' || | |
| !about?.data || | |
| about.data.subreddit_type !== 'public' || | |
| about.data.submission_type === 'image' | |
| ) { |
| tags: settings.tags.map((tag: any) => ({ | ||
| slug: tags.find((t) => t.objectID === tag.value)?.slug, | ||
| })), |
There was a problem hiding this comment.
If tags.find returns undefined (i.e., a tag value is not found in the predefined tags array), mapping it directly will result in { slug: undefined }. When serialized to JSON, this becomes {}. Hashnode's GraphQL API expects a non-empty slug or id for each tag input, so sending an empty object will trigger a validation error. It is safer to filter out any undefined slugs before mapping them.
| tags: settings.tags.map((tag: any) => ({ | |
| slug: tags.find((t) => t.objectID === tag.value)?.slug, | |
| })), | |
| tags: settings.tags | |
| .map((tag: any) => tags.find((t) => t.objectID === tag.value)?.slug) | |
| .filter((slug): slug is string => !!slug) | |
| .map((slug) => ({ slug })), |
There was a problem hiding this comment.
⏱️ Code Review completed (44 files · 101,935 chars · 3 PR unit(s))
ℹ️ Full-Context Analysis: Analyzed all changed files in a unified context pass to preserve cross-file type definitions, imports, and caller contracts. Deducted 3 PR units.
⚠️ PR diff exceeded maximum review ceiling (3 batches / ~90,000 chars) - lower-priority files were skipped.
⏱️ Adversarial Review completed (Model: qwen3.8-27b)
🔍 Verified Adversarial Review Findings
📋 Findings Summary (1 inline finding)
- 🟡 IMPORTANT
libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts:180: Unsafe tag mapping producesundefinedslug (💡 1-click suggestion on diff)
💡 1-Click Suggestions Ready: Go to the Files changed tab to review and apply 1 suggestion directly with 1-click commit.
🛡️ Dismissed Claims
- Hashnode cover image field name change: The change from
coverImageOptions: { coverImageURL: ... }tocoverImage: ...is a deliberate API contract update. The diff also updates the endpoint fromgql.hashnode.comtogql-beta.hashnode.com, indicating a migration to a new API version where the schema has changed. Without evidence that the new endpoint still expects the old field name, this is not a bug but an intentional adaptation to the new API.
| : {}), | ||
| contentMarkdown: postDetails?.[0].message, | ||
| tags: settings.tags.map((tag: any) => ({ id: tag.value })), | ||
| tags: settings.tags.map((tag: any) => ({ |
There was a problem hiding this comment.
🟡 IMPORTANT: Unsafe tag mapping produces undefined slug
Failure Trace:
- User creates a post with a tag where
tag.valueis an ID not present in the statictagsarray (e.g., a new tag added to Hashnode after the static list was generated, or a mismatch in ID format).
2.tags.find((t) => t.objectID === tag.value)returnsundefined.
3.?.slugevaluates toundefined.
4. The resulting object is{ slug: undefined }.
5. WhenjsonToGraphQLQueryconstructs the mutation, it serializes this as{ slug: null }or omits the field depending on configuration, but typically sendsnullfor undefined values in object literals.
6. The Hashnode GraphQL API rejects the mutation with a validation error (e.g., "Field slug of type String! was not provided") or silently fails to apply the tag, whereas the previous code sent the raw ID which might have been handled differently or failed more explicitly.
| tags: settings.tags.map((tag: any) => ({ | |
| tags: settings.tags | |
| .map((tag: any) => { | |
| const found = tags.find((t) => t.objectID === tag.value); | |
| return found ? { slug: found.slug } : null; | |
| }) | |
| .filter(Boolean), |
What kind of change does this PR introduce?
CI/CD / Upstream sync. Manually completed sync merging upstream gitroomhq/postiz-app main (75 commits, head 4c33d52) into dev: provider error-mapping fixes (dribbble, X, lemmy, VK), X unauthorized-refresh-token fix, YouTube clipping workflow stabilization, Mastra/MCP updates.
Five conflicts resolved by hand:
Why was this change needed?
Keep the upstream corridor current: 75 commits of provider fixes, clipping workflow stabilization and security hardening flow in small reviewable increments (per ADR-0001).
Technical Details & Scope
Verification & Testing
QA
Checklist: