Skip to content

Promote dev to main (upstream v2.25.1 + code-scanning fixes) - #70

Merged
JOY (JOY) merged 143 commits into
mainfrom
dev
Oct 12, 2026
Merged

JOY (JOY) merged 143 commits into
mainfrom
dev

Conversation

@JOY

Copy link
Copy Markdown

What kind of change does this PR introduce?

Release promotion (dev → main) for the prod deploy. It carries exactly the reviewed diff of #69, with no new commits. That is the upstream Postiz v2.25.1 sync (135 commits) plus the fork-side follow-ups:

  • the DOS billing gating ported onto upstream's redesigned billing page
  • MCP upload tool hints pointed at this deployment's public API and docs, YouTube Workspace message branded
  • the React Compiler ESLint fixes
  • the CodeQL js/double-escaping fix in stripHtmlValidation

Once merged, build-containers.yml publishes ghcr.io/dos/crove-post:latest. Nothing else changes for the running prod stack until docker compose -f scripts/docker-compose.prod.yaml up -d is run on crove-server.

Why was this change needed?

Production deploys from main (:latest image). #69 is merged and green on dev, so this promotes it.

Technical Details & Scope

See #69 for the full write-up, covering upstream highlights, conflict resolutions and follow-up commits. The things that matter for this deploy:

PROD DB prerequisite: run before compose up. These are two new nullable columns plus an index, all additive. The new code reads them, so prod must have them before the new image serves traffic. They go through the DOS-Me ledger, as with the v2.25.0 table.

ALTER TABLE "Integration" ADD COLUMN IF NOT EXISTS "customName" TEXT;
ALTER TABLE "Post" ADD COLUMN IF NOT EXISTS "batchId" TEXT;
CREATE INDEX IF NOT EXISTS "Post_batchId_idx" ON "Post"("batchId");

Temporal / email: emails now run as one sendSingleEmailWorkflow per email, on the rate-limited email and email-bulk task queues. The orchestrator must poll both queues, so do not add them to EXCLUDE_QUEUE. The old sendEmailWorkflow stays registered so the running instance can drain.

Other workflow changes: autopost and missing-post now continue-as-new behind patched(), and streak moved to streakWorkflowV2. All of these are versioned upstream changes, so in-flight runs replay safely.

Unchanged: the DOS shared billing flows (dos.me checkout, portal and cancel), DOS ID SSO, the branding engine, and the MCP tool aliases.

Verification & Testing

QA

  1. Before merging or deploying, confirm the three DDL statements above have landed on the prod DB through the DOS-Me ledger. In psql: \d "Integration" lists customName, and \d "Post" lists batchId and Post_batchId_idx.
  2. Merge this PR. Confirm the Build & Publish Crove Containers run on main succeeds and pushes ghcr.io/dos/crove-post:latest.
  3. On crove-server, run docker compose -f scripts/docker-compose.prod.yaml up -d. The crove-post container restarts on the new image, and its logs show no Prisma "column does not exist" errors.
  4. Log in at https://post.crove.com through DOS ID as the owner of a DOS Plus org and open /billing. Expect Free / Plus $9 / Pro $19 in one row, no Monthly/Yearly toggle, and no "Billing history" table. "Update Payment Method / Invoices History" opens the dos.me portal.
  5. Trigger a password reset for a test account. The email arrives within a minute. In the Temporal UI, new notification emails show up as send_email_<uuid> workflows on the email / email-bulk queues.
  6. Open /launches. Rename a channel from its menu, filter the calendar by two channels, then switch the customer. The custom name shows, and the channel filter resets for the new customer.
  7. Open /plugs. Expect a redirect to /settings?tab=plugs.

Checklist:

  • My code follows the project's code style and architectural conventions.
  • Local build passes (pnpm run build).
  • Branding guard validation passes (pnpm dlx tsx scripts/branding-guard.ts).
  • Tests and typecheck have been verified without errors.
  • Documentation has been updated (if applicable).
  • No secrets or sensitive credentials are included in this PR.
  • I have filled in the QA / Verification section above with real steps to verify this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ


Generated by Claude Code

Adds a pencil edit affordance on every row of the tags dropdown in the
creation modal (spaced away from the delete x to avoid mis-clicks), wired
to the existing ShowModal edit mode (PUT /posts/tags/:id). After saving,
the tag list revalidates and any selected instance of the edited tag is
remapped so chips and the post value reflect the new name/color without a
reload. Uses onClose to resolve the modal promise so dismissing without
saving restores the click-outside behavior. Adds a PencilIcon to the
shared ui/icons (none existed) and the edit_tag translation key with
lingo.dev translations for all locales.

Tested e2e on a local run: edited a selected tag's name and color from
the dropdown - the PUT persisted, the dropdown row and the selected
footer chip updated immediately without a reload, re-saving the post kept
it attached to the renamed tag, and dismissing the modal without saving
restored the dropdown's click-outside close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the mix-blend-difference treatment on the calendar tag bar
(label and hover action icons) with the white-text + text-shadow-tags
outline already used in the tags dropdown, so the bar stays readable for
light, gray, and saturated tag colors alike. Posts without a tag color
keep the bg-btnPrimary fallback unchanged.

Tested e2e on a local run: reproduced the unreadable label on main with a
mid-gray tag (white-on-gray difference blend is near-invisible for the
label and the hover icons), then verified on this branch that the label
and all hover action icons are crisply readable on mid-gray, pastel,
saturated red, and purple tags, with before/after screenshots of the same
post and color.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap the async bodies of editTag and deleteTag in try/finally so
setAllowClose(true) runs even if mutate or the delete request rejects;
otherwise a failed request left the dropdown's click-outside close
permanently disabled until a page reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nnel

Reconnecting a channel resolved the auth result with refreshToken:
body.refresh - a field that is empty on the OAuth callback - so every
reconnect overwrote the stored refresh token with an empty string and
set a ~31 year token expiration. From that moment the channel worked
only while the fresh access token lived (~1 hour for YouTube/GMB):
the next refresh attempt failed before even reaching the provider,
the channel was flagged for reconnection, and the next reconnect
wiped the token again - a permanent loop that no amount of
re-authorizing could escape.

- store auth.refreshToken / auth.expiresIn from the new authentication
  in the reconnect branch (reConnect stays identity-verification only)
- guard the repository update so an empty refresh token can never
  overwrite a stored one
- log the real error when a refresh or post-refresh reConnect fails
  instead of swallowing it, and pass it into the user notification
- add an optional refreshErrorMessage provider hook; YouTube maps
  Google's invalid_rapt (Workspace session policy) to a dedicated
  message telling the user how to exempt Postiz
- YouTube reConnect no longer goes through pages(), which swallowed
  the real Google error and reported any failure as 'Channel not found'
- drop the duplicated disconnect notification (refreshProcess notified
  once directly and again via disconnectChannel)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds GET /public/v1/media?page=&search= and a read-only mediaListTool so
agents can reuse already-uploaded media paths as attachments instead of
re-uploading. getMedia select now also returns type, fileSize, createdAt.

Tested: unauthenticated request returns 401; with an API key pagination
(pages count, page 2), filename search and the new fields verified;
mediaListTool appears in the MCP tools list, returns the same rows, and a
draft scheduled through the MCP with a listed path stores that path as the
post image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A negative page produced a negative Prisma skip and a 500. GetMediaDto
(same shape as GetNotificationsDto) now rejects non-numeric or < 1 values
with 400 and defaults to 1.

Tested: page=abc / 0 / -3 return 400 with validation messages, page=1.5
parses to 1, no page / page=2 / search unchanged, unauthenticated still 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Onboarding checkout now refuses to mint a new subscription when the org's
Stripe customer already has a live one, the created webhook cancels a
duplicate, the deleted webhook keeps the org on a surviving subscription,
and in-app cancel acts on all live subscriptions.
…g for the webhook

POST /billing/cancel toggles Stripe's cancel_at_period_end, but only the
customer.subscription.updated webhook wrote cancelAt to the Subscription
row. When that webhook was lost, delayed or dropped by the handler, the
billing page showed "Cancel subscription" again after a reload, and the
next click toggled the subscription back to active, so the customer was
billed at renewal after cancelling.

- setToCancel writes cancelAt right after the Stripe update succeeds
  (the cancel date on cancel, null on reactivate) through a new
  updateCancelAt service/repository method. The webhook still runs and
  writes the same value.
- The "Subscription Cancelled" email is sent after the call and only when
  the result carries a cancel date, so reactivations no longer send it.
- Both billing page toasts are chosen from the response. When the click
  did the opposite of the button, the toast says so and tells the user to
  click again.

The endpoint stays a toggle and the failed-payment immediate cancel is
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The calendar error tooltip always showed the generic 'An error
occurred while publishing this post' because getPosts never selected
the error column. Select it, but surface ONLY curated provider
messages: bad_body failures whose message came from a provider
handleErrors mapping or a hand-written provider throw. Unmapped API
responses carry the 'Unknown Error' placeholder at the point of
throwing, so anything else - raw API bodies, unexpected exceptions,
internal workflow strings like 'Refresh channel needed' - keeps the
generic tooltip exactly as today.

Curated messages are stored in post.error as {"message":"..."} so
the frontend can distinguish them from everything the column held
until now (internal strings, serialized ActivityFailure dumps), which
all render generic. Internal strings are still stored for debugging
and the raw failure still goes to the Errors table. The
checkPostStatus/finalizePost developer-guard throws are demoted to the
unmapped placeholder (detail moved to the json field) so they can
never surface either. The tooltip is capped at 400px with wrapping.

Verified e2e through real Temporal v1.0.6 workflows on a local run:
a real Pinterest 'Board not found' response surfaced its handleErrors
mapping; an unmapped bad_body, an unexpected exception, a
refreshNeeded early-exit string, and a seeded legacy serialized-blob
row all rendered the generic tooltip (checked in the rendered
calendar DOM).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…edly

changeState only wrote the error column when it had a curated message to
store, so a post that first failed with a curated bad_body message and then
failed again for an uncurated reason (a token refresh failure, an unexpected
exception) kept the old value. The calendar tooltip then showed the earlier,
unrelated error - exactly the misleading-message problem this branch set out
to fix.

Overwrite the column on every failure instead: curated messages and internal
strings are stored as before, and anything uncurated clears it so the tooltip
falls back to the generic text. The full failure is still recorded in the
Errors table, so nothing is lost for debugging.

Reported by Sentry's PR review bot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post.error keeps persisting the raw serialized failure; curation moved
to getPosts so the calendar and public API both get the curated
message or null, retroactively for existing rows.
The repository now returns a curated error message (or null) for every post,
so the MCP List Posts tool passes it through as an "error" field and declares
it in the output schema, matching the public GET /posts response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvHgEEangtx46CmTGCrnL1
… hold all of them

When a subscription lapses (trial ends or is cancelled), the FREE
downgrade disables every channel. Subscribing again never re-enabled
them, so a paying customer landed on a plan with all channels still
disabled and the app looking broken.

On a paid subscription change, if some non-deleted channels are disabled
and the plan's totalChannels can hold all of them, enable everything
back. When they don't all fit, nothing is enabled, since there is no way
to tell auto-disabled channels from ones the user disabled on purpose.
Idempotent: once everything is enabled the branch is a no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYTuLLhYQsLCdgxBFEXpG9
…l the user after checkout

Re-enabling all channels on any subscription create/update also undid
channels the user disabled on purpose whenever a same-plan update arrived
(renewal, cancel toggle, store re-sync). The enable-all now also requires
the new channel limit to be larger than the current subscription's, which
still covers trial end, resubscribing (no subscription row, limit 0) and
upgrades.

After returning from checkout, a notice on the calendar tells the user
whether all their channels were enabled again or stay disabled because the
plan does not cover them. It opens after the onboarding modal so it is shown
on top of it and is not removed by the onboarding close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…extensions

Tag events whose throwing callback has no frame from our /_next/ bundle as
third_party_code and do not open the report dialog for them. Frames of Sentry's
own callback wrapper are skipped, and frameless "Could not establish connection"
rejections are treated as extension noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… name

Adds a nullable customName column on Integration, PUT /integrations/:id/custom-name, and Rename channel / Reset to original name options in the channel menu. Channel lists (app, public API, agent) return the custom name when set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153xf1m3VSQnd9QrMLhDikq
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	apps/backend/src/api/routes/integrations.controller.ts
Adds a hover-only globe icon to calendar items that opens the post's
releaseURL in a new tab. Shown only for non-recurring PUBLISHED posts
with a valid http URL, so rescheduled/re-drafted and failed posts hide it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ve a URL

A post can end in ERROR after the platform accepted it (a thread comment failed after the parent was published, a follow-up mutation was rejected): its releaseURL is kept, so let the user reach the live post instead of re-publishing it.
…e opener

Reddit, Lemmy and Farcaster join several release URLs with commas, so the open-post icon opened one mangled URL; open the first target instead. Also pass noopener,noreferrer so the cross-origin page cannot reach the dashboard tab.
The open-post icon now asks the backend for the post's current release URL
on every click instead of opening the URL loaded with the calendar.

New GET /posts/:id/release-url calls PostsService.getReleaseURL, which runs
the provider's optional resolveReleaseId through resolveRelease (the same
generic step checkPostAnalytics uses), persists a newly resolved id and URL,
and returns the URL. Providers without a resolver return the stored URL with
no platform call.

This covers platforms that only expose the public post URL some time after
the publish completes (TikTok stores the profile URL until then): the icon
opens the real post as soon as the platform has it, and the stored URL is
corrected at the same time.

Also tightens the icon gap in the calendar item header from 10px to 6px so
the extra icon fits narrow week-view items.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nevo David (nevo-david) and others added 27 commits October 8, 2026 12:01
…view and Duplicate

Complements gitroomhq#2204: the hover icons now end with Preview, Duplicate, Delete,
so Duplicate sits between Preview and Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(calendar): put Delete right-most in the post hover bar, after Preview and Duplicate
…cial connect

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ct-auth-failure

fix(integrations): log the error behind "Authentication failed" on social connect
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cancelling a subscription that includes team members disables the
non-owner members (userOrganization.disabled = true) without removing
them. The delete-account guard counted those disabled rows too, so an
owner with a cancelled plan (stuck on the billing page, unable to reach
the team settings to remove anyone) could never delete their account.

getTeam now selects the member's disabled flag and the guard only
blocks on active members other than the owner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…gnore-disabled-members

fix(account): ignore disabled team members when deleting an account
…-session-observability

fix(integrations): reconnecting a channel wiped its stored refresh token
Billing page redesign (plan cards + invoice history), settings
reorganisation (Plugs / Integrations / Apps / Authentication tabs, old
/plugs and /third-party routes redirect), new post creation modal with
auto RTL text direction, calendar channel filter / published frame /
open-post link / curated error tooltip / batch schedules, channel
rename (customName), tag editing, media list in the public API and MCP
(mediaListTool), superadmin AI-credit reset endpoint. Fixes: Stripe
duplicate subscriptions and cancel toggle, channels re-enabled on plan
upgrade, refresh-channel limit check, reconnect keeps the new refresh
token, Google Workspace invalid_rapt message, Instagram trial reel limit.
Infra: one email workflow per email on rate-limited email / email-bulk
task queues (auth emails sent synchronously), autopost / missing-post
continue-as-new behind patched(), streakWorkflowV2.

Conflicts resolved:
- CLAUDE.md: union (upstream's webhook no-throw rule appended)
- package.json: keep the fork's `pnpm exec prisma` scripts, take
  upstream's removal of --accept-data-loss from prisma-db-push
- billing.controller: keep the DOS shared billing branch of /cancel
  (feedback email once dos.me accepts the cancel); Stripe path is
  upstream's "email only when cancel_at is set"
- main.billing.component: upstream's redesign, with the DOS mode
  gating re-applied (monthly-only Plus/Pro cards, yearly toggle hidden,
  portal/cancel box behind showPortalAndCancel, invoice history hidden
  because dos.me owns the invoices, 3-column grid for the 3 DOS plans,
  upstream's cancel-toggle toasts skipped since a dos.me cancel never
  toggles)
- load.tools.service: keep the fork's agentId + tool aliases, add
  upstream's coreToolNames()
- email.service: union imports
- plugs / third-party pages: upstream redirects (the pages moved into
  Settings, so the fork's branded metadata no longer renders)

Schema: new nullable Integration.customName, Post.batchId and
Post_batchId_idx. Additive, needs the DDL on beta and prod before the
new code serves traffic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ
…message

The branding guard blocked upstream's new uploadFromUrlTool /
uploadWidgetTool descriptions: they send agents to docs.postiz.com and
to the upstream CLI (`postiz upload`), which defaults to the upstream
cloud, so following the hint would push a user's private file to the
wrong instance. They now point at this deployment's public API
(POST /public/v1/upload) and brand.docsUrl.

YouTube's new invalid_rapt refresh message asks a Google Workspace admin
to trust the app by name, so it uses the deployment brand instead of
"Postiz".

docs/fork-delta.md records these and the billing divergences carried
through the v2.25.1 sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ
The ESLint code-scanning gate failed on 23 react-hooks errors in lines
the upstream merge brought in. Fixed without changing behaviour:

- useCallback deps completed where the compiler inferred more than the
  array listed (date picker changeTime, channel rename save, channel
  filter, customer selector, composer date picker, team invite submit)
- teams AddMember declares `t` before the callback that uses it
- the composer toolbar gets the TipTap editor from state, reported by a
  new optional `onEditor` prop on OnlyEditor, instead of reading
  editorRef while rendering; inlineImagesRef is updated in an effect
- the editor rebuild on an extension change and the calendar channel
  filter reset on a customer switch adjust state while rendering
  instead of calling setState synchronously in an effect (no render
  with the stale value any more)

Recorded in docs/fork-delta.md as a candidate to send upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ
…t fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ
CodeQL flagged js/tainted-format-string (high) on the log upstream
v2.25.1 added to the social connect error path: the `integration` route
parameter was interpolated into console.log's format string while the
error was passed as an extra argument, so a crafted path segment with
%-placeholders could reshape the log line. The parameter is now a plain
argument, the same fix the fork already carries in clipping.service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ
The ESLint code-scanning bot left 12 warnings on lines the upstream
merge brought in. All cleared, none left on changed lines:

- missing hook dependencies added (channel rename / reset, post-checkout
  channel notice, inline-images sync, media delete / pick / library);
  each listed value is stable or a prop, and the effects involved are
  guarded, so nothing re-runs in a loop
- manage.modal memoises existingPosts and channel, so the composer's
  delete and save callbacks can list them (and customClose, fetch,
  modal, mutate, t, toaster) instead of closing over the first render
- the channel filter avatar and the YouTube badge use the repo's
  ImageWithFallback / SafeImage instead of a bare <img> (the badge also
  gets its alt text)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ
The CodeQL gate's high alert was js/double-escaping, not the log line:
upstream v2.25.1 reformatted the html branch of stripHtmlValidation
(inline images), which made the long-standing "&amp; decoded before
&quot; / &nbsp; / &#39;" order count as new code. A post containing the
literal text &quot; (stored as &amp;quot;) came out as a bare quote.
&amp; is now decoded last, so literal entities survive and the normal
ones are still decoded.

A local CodeQL run (javascript-code-scanning suite, base vs head)
confirmed this was the only alert starting on a changed line, so the
console.log change from 813f4d5 is reverted to upstream's line - it
was never flagged and only added fork delta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ
merge: upstream v2.25.1 (135 commits - billing redesign, settings tabs, creation modal, email queues)

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several enhancements and bug fixes across the application, focusing on billing history, integration management, mobile responsiveness, and Temporal workflow optimizations. Key updates include a new billing history component, bottom sheets for mobile settings, custom channel renaming, and a new media list tool. However, several critical issues were identified in the review: a casing mismatch in the billing controller that prevents cancellation emails, potential runtime TypeErrors due to unsafe property access on potentially empty arrays or undefined URLs, a context loss issue in the drag-and-drop provider, and a potential server-side rendering crash when accessing window.matchMedia directly.

return (await this.provider(org)).setToCancel(org.id);
const result = await (await this.provider(org)).setToCancel(org.id);

if (result.cancel_at) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The setToCancel method in StripeService returns an object with cancelAt (camelCase), but here the code checks result.cancel_at (snake_case). This mismatch causes the condition to always evaluate to false, preventing the cancellation email notification from being sent.

Suggested change
if (result.cancel_at) {
if (result.cancelAt) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. StripeService.setToCancel returns { id, cancel_at } in snake_case on all three paths: un-cancel, immediate cancel after a failed payment, and cancel at period end (stripe.service.ts). So result.cancel_at is the right key and the email is sent whenever a cancel is set. Switching to cancelAt would stop the email.


Generated by Claude Code

Comment on lines +37 to +41
backend[name] = (event: DragEvent) => {
if (!isInsideEditor(event)) {
handler(event);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the drag handlers in HTML5Backend are prototype methods (which is standard for class methods in JS/TS), calling handler(event) directly as a plain function will invoke it with this set to undefined. This will cause a runtime TypeError when the handler tries to access internal properties of the backend. To preserve the correct context, use handler.call(backend, event) instead.

Suggested change
backend[name] = (event: DragEvent) => {
if (!isInsideEditor(event)) {
handler(event);
}
};
backend[name] = (event: DragEvent) => {
if (!isInsideEditor(event)) {
handler.call(backend, event);
}
};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. In the react-dnd-html5-backend version installed here, HTML5BackendImpl assigns these handlers as instance arrow functions in its constructor (this.handleTopDragStart = (e)=>{…}, dist/HTML5BackendImpl.js:323). They are not prototype methods, so this is already bound lexically and calling handler(event) keeps the backend context. The backend itself registers them the same way, via addEventListener('dragstart', this.handleTopDragStart).


Generated by Claude Code

Comment on lines +62 to +65
export const mediaLibrarySize = () =>
window.matchMedia('(max-width: 1025px)').matches
? '100%'
: 'calc(100% - 80px)';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Accessing window.matchMedia directly during server-side rendering (SSR) will throw a ReferenceError: window is not defined and crash the page. Ensure window is defined before accessing it.

Suggested change
export const mediaLibrarySize = () =>
window.matchMedia('(max-width: 1025px)').matches
? '100%'
: 'calc(100% - 80px)';
export const mediaLibrarySize = () =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 1025px)').matches
? '100%'
: 'calc(100% - 80px)';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. mediaLibrarySize() is never called during render. Every caller runs it inside a click handler or an effect that opens the media modal, and all of them are in 'use client' components:

  • showModal / openMedia callbacks in media.component.tsx and developer.component.tsx
  • the show-modal emitter listener registered in a useEffect

So it only runs in the browser and never during SSR.


Generated by Claude Code

Comment on lines +386 to +388
// multi-target posts (several subreddits / communities / channels)
// join their URLs with commas: open the first one
tab.location.href = (release.releaseURL || post.releaseURL).split(',')[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If both release.releaseURL and post.releaseURL are null or undefined, calling .split(',') on them will throw a TypeError. Ensure the URL is defined before splitting it.

      const targetURL = release.releaseURL || post.releaseURL;
      if (targetURL) {
        tab.location.href = targetURL.split(',')[0];
      } else {
        tab.close();
        toaster.show(
          t('post_has_no_public_link', 'This post has no public link'),
          'warning'
        );
      }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. The open-post icon that calls openPost only renders when post.releaseURL?.startsWith('http') (calendar.tsx, around line 1329). So post.releaseURL is always a non-empty string when this runs, and (release.releaseURL || post.releaseURL) can't be null or undefined. The cases with no release URL return earlier with the "no public link", "reconnect" and "pending" toasts.


Generated by Claude Code

(f) => !f.users[0].disabled
);
return (await this._orgService.getOrgsByUserId(user.id))
.filter((f) => !f.users[0].disabled)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing f.users[0].disabled directly is unsafe if the users array is empty, which will throw a TypeError. Use optional chaining to safely access the property.

Suggested change
.filter((f) => !f.users[0].disabled)
.filter((f) => !f.users?.[0]?.disabled)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is, and it was already answered on #69. getOrgsByUserId only returns orgs where users: { some: { userId } } matches, and its include filters users to that same userId. So every returned org has that row at users[0]. This filter also predates the sync.


Generated by Claude Code

Comment on lines +219 to +222
setChannelDate(
sibling.integration,
dayjs.utc(sibling.posts[0].publishDate).local()
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing sibling.posts[0].publishDate directly is unsafe if sibling.posts is empty, which will throw a TypeError. Use optional chaining or a guard to safely access the property.

          if (sibling.posts?.[0]) {
            setChannelDate(
              sibling.integration,
              dayjs.utc(sibling.posts[0].publishDate).local()
            );
          }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. siblings is built in PostsService from the batch groups, and those groups are filtered with batchPosts.length && … before being mapped through groupForEditor. So every sibling has at least one post, and sibling.posts[0] is always defined.


Generated by Claude Code

// a copied picture also comes as html linking to the site it was copied
// from, so the file wins unless the html has text of its own
// @ts-ignore
const html = event.clipboardData.getData('text/html');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If event is a File[] array rather than a ClipboardEvent, event.clipboardData will be undefined, and calling getData on it will throw a TypeError. Use optional chaining to safely access clipboardData.

Suggested change
const html = event.clipboardData.getData('text/html');
const html = event?.clipboardData?.getData?.('text/html');

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. When paste gets a File[], event.clipboardData is undefined, so clipboardItems = event.clipboardData?.items is undefined and the handler returns false a few lines above. The getData('text/html') call is only reached when clipboardData exists.


Generated by Claude Code

@JOY
JOY (JOY) merged commit 5308eb1 into main Oct 12, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants