From 751ce66c4654e787c33f77dfbd6e67fed54ff632 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 3 Aug 2026 09:42:58 +0700 Subject: [PATCH 01/99] feat(tags): expose tag editing in the tags dropdown 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 --- .../components/launches/tags.component.tsx | 55 ++++++++++++++++++- .../src/components/ui/icons/index.tsx | 25 +++++++++ i18n.lock | 1 + .../translation/locales/ar/translation.json | 1 + .../translation/locales/bn/translation.json | 1 + .../translation/locales/de/translation.json | 1 + .../translation/locales/en/translation.json | 1 + .../translation/locales/es/translation.json | 1 + .../translation/locales/fr/translation.json | 1 + .../translation/locales/he/translation.json | 1 + .../translation/locales/it/translation.json | 1 + .../translation/locales/ja/translation.json | 1 + .../translation/locales/ko/translation.json | 1 + .../translation/locales/pt/translation.json | 1 + .../translation/locales/ru/translation.json | 1 + .../translation/locales/tr/translation.json | 1 + .../translation/locales/vi/translation.json | 1 + .../translation/locales/zh/translation.json | 1 + 18 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/launches/tags.component.tsx b/apps/frontend/src/components/launches/tags.component.tsx index 3d08f00365..073cbc5bb2 100644 --- a/apps/frontend/src/components/launches/tags.component.tsx +++ b/apps/frontend/src/components/launches/tags.component.tsx @@ -17,6 +17,7 @@ import { DropdownArrowIcon, PlusIcon, CheckmarkIcon, + PencilIcon, } from '@gitroom/frontend/components/ui/icons'; export const TagsComponent: FC<{ @@ -105,6 +106,52 @@ export const TagsComponentInner: FC<{ } }, []); + const editTag = useCallback( + async (tag: any, e: React.MouseEvent) => { + setAllowClose(false); + e.stopPropagation(); + const val: string | undefined = await new Promise((resolve) => { + modals.openModal({ + title: t('edit_tag', 'Edit Tag'), + onClose: () => resolve(undefined), + children: (close) => ( + + ), + }); + }); + + const newValues = await mutate(); + + if (val) { + const updated = newValues.tags.find((p: any) => p.id === tag.id); + if (updated && tagValue.find((a) => a.id === tag.id)) { + const modify = tagValue.map((a) => (a.id === tag.id ? updated : a)); + setTagValue(modify); + onChange({ + target: { + value: modify.map((p: any) => ({ + label: p.name, + value: p.name, + })), + name, + }, + }); + } + } + + setTimeout(() => { + setAllowClose(true); + }, 500); + }, + [tagValue, name, onChange, mutate, modals, t] + ); + const deleteTag = useCallback( async (tag: any, e: React.MouseEvent) => { setAllowClose(false); @@ -231,10 +278,16 @@ export const TagsComponentInner: FC<{ {p.name} +
editTag(p, e)} + className="ms-auto me-[12px] transition-opacity cursor-pointer opacity-60 hover:opacity-100" + > + +
{!tagValue.find((a) => a.id === p.id) && (
deleteTag(p, e)} - className="ms-auto transition-opacity cursor-pointer text-red-500 text-[14px] font-[600]" + className="transition-opacity cursor-pointer text-red-500 text-[14px] font-[600]" > ×
diff --git a/apps/frontend/src/components/ui/icons/index.tsx b/apps/frontend/src/components/ui/icons/index.tsx index 5991386a00..84a256ac22 100644 --- a/apps/frontend/src/components/ui/icons/index.tsx +++ b/apps/frontend/src/components/ui/icons/index.tsx @@ -717,6 +717,31 @@ export const InsertMediaIcon: FC = ({ ); +// Pencil Icon (edit) +export const PencilIcon: FC = ({ + size = 16, + className, + ...props +}) => ( + + + +); + // Design Media Icon (pencil/edit) export const DesignMediaIcon: FC = ({ size = 16, diff --git a/i18n.lock b/i18n.lock index e0aa6b38b5..6dd8d3cce9 100644 --- a/i18n.lock +++ b/i18n.lock @@ -674,6 +674,7 @@ checksums: two_weeks: 2535083efa302c3dde7d06cce2455b74 repeat_post_every_label: f3e3fd4d95a0eeaf1eab0219278ae812 add_new_tag: 911360b396ef6db7a24eb53134f0f7d2 + edit_tag: 5a8b41a0364539c5d7c243f9d3695e9b tag_name: 9368b5a047572b6051f334af5aa76819 post_is_too_long: 7784c6ecca2c7e58665e83e219aff80b your_post_should_have_at_least_one_character_or_one_image: e7a0d63d2931ca540ceea25c6d4df7ec diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 7af68fa08f..d6f91a0a39 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -670,6 +670,7 @@ "two_weeks": "أسبوعان", "repeat_post_every_label": "تكرار النشر كل", "add_new_tag": "إضافة وسم جديد", + "edit_tag": "تعديل الوسم", "tag_name": "الاسم", "post_is_too_long": "المنشور طويل جدًا، يرجى تعديله", "your_post_should_have_at_least_one_character_or_one_image": "يجب أن يحتوي منشورك على حرف واحد على الأقل أو صورة واحدة.", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 28888f9e31..e87750545d 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -670,6 +670,7 @@ "two_weeks": "দুই সপ্তাহ", "repeat_post_every_label": "প্রতি কতদিন পর পোস্ট পুনরাবৃত্তি হবে", "add_new_tag": "নতুন ট্যাগ যোগ করুন", + "edit_tag": "ট্যাগ সম্পাদনা করুন", "tag_name": "নাম", "post_is_too_long": "পোস্টটি খুব বড়, অনুগ্রহ করে ঠিক করুন", "your_post_should_have_at_least_one_character_or_one_image": "আপনার পোস্টে অন্তত একটি অক্ষর বা একটি ছবি থাকতে হবে।", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 8f6711ff52..2a97b06796 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -670,6 +670,7 @@ "two_weeks": "Zwei Wochen", "repeat_post_every_label": "Beitrag wiederholen alle", "add_new_tag": "Neues Schlagwort hinzufügen", + "edit_tag": "Tag bearbeiten", "tag_name": "Name", "post_is_too_long": "Beitrag ist zu lang, bitte korrigieren", "your_post_should_have_at_least_one_character_or_one_image": "Dein Beitrag sollte mindestens ein Zeichen oder ein Bild enthalten.", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 6699a7f292..218a49ce59 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -672,6 +672,7 @@ "two_weeks": "Two Weeks", "repeat_post_every_label": "Repeat Post Every", "add_new_tag": "Add New Tag", + "edit_tag": "Edit Tag", "tag_name": "Name", "post_is_too_long": "post is too long, please fix it", "your_post_should_have_at_least_one_character_or_one_image": "Your post should have at least one character or one image.", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 061a03a7c9..fbeba0354f 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -670,6 +670,7 @@ "two_weeks": "Dos semanas", "repeat_post_every_label": "Repetir publicación cada", "add_new_tag": "Agregar nueva etiqueta", + "edit_tag": "Editar etiqueta", "tag_name": "Nombre", "post_is_too_long": "la publicación es demasiado larga, por favor arréglala", "your_post_should_have_at_least_one_character_or_one_image": "Tu publicación debe tener al menos un carácter o una imagen.", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index cb1e046c88..14f57b72f9 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -670,6 +670,7 @@ "two_weeks": "Deux semaines", "repeat_post_every_label": "Répéter la publication tous les", "add_new_tag": "Ajouter un nouveau tag", + "edit_tag": "Modifier le tag", "tag_name": "Nom", "post_is_too_long": "le message est trop long, veuillez le corriger", "your_post_should_have_at_least_one_character_or_one_image": "Votre publication doit contenir au moins un caractère ou une image.", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index 360c0d2a88..da499d0de6 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -670,6 +670,7 @@ "two_weeks": "שבועיים", "repeat_post_every_label": "חזור על הפוסט כל", "add_new_tag": "הוסף תגית חדשה", + "edit_tag": "ערוך תגית", "tag_name": "שם", "post_is_too_long": "הפוסט ארוך מדי, אנא תקן אותו", "your_post_should_have_at_least_one_character_or_one_image": "הפוסט שלך צריך לכלול לפחות תו אחד או תמונה אחת.", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 334ebf0202..40edfee6e9 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -670,6 +670,7 @@ "two_weeks": "Due settimane", "repeat_post_every_label": "Ripeti post ogni", "add_new_tag": "Aggiungi nuovo tag", + "edit_tag": "Modifica etichetta", "tag_name": "Nome", "post_is_too_long": "il post è troppo lungo, per favore correggilo", "your_post_should_have_at_least_one_character_or_one_image": "Il tuo post deve contenere almeno un carattere o un'immagine.", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 7ea2930462..00ca73b2c0 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -670,6 +670,7 @@ "two_weeks": "2週間", "repeat_post_every_label": "投稿を繰り返す間隔", "add_new_tag": "新しいタグを追加", + "edit_tag": "タグを編集", "tag_name": "名前", "post_is_too_long": "投稿が長すぎます。修正してください", "your_post_should_have_at_least_one_character_or_one_image": "投稿には少なくとも1文字または1枚の画像が必要です。", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index 83501c1b80..c22e5e7714 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -670,6 +670,7 @@ "two_weeks": "2주", "repeat_post_every_label": "게시 반복 주기", "add_new_tag": "새 태그 추가", + "edit_tag": "태그 편집", "tag_name": "이름", "post_is_too_long": "게시글이 너무 깁니다. 수정해 주세요.", "your_post_should_have_at_least_one_character_or_one_image": "게시물에는 최소한 한 글자 또는 한 이미지를 포함해야 합니다.", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index ac6ad3a7e2..7cafa2beed 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -670,6 +670,7 @@ "two_weeks": "Duas semanas", "repeat_post_every_label": "Repetir postagem a cada", "add_new_tag": "Adicionar nova tag", + "edit_tag": "Editar etiqueta", "tag_name": "Nome", "post_is_too_long": "a postagem está muito longa, por favor corrija", "your_post_should_have_at_least_one_character_or_one_image": "Sua postagem deve ter pelo menos um caractere ou uma imagem.", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 076e47fc95..91b815477b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -670,6 +670,7 @@ "two_weeks": "Две недели", "repeat_post_every_label": "Повторять публикацию каждые", "add_new_tag": "Добавить новый тег", + "edit_tag": "Редактировать тег", "tag_name": "Название", "post_is_too_long": "пост слишком длинный, пожалуйста, исправьте его", "your_post_should_have_at_least_one_character_or_one_image": "Ваша публикация должна содержать хотя бы один символ или одно изображение.", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index 9931117cb0..c6f6f0969c 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -670,6 +670,7 @@ "two_weeks": "İki Hafta", "repeat_post_every_label": "Gönderiyi Her Tekrarla", "add_new_tag": "Yeni Etiket Ekle", + "edit_tag": "Etiketi Düzenle", "tag_name": "Adı", "post_is_too_long": "gönderi çok uzun, lütfen düzeltin", "your_post_should_have_at_least_one_character_or_one_image": "Gönderinizde en az bir karakter veya bir görsel olmalıdır.", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 3be38fafe5..8ef2c84d87 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -670,6 +670,7 @@ "two_weeks": "Hai tuần", "repeat_post_every_label": "Lặp lại bài đăng mỗi", "add_new_tag": "Thêm thẻ mới", + "edit_tag": "Chỉnh sửa thẻ", "tag_name": "Tên", "post_is_too_long": "bài đăng quá dài, vui lòng chỉnh sửa", "your_post_should_have_at_least_one_character_or_one_image": "Bài đăng của bạn phải có ít nhất một ký tự hoặc một hình ảnh.", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 5fb1f954d0..b96fe1c904 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -670,6 +670,7 @@ "two_weeks": "两周", "repeat_post_every_label": "每隔...重复发布", "add_new_tag": "添加新标签", + "edit_tag": "编辑标签", "tag_name": "名称", "post_is_too_long": "帖子太长,请修改", "your_post_should_have_at_least_one_character_or_one_image": "您的帖子至少应包含一个字符或一张图片。", From d9bae16b287178810a87a67d0dbf080f59ef33f5 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 3 Aug 2026 09:43:51 +0700 Subject: [PATCH 02/99] fix(tags): readable calendar tag labels on any tag color 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 --- apps/frontend/src/components/launches/calendar.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 8bb1c633af..6c930dcca3 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -1069,7 +1069,7 @@ const CalendarItem: FC<{ >
@@ -1079,7 +1079,7 @@ const CalendarItem: FC<{
@@ -1089,7 +1089,7 @@ const CalendarItem: FC<{
@@ -1098,7 +1098,7 @@ const CalendarItem: FC<{
@@ -1110,7 +1110,7 @@ const CalendarItem: FC<{
@@ -1120,7 +1120,7 @@ const CalendarItem: FC<{
@@ -1132,7 +1132,7 @@ const CalendarItem: FC<{
From 1f7e1b6b40ebd07b3a8a9c86741caee8391235eb Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 4 Aug 2026 09:16:16 +0700 Subject: [PATCH 03/99] fix(tags): always restore dropdown closability after edit/delete modals 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 --- .../components/launches/tags.component.tsx | 145 +++++++++--------- 1 file changed, 74 insertions(+), 71 deletions(-) diff --git a/apps/frontend/src/components/launches/tags.component.tsx b/apps/frontend/src/components/launches/tags.component.tsx index 073cbc5bb2..c306186b4c 100644 --- a/apps/frontend/src/components/launches/tags.component.tsx +++ b/apps/frontend/src/components/launches/tags.component.tsx @@ -110,44 +110,48 @@ export const TagsComponentInner: FC<{ async (tag: any, e: React.MouseEvent) => { setAllowClose(false); e.stopPropagation(); - const val: string | undefined = await new Promise((resolve) => { - modals.openModal({ - title: t('edit_tag', 'Edit Tag'), - onClose: () => resolve(undefined), - children: (close) => ( - - ), + try { + const val: string | undefined = await new Promise((resolve) => { + modals.openModal({ + title: t('edit_tag', 'Edit Tag'), + onClose: () => resolve(undefined), + children: (close) => ( + + ), + }); }); - }); - const newValues = await mutate(); + const newValues = await mutate(); - if (val) { - const updated = newValues.tags.find((p: any) => p.id === tag.id); - if (updated && tagValue.find((a) => a.id === tag.id)) { - const modify = tagValue.map((a) => (a.id === tag.id ? updated : a)); - setTagValue(modify); - onChange({ - target: { - value: modify.map((p: any) => ({ - label: p.name, - value: p.name, - })), - name, - }, - }); + if (val) { + const updated = newValues.tags.find((p: any) => p.id === tag.id); + if (updated && tagValue.find((a) => a.id === tag.id)) { + const modify = tagValue.map((a) => + a.id === tag.id ? updated : a + ); + setTagValue(modify); + onChange({ + target: { + value: modify.map((p: any) => ({ + label: p.name, + value: p.name, + })), + name, + }, + }); + } } + } finally { + setTimeout(() => { + setAllowClose(true); + }, 500); } - - setTimeout(() => { - setAllowClose(true); - }, 500); }, [tagValue, name, onChange, mutate, modals, t] ); @@ -156,50 +160,49 @@ export const TagsComponentInner: FC<{ async (tag: any, e: React.MouseEvent) => { setAllowClose(false); e.stopPropagation(); - const confirmed: boolean = await new Promise((resolve) => { - modals.openModal({ - title: t('delete_tag', 'Delete Tag'), - children: (close) => ( - - ), + try { + const confirmed: boolean = await new Promise((resolve) => { + modals.openModal({ + title: t('delete_tag', 'Delete Tag'), + children: (close) => ( + + ), + }); }); - }); - if (!confirmed) { - setTimeout(() => { - setAllowClose(true); - }, 500); - return; - } - - await fetch(`/posts/tags/${tag.id}`, { - method: 'DELETE', - }); + if (!confirmed) { + return; + } - // Remove the tag from current selection if it was selected - const modify = tagValue.filter((a) => a.id !== tag.id); - if (modify.length !== tagValue.length) { - setTagValue(modify); - onChange({ - target: { - value: modify.map((p: any) => ({ - label: p.name, - value: p.name, - })), - name, - }, + await fetch(`/posts/tags/${tag.id}`, { + method: 'DELETE', }); - } - await mutate(); + // Remove the tag from current selection if it was selected + const modify = tagValue.filter((a) => a.id !== tag.id); + if (modify.length !== tagValue.length) { + setTagValue(modify); + onChange({ + target: { + value: modify.map((p: any) => ({ + label: p.name, + value: p.name, + })), + name, + }, + }); + } - setTimeout(() => { - setAllowClose(true); - }, 500); + await mutate(); + } finally { + setTimeout(() => { + setAllowClose(true); + }, 500); + } }, [tagValue, name, onChange, mutate, fetch, modals, t] ); From 49aa92ac273033c689f39d6f117d18cd1091e235 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 12 Aug 2026 19:12:50 +0700 Subject: [PATCH 04/99] fix(integrations): keep the new refresh token when reconnecting a channel 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 --- .../routes/no.auth.integrations.controller.ts | 9 +++- .../src/activities/post.activity.ts | 10 ++++- .../integrations/integration.repository.ts | 3 +- .../integrations/integration.service.ts | 6 +-- .../refresh.integration.service.ts | 41 +++++++++++++------ .../social/social.integrations.interface.ts | 1 + .../integrations/social/youtube.provider.ts | 26 ++++++++++-- 7 files changed, 73 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index 3ac67cb541..e88615860d 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -136,7 +136,14 @@ export class NoAuthIntegrationsController { refresh, auth.accessToken ); - return res({ ...newAuth, refreshToken: body.refresh }); + // keep the fresh refresh token and expiration from the new + // authentication, otherwise the stored refresh token is wiped and + // the channel breaks again as soon as the access token expires + return res({ + ...newAuth, + refreshToken: auth.refreshToken, + expiresIn: auth.expiresIn, + }); } catch (err: any) { return res({ error: err.message, diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index cd7eefd821..6011d65184 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -460,7 +460,10 @@ export class PostActivity { return refresh; } catch (err) { - await this._refreshIntegrationService.setBetweenSteps(integration); + await this._refreshIntegrationService.setBetweenSteps( + integration, + (err as Error)?.message || '' + ); return false; } } @@ -489,7 +492,10 @@ export class PostActivity { return refresh; } catch (err) { - await this._refreshIntegrationService.setBetweenSteps(integration, cause); + await this._refreshIntegrationService.setBetweenSteps( + integration, + cause || (err as Error)?.message || '' + ); return false; } } diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index 15434d6c83..73406e22d6 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -284,7 +284,8 @@ export class IntegrationRepository { profile: username, providerIdentifier: provider, token, - refreshToken, + // never wipe a stored refresh token with an empty one + ...(refreshToken ? { refreshToken } : {}), ...(expiresIn ? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) } : {}), diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index a129641bf8..4f38d921ba 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -183,9 +183,9 @@ export class IntegrationService { } } - async disconnectChannel(orgId: string, integration: Integration) { + async disconnectChannel(orgId: string, integration: Integration, err = '') { await this._integrationRepository.disconnectChannel(orgId, integration.id); - await this.informAboutRefreshError(orgId, integration); + await this.informAboutRefreshError(orgId, integration, err); } async informAboutRefreshError( @@ -195,7 +195,7 @@ export class IntegrationService { ) { await this._notificationService.inAppNotification( orgId, - `Could not refresh your ${integration.providerIdentifier} channel ${err}`, + `Could not refresh your ${integration.providerIdentifier} channel`, `Could not refresh your ${integration.providerIdentifier} channel ${err}. Please go back to the system and connect it again ${process.env.FRONTEND_URL}/launches`, true, false, diff --git a/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts b/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts index 0689bdc603..8fabd0b5c9 100644 --- a/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts +++ b/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts @@ -73,25 +73,31 @@ export class RefreshIntegrationService { socialProvider: SocialProvider, cause = '' ): Promise { + let refreshError: any = undefined; const refresh: false | AuthTokenDetails = await socialProvider .refreshToken(integration.refreshToken) - .catch((err) => false); + .catch((err) => { + refreshError = err; + return false as const; + }); if (!refresh || !refresh.accessToken) { + if (refreshError) { + console.error( + `Could not refresh ${integration.providerIdentifier} integration ${integration.id}`, + refreshError?.response?.data || refreshError?.message || refreshError + ); + } + await this._integrationService.refreshNeeded( integration.organizationId, integration.id ); - await this._integrationService.informAboutRefreshError( - integration.organizationId, - integration, - cause - ); - await this._integrationService.disconnectChannel( integration.organizationId, - integration + integration, + socialProvider.refreshErrorMessage?.(refreshError) || cause ); return false; @@ -104,11 +110,20 @@ export class RefreshIntegrationService { return refresh; } - const reConnect = await socialProvider.reConnect( - integration.rootInternalId, - integration.internalId, - refresh.accessToken - ); + let reConnect; + try { + reConnect = await socialProvider.reConnect( + integration.rootInternalId, + integration.internalId, + refresh.accessToken + ); + } catch (err: any) { + console.error( + `Could not reconnect ${integration.providerIdentifier} integration ${integration.id} after a successful token refresh`, + err?.response?.data || err?.message || err + ); + throw err; + } return { ...refresh, diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index fb9db3cf71..15178fe3d9 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -167,6 +167,7 @@ export interface SocialProvider convertToJPEG?: boolean; stripLinks?: () => boolean; refreshCron?: boolean; + refreshErrorMessage?(err: any): string | undefined; dto?: any; maxLength: (additionalSettings?: any, settings?: any) => number; checkValidity( diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 1e6f3852a3..d9f3f8fa13 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -259,6 +259,19 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { return undefined; } + refreshErrorMessage(err: any): string | undefined { + const data = err?.response?.data || {}; + const text = `${data.error_subtype || ''} ${data.error_description || ''} ${ + err?.message || '' + }`; + + if (text.includes('invalid_rapt')) { + return `because your organization's Google Workspace session policy invalidated its access. Ask your Google Workspace admin to mark Postiz as a trusted app and turn on "Exempt trusted apps" (https://support.google.com/a/answer/9368756) to prevent this from happening again`; + } + + return undefined; + } + async refreshToken(refresh_token: string): Promise { const { client, oauth2 } = clientAndYoutube(); client.setCredentials({ refresh_token }); @@ -394,10 +407,17 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { requiredId: string, accessToken: string ): Promise> { - const pages = await this.pages(accessToken); - const findPage = pages.find((p) => p.id === requiredId); + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + + // don't go through pages() here, it swallows the real Google error and an + // empty list would be reported as a missing channel + const { data } = await youtube(client).channels.list({ + part: ['id'], + mine: true, + }); - if (!findPage) { + if (!(data.items || []).some((p) => p.id === requiredId)) { throw new Error('Channel not found'); } From 5cd4de2f4d11613072ab13ef68d45816e155f46e Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 19 Aug 2026 18:34:29 +0700 Subject: [PATCH 05/99] feat: list media via public API and MCP mediaListTool 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 --- .../v1/public.integrations.controller.ts | 10 +++ .../src/chat/load.tools.service.ts | 1 + .../src/chat/tools/media.list.tool.ts | 77 +++++++++++++++++++ .../src/chat/tools/tool.list.ts | 2 + .../database/prisma/media/media.repository.ts | 3 + 5 files changed, 93 insertions(+) create mode 100644 libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 94cb46881b..82b59a6c13 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -289,6 +289,16 @@ export class PublicIntegrationsController { return { connected: true }; } + @Get('/media') + getMedia( + @GetOrgFromRequest() org: Organization, + @Query('page') page: number, + @Query('search') search?: string + ) { + Sentry.metrics.count('public_api-request', 1); + return this._mediaService.getMedia(org.id, page, search); + } + @Get('/groups') async listGroups(@GetOrgFromRequest() org: Organization) { Sentry.metrics.count('public_api-request', 1); diff --git a/libraries/nestjs-libraries/src/chat/load.tools.service.ts b/libraries/nestjs-libraries/src/chat/load.tools.service.ts index a0194b6245..4f085a3350 100644 --- a/libraries/nestjs-libraries/src/chat/load.tools.service.ts +++ b/libraries/nestjs-libraries/src/chat/load.tools.service.ts @@ -78,6 +78,7 @@ export class LoadToolsService { - In every message I will send you the list of needed social medias (id and platform), if you already have the information use it, if not, use the integrationSchema tool to get it. - Make sure you always take the last information I give you about the socials, it might have changed. - Before scheduling a post, always make sure you ask the user confirmation by providing all the details of the post (text, images, videos, date, time, social media platform, account). + - To reuse media that was already uploaded, use mediaListTool (search by filename) and pass the returned path as the attachment, instead of asking the user for a URL or uploading it again. - To find or inspect existing posts, use postsListTool with a UTC start and end date - it returns every post scheduled in that window. To cover "all my upcoming posts", pass a wide window starting now. - To change the provider settings of an existing post that was not published yet (scheduled or draft), first find it with postsListTool, then use postSettingsTool with the post's id. It only updates the settings - the content and the publish date stay as they are - and only the keys you pass are changed (get them with the integrationSchema tool). Show the user which post and which settings will change and get their confirmation first. - Never open the "modal with populated content" to edit an existing post - that modal only CREATES a new post, so using it to edit would duplicate the post. It is only for brand new posts. diff --git a/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts new file mode 100644 index 0000000000..34501cc5b9 --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts @@ -0,0 +1,77 @@ +import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface'; +import { createTool } from '@mastra/core/tools'; +import { Injectable } from '@nestjs/common'; +import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; +import z from 'zod'; +import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; + +@Injectable() +export class MediaListTool implements AgentToolInterface { + constructor(private _mediaService: MediaService) {} + name = 'mediaListTool'; + + run() { + return createTool({ + id: 'mediaListTool', + description: `List the media (images and videos) already uploaded to the media library, newest first, 18 per page, optionally searched by original filename. +Use the returned path as an attachment URL in integrationSchedulePostTool instead of uploading the file again.`, + inputSchema: z.object({ + search: z + .string() + .optional() + .describe('Filter by original filename (case-insensitive contains)'), + page: z.number().optional().describe('Page number, starting at 1'), + }), + mcp: { + annotations: { + title: 'List Media', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + outputSchema: z.object({ + pages: z.number(), + output: z.array( + z.object({ + id: z.string(), + name: z.string(), + originalName: z.string().nullable(), + path: z.string(), + type: z.string(), + fileSize: z.number(), + createdAt: z.string(), + thumbnail: z.string().nullable(), + }) + ), + }), + execute: async (inputData, context) => { + checkAuth(inputData, context); + const organizationId = JSON.parse( + (context?.requestContext as any)?.get('organization') as string + ).id; + + const { pages, results } = await this._mediaService.getMedia( + organizationId, + inputData.page || 1, + inputData.search + ); + + return { + pages, + output: results.map((p) => ({ + id: p.id, + name: p.name, + originalName: p.originalName, + path: p.path, + type: p.type, + fileSize: p.fileSize, + createdAt: p.createdAt.toISOString(), + thumbnail: p.thumbnail, + })), + }; + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts index ef386474f2..0586efb164 100644 --- a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts +++ b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts @@ -8,6 +8,7 @@ import { GenerateImageTool } from '@gitroom/nestjs-libraries/chat/tools/generate import { IntegrationListTool } from '@gitroom/nestjs-libraries/chat/tools/integration.list.tool'; import { GroupListTool } from '@gitroom/nestjs-libraries/chat/tools/group.list.tool'; import { UploadFromUrlTool } from '@gitroom/nestjs-libraries/chat/tools/upload.from.url.tool'; +import { MediaListTool } from '@gitroom/nestjs-libraries/chat/tools/media.list.tool'; import { PostsListTool } from '@gitroom/nestjs-libraries/chat/tools/posts.list.tool'; import { PostSettingsTool } from '@gitroom/nestjs-libraries/chat/tools/post.settings.tool'; @@ -24,4 +25,5 @@ export const toolList = [ GenerateVideoTool, GenerateImageTool, UploadFromUrlTool, + MediaListTool, ]; diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts index 208def20f6..354e988fc5 100644 --- a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts @@ -110,6 +110,9 @@ export class MediaRepository { thumbnail: true, alt: true, thumbnailTimestamp: true, + type: true, + fileSize: true, + createdAt: true, }, skip: pageNum * 18, take: 18, From 063ee50954fda0195ae9860df6b44e4788a70e0e Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 19 Aug 2026 22:43:57 +0700 Subject: [PATCH 06/99] fix: validate page query on GET /public/v1/media 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 --- .../routes/v1/public.integrations.controller.ts | 6 +++--- .../src/dtos/media/get.media.dto.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 libraries/nestjs-libraries/src/dtos/media/get.media.dto.ts diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 82b59a6c13..12deef65a3 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -35,6 +35,7 @@ import { import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto'; import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.function.dto'; import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto'; +import { GetMediaDto } from '@gitroom/nestjs-libraries/dtos/media/get.media.dto'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto'; import { Readable } from 'stream'; @@ -292,11 +293,10 @@ export class PublicIntegrationsController { @Get('/media') getMedia( @GetOrgFromRequest() org: Organization, - @Query('page') page: number, - @Query('search') search?: string + @Query() query: GetMediaDto ) { Sentry.metrics.count('public_api-request', 1); - return this._mediaService.getMedia(org.id, page, search); + return this._mediaService.getMedia(org.id, query.page, query.search); } @Get('/groups') diff --git a/libraries/nestjs-libraries/src/dtos/media/get.media.dto.ts b/libraries/nestjs-libraries/src/dtos/media/get.media.dto.ts new file mode 100644 index 0000000000..6b20a30b70 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/media/get.media.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsNumber, IsString, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class GetMediaDto { + @IsOptional() + @IsNumber() + @Min(1) + @Transform(({ value }) => parseInt(value, 10)) + page?: number = 1; + + @IsOptional() + @IsString() + search?: string; +} From 28e71678b038cfd5744e8e33d8c74ec2876b9615 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 10 Sep 2026 13:48:09 +0700 Subject: [PATCH 07/99] fix: guard against duplicate Stripe subscriptions per customer 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. --- .../billing/first.billing.component.tsx | 9 +- .../src/services/stripe.service.ts | 128 ++++++++++++++---- 2 files changed, 107 insertions(+), 30 deletions(-) diff --git a/apps/frontend/src/components/billing/first.billing.component.tsx b/apps/frontend/src/components/billing/first.billing.component.tsx index ee8d8b1657..7d845c3dd0 100644 --- a/apps/frontend/src/components/billing/first.billing.component.tsx +++ b/apps/frontend/src/components/billing/first.billing.component.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import useSWR from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { loadStripe, Stripe } from '@stripe/stripe-js'; @@ -54,6 +54,7 @@ export const FirstBillingComponent = () => { const [tier, setTier] = useState('STANDARD'); const [period, setPeriod] = useState('MONTHLY'); const fetch = useFetch(); + const { mutate } = useSWRConfig(); const modals = useModals(); const t = useT(); const [datafast_visitor_id] = useCookie('datafast_visitor_id', ''); @@ -106,6 +107,12 @@ export const FirstBillingComponent = () => { } ); + useEffect(() => { + if (data?.blocked) { + mutate('/user/self'); + } + }, [data?.blocked, mutate]); + const price = useMemo( () => Object.entries(pricing).filter(([key, value]) => key !== 'FREE'), [] diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index e465ce3d3a..88066623e0 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -150,6 +150,26 @@ export class StripeService extends PaymentProviderAbstract { uniqueId: string; }; + const liveSubscriptions = await this.getLiveSubscriptions( + event.data.object.customer as string + ); + const isDuplicate = liveSubscriptions.some( + (f) => + f.id !== event.data.object.id && + (f.created < event.data.object.created || + (f.created === event.data.object.created && + f.id < event.data.object.id)) + ); + if (isDuplicate) { + await stripe.subscriptions.cancel(event.data.object.id); + logger.info('stripe_duplicate_subscription_cancelled', { + stripe_event_type: event.type, + stripe_event_id: event.id, + stripe_subscription_id: event.data.object.id, + }); + return { ok: true }; + } + try { const check = await this.checkValidCard(event); if (!check) { @@ -195,9 +215,41 @@ export class StripeService extends PaymentProviderAbstract { } async deleteSubscription(event: Stripe.CustomerSubscriptionDeletedEvent) { - await this._subscriptionService.deleteSubscription( - event.data.object.customer as string, - STRIPE_PROVIDER + const customer = event.data.object.customer as string; + const [survivor] = await this.getLiveSubscriptions(customer); + if (!survivor) { + await this._subscriptionService.deleteSubscription( + customer, + STRIPE_PROVIDER + ); + return; + } + + const { uniqueId, billing, period } = survivor.metadata as { + billing: 'STANDARD' | 'PRO'; + period: 'MONTHLY' | 'YEARLY'; + uniqueId: string; + }; + logger.info('stripe_subscription_deleted_survivor_kept', { + stripe_event_type: event.type, + stripe_event_id: event.id, + stripe_subscription_id: event.data.object.id, + stripe_survivor_id: survivor.id, + outcome: pricing[billing] && period ? 'resynced' : 'metadata_missing', + }); + if (!pricing[billing] || !period) { + return; + } + + return this._subscriptionService.createOrUpdateSubscription( + STRIPE_PROVIDER, + survivor.status !== 'active', + uniqueId, + customer, + pricing[billing].channel!, + billing, + period, + survivor.cancel_at ); } @@ -373,28 +425,38 @@ export class StripeService extends PaymentProviderAbstract { }); } + private async getLiveSubscriptions(customer?: string | null) { + if (!customer || !customer.startsWith('cus_')) { + return []; + } + + return ( + await stripe.subscriptions.list({ + customer, + status: 'all', + expand: ['data.latest_invoice'], + }) + ).data.filter((f) => f.status !== 'canceled'); + } + async setToCancel(organizationId: string) { const id = makeId(10); const org = await this._organizationService.getOrgById(organizationId); const customer = await this.createOrGetCustomer(org!); - const currentUserSubscription = { - data: ( - await stripe.subscriptions.list({ - customer, - status: 'all', - expand: ['data.latest_invoice'], - }) - ).data.filter((f) => f.status !== 'canceled'), - }; + const subscriptions = await this.getLiveSubscriptions(customer); - const sub = currentUserSubscription.data[0]; + const sub = subscriptions[0]; // If the user is toggling back (un-cancelling), just remove the cancel if (sub.cancel_at_period_end) { - const { cancel_at } = await stripe.subscriptions.update(sub.id, { - cancel_at_period_end: false, - metadata: { service: 'gitroom', id }, - }); + let cancel_at: number | null = null; + for (const s of subscriptions) { + const updated = await stripe.subscriptions.update(s.id, { + cancel_at_period_end: false, + metadata: { service: 'gitroom', id }, + }); + cancel_at = cancel_at || updated.cancel_at; + } return { id, @@ -411,7 +473,9 @@ export class StripeService extends PaymentProviderAbstract { if (hasFailedPayment) { // Payment already failed — cancel immediately and delete subscription - await stripe.subscriptions.cancel(sub.id); + for (const s of subscriptions) { + await stripe.subscriptions.cancel(s.id); + } await this._subscriptionService.deleteSubscription( customer, STRIPE_PROVIDER @@ -424,10 +488,16 @@ export class StripeService extends PaymentProviderAbstract { } // Payment succeeded — cancel at end of billing period - const { cancel_at } = await stripe.subscriptions.update(sub.id, { - cancel_at_period_end: true, - metadata: { service: 'gitroom', id }, - }); + let cancel_at: number | null = null; + for (const s of subscriptions) { + const updated = await stripe.subscriptions.update(s.id, { + cancel_at_period_end: true, + metadata: { service: 'gitroom', id }, + }); + if (updated.cancel_at && (!cancel_at || updated.cancel_at < cancel_at)) { + cancel_at = updated.cancel_at; + } + } return { id, @@ -777,6 +847,9 @@ export class StripeService extends PaymentProviderAbstract { const priceData = pricing[body.billing]; const org = await this._organizationService.getOrgById(organizationId); const customer = await this.createOrGetCustomer(org!); + if ((await this.getLiveSubscriptions(customer)).length) { + return { blocked: true }; + } const allProducts = await stripe.products.list({ active: true, expand: ['data.prices'], @@ -1042,18 +1115,15 @@ export class StripeService extends PaymentProviderAbstract { const customer = org.paymentId; - const subscriptions = ( - await stripe.subscriptions.list({ - customer, - status: 'all', - }) - ).data.filter((f) => f.status !== 'canceled'); + const subscriptions = await this.getLiveSubscriptions(customer); if (!subscriptions.length) { throw new Error('No active subscription found'); } - await stripe.subscriptions.cancel(subscriptions[0].id); + for (const subscription of subscriptions) { + await stripe.subscriptions.cancel(subscription.id); + } await this._subscriptionService.deleteSubscription( customer, STRIPE_PROVIDER From c3f39b97fe7e6f2cd4e0b25ac3fc6fbe9f4a25b8 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 10 Sep 2026 14:09:43 +0700 Subject: [PATCH 08/99] fix: use console.log for the new stripe guard log lines --- libraries/nestjs-libraries/src/services/stripe.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 88066623e0..34e04354e4 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -162,7 +162,7 @@ export class StripeService extends PaymentProviderAbstract { ); if (isDuplicate) { await stripe.subscriptions.cancel(event.data.object.id); - logger.info('stripe_duplicate_subscription_cancelled', { + console.log('stripe_duplicate_subscription_cancelled', { stripe_event_type: event.type, stripe_event_id: event.id, stripe_subscription_id: event.data.object.id, @@ -230,7 +230,7 @@ export class StripeService extends PaymentProviderAbstract { period: 'MONTHLY' | 'YEARLY'; uniqueId: string; }; - logger.info('stripe_subscription_deleted_survivor_kept', { + console.log('stripe_subscription_deleted_survivor_kept', { stripe_event_type: event.type, stripe_event_id: event.id, stripe_subscription_id: event.data.object.id, From 76d6616e042f8e50ccbc4debe75e50e0b9e1a651 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 10 Sep 2026 14:17:04 +0700 Subject: [PATCH 09/99] fix: reject in-app cancel when the org has no live Stripe subscription --- libraries/nestjs-libraries/src/services/stripe.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 34e04354e4..14aea7c300 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -1,5 +1,5 @@ import Stripe from 'stripe'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { Organization, User } from '@prisma/client'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; @@ -447,6 +447,10 @@ export class StripeService extends PaymentProviderAbstract { const sub = subscriptions[0]; + if (!sub) { + throw new BadRequestException('No active subscription found'); + } + // If the user is toggling back (un-cancelling), just remove the cancel if (sub.cancel_at_period_end) { let cancel_at: number | null = null; From 5fb2f5368e60a9f42f61707e5ab7516936a1ea82 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 10 Sep 2026 14:26:26 +0700 Subject: [PATCH 10/99] chore: drop unused Injectable import from stripe.service --- libraries/nestjs-libraries/src/services/stripe.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 14aea7c300..46d401620b 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -1,5 +1,5 @@ import Stripe from 'stripe'; -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import { Organization, User } from '@prisma/client'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; From 328b34c594a7d5a66b265588c8f451e1900200f6 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 21 Sep 2026 17:44:26 +0700 Subject: [PATCH 11/99] fix(billing): persist cancelAt on cancel/reactivate instead of waiting 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 --- .../src/api/routes/billing.controller.ts | 18 +++++++++++------- .../billing/main.billing.component.tsx | 13 ++++++++++--- .../subscriptions/subscription.repository.ts | 12 ++++++++++++ .../subscriptions/subscription.service.ts | 7 +++++++ .../src/services/stripe.service.ts | 2 ++ 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index 49fe1a3c92..e1777e50f2 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -169,14 +169,18 @@ export class BillingController { @GetUserFromRequest() user: User, @Body() body: { feedback: string } ) { - await this._notificationService.sendEmail( - process.env.EMAIL_FROM_ADDRESS, - 'Subscription Cancelled', - `Organization ${org.name} has cancelled their subscription because: ${body.feedback}`, - user.email - ); + const result = await (await this.provider(org)).setToCancel(org.id); + + if (result.cancel_at) { + await this._notificationService.sendEmail( + process.env.EMAIL_FROM_ADDRESS, + 'Subscription Cancelled', + `Organization ${org.name} has cancelled their subscription because: ${body.feedback}`, + user.email + ); + } - return (await this.provider(org)).setToCancel(org.id); + return result; } @Post('/prorate') diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index ff75f66dba..c17d652025 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -294,7 +294,11 @@ export const MainBillingComponent: FC<{ cancelAt: cancel_at, })); - toast.show('Subscription reactivated successfully'); + toast.show( + cancel_at + ? 'Your subscription was already active, so it is now set to cancel. Click Reactivate subscription again to keep it.' + : 'Subscription reactivated successfully' + ); setLoading(false); return; } @@ -370,8 +374,11 @@ export const MainBillingComponent: FC<{ ...subs!, cancelAt: cancel_at, })); - if (cancel_at) - toast.show('Subscription set to canceled successfully'); + toast.show( + cancel_at + ? 'Subscription set to canceled successfully' + : 'Your subscription was already set to cancel, so it has been reactivated. Click Cancel subscription again to cancel it.' + ); setLoading(false); } return; diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts index 1071b7fc50..48b59b1fea 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -119,6 +119,18 @@ export class SubscriptionRepository { }); } + updateCancelAt(organizationId: string, cancelAt: number | null) { + return this._subscription.model.subscription.updateMany({ + where: { + organizationId, + deletedAt: null, + }, + data: { + cancelAt: cancelAt ? new Date(cancelAt * 1000) : null, + }, + }); + } + async getSubscriptionByOrgId(orgId: string) { return this._subscription.model.subscription.findFirst({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index aaf2ed471d..b8b09c2070 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -84,6 +84,13 @@ export class SubscriptionService { ); } + updateCancelAt(organizationId: string, cancelAt: number | null) { + return this._subscriptionRepository.updateCancelAt( + organizationId, + cancelAt + ); + } + async checkSubscription(organizationId: string, subscriptionId: string) { return await this._subscriptionRepository.checkSubscription( organizationId, diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index e465ce3d3a..d79e78673f 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -395,6 +395,7 @@ export class StripeService extends PaymentProviderAbstract { cancel_at_period_end: false, metadata: { service: 'gitroom', id }, }); + await this._subscriptionService.updateCancelAt(organizationId, cancel_at); return { id, @@ -428,6 +429,7 @@ export class StripeService extends PaymentProviderAbstract { cancel_at_period_end: true, metadata: { service: 'gitroom', id }, }); + await this._subscriptionService.updateCancelAt(organizationId, cancel_at); return { id, From 291b07b4b7c74efd1f0a690cc5f085eb4fa11608 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 28 Jul 2026 14:30:15 +0700 Subject: [PATCH 12/99] fix(calendar): surface curated provider errors in the calendar tooltip 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 --- .../src/components/launches/calendar.tsx | 18 ++++++++++++- .../database/prisma/posts/posts.repository.ts | 26 ++++++++++++++++--- .../src/integrations/social.abstract.ts | 10 ++++--- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 0f3ae954dd..fe01e2b855 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -59,6 +59,21 @@ import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validatio import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; import { Button } from '@gitroom/react/form/button'; +// Curated provider errors are stored as {"message":"..."} — anything else in +// post.error (internal strings like 'Refresh channel needed', serialized +// failures from before curation existed) falls back to the generic tooltip +const displayErrorMessage = (error?: string | null) => { + if (!error) { + return null; + } + try { + const parsed = JSON.parse(error); + return typeof parsed?.message === 'string' ? parsed.message : null; + } catch (e) { + return null; + } +}; + // Extend dayjs with necessary plugins extend(isSameOrAfter); extend(isSameOrBefore); @@ -1059,7 +1074,8 @@ const CalendarItem: FC<{
!
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 1aa9e1538f..38ed9bc83c 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -172,6 +172,7 @@ export class PostsRepository { releaseURL: true, releaseId: true, state: true, + error: true, intervalInDays: true, group: true, creationMethod: true, @@ -411,15 +412,34 @@ export class PostsRepository { } async changeState(id: string, state: State, err?: any, body?: any) { + // The calendar tooltip only surfaces curated provider messages: bad_body + // failures whose message came from a handleErrors mapping or a + // hand-written provider throw (unmapped API responses carry the + // 'Unknown Error' placeholder instead). Those are stored as + // {"message":"..."} so the frontend can tell them apart from everything + // this column held until now (internal strings like 'Refresh channel + // needed', serialized failures), which keeps rendering the generic + // tooltip. Internal strings are still stored for debugging, and the full + // failure is kept in the Errors table below. + const failureMessage = + err?.cause?.failure?.message || err?.failure?.cause?.message; + const errorMessage = !err + ? undefined + : typeof err === 'string' + ? err // stored for debugging, rendered as the generic tooltip + : err?.cause?.type === 'bad_body' && + failureMessage && + failureMessage !== 'Unknown Error' + ? JSON.stringify({ message: failureMessage }) + : undefined; + const update = await this._post.model.post.update({ where: { id, }, data: { state, - ...(err - ? { error: typeof err === 'string' ? err : JSON.stringify(err) } - : {}), + ...(errorMessage ? { error: errorMessage } : {}), }, include: { integration: { diff --git a/libraries/nestjs-libraries/src/integrations/social.abstract.ts b/libraries/nestjs-libraries/src/integrations/social.abstract.ts index 93c1ec2ce2..af74d345f4 100644 --- a/libraries/nestjs-libraries/src/integrations/social.abstract.ts +++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts @@ -183,11 +183,13 @@ export abstract class SocialAbstract { pendingData: any, integration: Integration ): Promise { + // 'Unknown Error' keeps this developer guard out of the user-facing + // post.error (changeState only surfaces curated bad_body messages) throw new BadBody( this.identifier, + '{"error":"checkPostStatus is not implemented for this provider"}', '{}', - '{}', - 'checkPostStatus is not implemented for this provider' + 'Unknown Error' ); } @@ -199,9 +201,9 @@ export abstract class SocialAbstract { ): Promise { throw new BadBody( this.identifier, + '{"error":"finalizePost is not implemented for this provider"}', '{}', - '{}', - 'finalizePost is not implemented for this provider' + 'Unknown Error' ); } From e333f75bd4cf41920554e376f634983a17113d8b Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 11 Aug 2026 14:41:19 +0700 Subject: [PATCH 13/99] fix(calendar): clear the stored error when a post fails again uncuratedly 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) --- .../src/database/prisma/posts/posts.repository.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 38ed9bc83c..bce687e072 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -439,7 +439,10 @@ export class PostsRepository { }, data: { state, - ...(errorMessage ? { error: errorMessage } : {}), + // Always overwrite on a new failure - keeping the previous value would + // show a stale (and now wrong) message for a post that failed again + // for a different reason. + ...(err ? { error: errorMessage ?? null } : {}), }, include: { integration: { From c8bf9d8f4259c7ee3e1db29c39444099da90c505 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 28 Aug 2026 13:42:11 +0700 Subject: [PATCH 14/99] fix(calendar): curate post errors at read time instead of write time 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. --- .../src/components/launches/calendar.tsx | 17 +----- .../database/prisma/posts/posts.repository.ts | 58 ++++++++++--------- 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index fe01e2b855..2616c08fdc 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -59,21 +59,6 @@ import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validatio import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; import { Button } from '@gitroom/react/form/button'; -// Curated provider errors are stored as {"message":"..."} — anything else in -// post.error (internal strings like 'Refresh channel needed', serialized -// failures from before curation existed) falls back to the generic tooltip -const displayErrorMessage = (error?: string | null) => { - if (!error) { - return null; - } - try { - const parsed = JSON.parse(error); - return typeof parsed?.message === 'string' ? parsed.message : null; - } catch (e) { - return null; - } -}; - // Extend dayjs with necessary plugins extend(isSameOrAfter); extend(isSameOrBefore); @@ -1075,7 +1060,7 @@ const CalendarItem: FC<{ className="absolute -top-[6px] -left-[6px] z-20 w-[18px] h-[18px] rounded-full bg-red-500 flex items-center justify-center text-white text-[11px] font-bold cursor-pointer" data-tooltip-id="tooltip" data-tooltip-class-name="!max-w-[400px] break-words" - data-tooltip-content={displayErrorMessage(post.error) || 'An error occurred while publishing this post'} + data-tooltip-content={post.error || 'An error occurred while publishing this post'} > !
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index bce687e072..17223e6eb2 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -126,6 +126,32 @@ export class PostsRepository { }); } + // Post.error persists the raw serialized workflow failure; only curated + // provider messages are surfaced to the calendar and the public API — + // 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 instead). Internal debug strings (e.g. + // 'Refresh channel needed') and unmapped failures come out as null. + private curatedError(error?: string | null): string | null { + if (!error) { + return null; + } + try { + const parsed = JSON.parse(error); + const [type, message] = parsed?.cause?.failure?.message + ? [parsed?.cause?.type, parsed?.cause?.failure?.message] + : [ + parsed?.failure?.cause?.applicationFailureInfo?.type, + parsed?.failure?.cause?.message, + ]; + return type === 'bad_body' && message && message !== 'Unknown Error' + ? message + : null; + } catch (err) { + return null; + } + } + async getPosts(orgId: string, query: GetPostsDto) { // Use the provided start and end dates directly const startDate = dayjs.utc(query.startDate).toDate(); @@ -193,7 +219,9 @@ export class PostsRepository { }, }); - return list.reduce((all, post) => { + return list + .map((post) => ({ ...post, error: this.curatedError(post.error) })) + .reduce((all, post) => { if (!post.intervalInDays) { return [...all, post]; } @@ -412,37 +440,15 @@ export class PostsRepository { } async changeState(id: string, state: State, err?: any, body?: any) { - // The calendar tooltip only surfaces curated provider messages: bad_body - // failures whose message came from a handleErrors mapping or a - // hand-written provider throw (unmapped API responses carry the - // 'Unknown Error' placeholder instead). Those are stored as - // {"message":"..."} so the frontend can tell them apart from everything - // this column held until now (internal strings like 'Refresh channel - // needed', serialized failures), which keeps rendering the generic - // tooltip. Internal strings are still stored for debugging, and the full - // failure is kept in the Errors table below. - const failureMessage = - err?.cause?.failure?.message || err?.failure?.cause?.message; - const errorMessage = !err - ? undefined - : typeof err === 'string' - ? err // stored for debugging, rendered as the generic tooltip - : err?.cause?.type === 'bad_body' && - failureMessage && - failureMessage !== 'Unknown Error' - ? JSON.stringify({ message: failureMessage }) - : undefined; - const update = await this._post.model.post.update({ where: { id, }, data: { state, - // Always overwrite on a new failure - keeping the previous value would - // show a stale (and now wrong) message for a post that failed again - // for a different reason. - ...(err ? { error: errorMessage ?? null } : {}), + ...(err + ? { error: typeof err === 'string' ? err : JSON.stringify(err) } + : {}), }, include: { integration: { From 8202a150a128f1f7a519bb5b9af55cbee00b3a9f Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 22 Sep 2026 13:49:06 +0700 Subject: [PATCH 15/99] feat(mcp): expose the curated post error in the posts list tool 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 Claude-Session: https://claude.ai/code/session_01XvHgEEangtx46CmTGCrnL1 --- .../nestjs-libraries/src/chat/tools/posts.list.tool.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts index a22ed040fa..2dc3644d18 100644 --- a/libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts @@ -38,7 +38,7 @@ export class PostsListTool implements AgentToolInterface { List the organization's posts scheduled to be published between two dates (the same data as the "List Posts" API endpoint). Returns every post in the window whatever its state (scheduled, draft, published, errored). "startDate" and "endDate" are required (UTC) - to list all upcoming posts, pass a wide window (for example from now to a year ahead). -Each item has an "id", its publish date, state, content, channel and current provider settings. +Each item has an "id", its publish date, state, error (for errored posts), content, channel and current provider settings. Posts cannot be deleted through the Postiz tools - if the user wants to delete a post, tell them to do it themselves in the Postiz app; never offer to delete a post. `, inputSchema: z.object({ @@ -62,6 +62,12 @@ Posts cannot be deleted through the Postiz tools - if the user wants to delete a .describe('The post id'), publishDate: z.string().describe('UTC time'), state: z.string().describe('QUEUE, DRAFT, PUBLISHED or ERROR'), + error: z + .string() + .nullable() + .describe( + 'Curated failure message for errored posts, null otherwise' + ), content: z.string(), settings: z .any() @@ -94,6 +100,7 @@ Posts cannot be deleted through the Postiz tools - if the user wants to delete a .utc() .format('YYYY-MM-DDTHH:mm:ss'), state: p.state, + error: p.error ?? null, content: p.content || '', settings: parseSettings(p.settings), group: p.group, From 4a6ba07f88e5206d953f7a7cf05a4984ec078e38 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 28 Aug 2026 08:51:46 +0700 Subject: [PATCH 16/99] feat(subscription): re-enable disabled channels when the new plan can 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 Claude-Session: https://claude.ai/code/session_01KYTuLLhYQsLCdgxBFEXpG9 --- .../integrations/integration.repository.ts | 24 +++++++++++ .../integrations/integration.service.ts | 4 ++ .../subscriptions/subscription.service.ts | 40 +++++++++++++++---- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index 37274a6f37..48a33277e4 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -842,6 +842,30 @@ export class IntegrationRepository { } } + async enableAllIntegrations(org: string) { + const getChannels = await this._integration.model.integration.findMany({ + where: { + organizationId: org, + disabled: true, + deletedAt: null, + }, + select: { + id: true, + }, + }); + + for (const channel of getChannels) { + await this._integration.model.integration.update({ + where: { + id: channel.id, + }, + data: { + disabled: false, + }, + }); + } + } + getPlugsByIntegrationId(org: string, id: string) { return this._plugs.model.plugs.findMany({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 6a226355b2..0034503099 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -394,6 +394,10 @@ export class IntegrationService { return this._integrationRepository.disableIntegrations(org, totalChannels); } + async enableAllIntegrations(org: string) { + return this._integrationRepository.enableAllIntegrations(org); + } + async checkForDeletedOnceAndUpdate(org: string, page: string) { return this._integrationRepository.checkForDeletedOnceAndUpdate(org, page); } diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index 9c727f71ff..e7e749e04f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -108,9 +108,10 @@ export class SubscriptionService { const from = pricing[getCurrentSubscription?.subscriptionTier || 'FREE']; const to = pricing[billing]; - const currentTotalChannels = ( - await this._integrationService.getIntegrationsList(organizationId) - ).filter((f) => !f.disabled); + const allChannels = await this._integrationService.getIntegrationsList( + organizationId + ); + const currentTotalChannels = allChannels.filter((f) => !f.disabled); if (currentTotalChannels.length > totalChannels) { await this._integrationService.disableIntegrations( @@ -119,6 +120,17 @@ export class SubscriptionService { ); } + // channels are auto-disabled when a subscription lapses; once the new plan + // can hold all of them, enable everything back (partial enabling would risk + // enabling channels the user disabled on purpose) + if ( + billing !== 'FREE' && + currentTotalChannels.length < allChannels.length && + allChannels.length <= totalChannels + ) { + await this._integrationService.enableAllIntegrations(organizationId); + } + if (from.team_members && !to.team_members) { await this._organizationService.disableOrEnableNonSuperAdminUsers( organizationId, @@ -169,11 +181,10 @@ export class SubscriptionService { const from = pricing[getCurrentSubscription?.subscriptionTier || 'FREE']; const to = pricing[billing]; - const currentTotalChannels = ( - await this._integrationService.getIntegrationsList( - getOrgByCustomerId?.id! - ) - ).filter((f) => !f.disabled); + const allChannels = await this._integrationService.getIntegrationsList( + getOrgByCustomerId?.id! + ); + const currentTotalChannels = allChannels.filter((f) => !f.disabled); if (currentTotalChannels.length > totalChannels) { await this._integrationService.disableIntegrations( @@ -182,6 +193,19 @@ export class SubscriptionService { ); } + // channels are auto-disabled when a subscription lapses; once the new plan + // can hold all of them, enable everything back (partial enabling would risk + // enabling channels the user disabled on purpose) + if ( + billing !== 'FREE' && + currentTotalChannels.length < allChannels.length && + allChannels.length <= totalChannels + ) { + await this._integrationService.enableAllIntegrations( + getOrgByCustomerId?.id! + ); + } + if (from.team_members && !to.team_members) { await this._organizationService.disableOrEnableNonSuperAdminUsers( getOrgByCustomerId?.id!, From c145f0c3b663d750b8f82569b2cbc752cb49a6ca Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 28 Sep 2026 13:10:55 +0700 Subject: [PATCH 17/99] feat(subscription): only re-enable channels when the limit grows, tell 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 --- .../launches/launches.component.tsx | 2 + .../src/components/layout/check.payment.tsx | 50 ++++++++++++++++++- .../subscriptions/subscription.service.ts | 16 +++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index bb8d9ce6d7..be3fde1b80 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -29,6 +29,7 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useIntegrationList } from '@gitroom/frontend/components/launches/helpers/use.integration.list'; import useCookie from 'react-use-cookie'; import { Onboarding } from '@gitroom/frontend/components/onboarding/onboarding'; +import { ChannelsAfterPayment } from '@gitroom/frontend/components/layout/check.payment'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; export const SVGLine = () => { @@ -523,6 +524,7 @@ export const LaunchesComponent = () => { return ( +
void; @@ -76,3 +78,49 @@ export const CheckPaymentInner: FC<{ } return props.children; }; + +export const ChannelsAfterPayment: FC<{ + integrations: { disabled: boolean }[]; +}> = ({ integrations }) => { + const check = useSearchParams().get('check'); + const fetch = useFetch(); + const modal = useDecisionModal(); + const t = useT(); + const shown = useRef(false); + + useEffect(() => { + if (!check || shown.current || !integrations?.length) { + return; + } + shown.current = true; + (async () => { + const { status } = await (await fetch('/billing/check/' + check)).json(); + if (status !== 2) { + return; + } + if (integrations.some((p) => p.disabled)) { + modal.open({ + title: t('channels_still_disabled', 'Your channels are disabled'), + onlyApprove: true, + approveLabel: t('ok', 'OK'), + description: t( + 'channels_still_disabled_description', + "Your plan doesn't cover all of your connected channels, so they stay disabled. Enable the ones you want from each channel's menu, up to your plan's limit, or upgrade your plan to enable all of them." + ), + }); + return; + } + modal.open({ + title: t('channels_enabled_again', 'Your channels are enabled'), + onlyApprove: true, + approveLabel: t('ok', 'OK'), + description: t( + 'channels_enabled_again_description', + 'All of your connected channels were enabled again and are ready to publish.' + ), + }); + })(); + }, [check, integrations]); + + return null; +}; diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index e7e749e04f..f66cbbe25b 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -121,12 +121,14 @@ export class SubscriptionService { } // channels are auto-disabled when a subscription lapses; once the new plan - // can hold all of them, enable everything back (partial enabling would risk - // enabling channels the user disabled on purpose) + // can hold all of them and the limit grew, enable everything back (partial + // enabling or same-plan updates would risk enabling channels the user + // disabled on purpose) if ( billing !== 'FREE' && currentTotalChannels.length < allChannels.length && - allChannels.length <= totalChannels + allChannels.length <= totalChannels && + totalChannels > (getCurrentSubscription?.totalChannels || 0) ) { await this._integrationService.enableAllIntegrations(organizationId); } @@ -194,12 +196,14 @@ export class SubscriptionService { } // channels are auto-disabled when a subscription lapses; once the new plan - // can hold all of them, enable everything back (partial enabling would risk - // enabling channels the user disabled on purpose) + // can hold all of them and the limit grew, enable everything back (partial + // enabling or same-plan updates would risk enabling channels the user + // disabled on purpose) if ( billing !== 'FREE' && currentTotalChannels.length < allChannels.length && - allChannels.length <= totalChannels + allChannels.length <= totalChannels && + totalChannels > (getCurrentSubscription?.totalChannels || 0) ) { await this._integrationService.enableAllIntegrations( getOrgByCustomerId?.id! From 70911f86982177019ba1ffb9b77c35c61afab4b6 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 28 Sep 2026 13:10:55 +0700 Subject: [PATCH 18/99] chore(i18n): translate the post-checkout channel notices Co-Authored-By: Claude Opus 5.5 --- i18n.lock | 4 ++++ .../src/translation/locales/ar/translation.json | 6 +++++- .../src/translation/locales/bn/translation.json | 6 +++++- .../src/translation/locales/de/translation.json | 6 +++++- .../src/translation/locales/en/translation.json | 6 +++++- .../src/translation/locales/es/translation.json | 6 +++++- .../src/translation/locales/fr/translation.json | 6 +++++- .../src/translation/locales/he/translation.json | 6 +++++- .../src/translation/locales/it/translation.json | 6 +++++- .../src/translation/locales/ja/translation.json | 6 +++++- .../src/translation/locales/ko/translation.json | 6 +++++- .../src/translation/locales/pt/translation.json | 6 +++++- .../src/translation/locales/ru/translation.json | 6 +++++- .../src/translation/locales/tr/translation.json | 6 +++++- .../src/translation/locales/vi/translation.json | 6 +++++- .../src/translation/locales/zh/translation.json | 6 +++++- 16 files changed, 79 insertions(+), 15 deletions(-) diff --git a/i18n.lock b/i18n.lock index 6f5597c7db..0011d08c41 100644 --- a/i18n.lock +++ b/i18n.lock @@ -790,3 +790,7 @@ checksums: preview_comment_detach_hint: 6fa385ae985c409b511cf7adea2f950a preview_comment_text_changed: 6a4298cc0304d150e0d150689fa049f4 preview_no_comments_yet: e9bda0977d617e8ee0079feba73b3c3a + channels_still_disabled: 1a8b6447bbdfde8b7fd84fe5b87bec4a + channels_still_disabled_description: a869ee5b969817a13ee01e0079269477 + channels_enabled_again: 636241055a7d339ac16f1f4f7d72d194 + channels_enabled_again_description: 595e387b2a32d35cb2a3860b209a7bc6 diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 34a1030e1f..fd7c4e4c70 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "تعذر نشر التعليق", "preview_comment_detach_hint": "التعليقات على النص المحدد تفقد تمييزها إذا تم تعديل النص لاحقًا.", "preview_comment_text_changed": "تم تغيير النص", - "preview_no_comments_yet": "لا توجد تعليقات بعد. حدد بعض النص في المنشور للتعليق عليه، أو أضف تعليقًا عامًا." + "preview_no_comments_yet": "لا توجد تعليقات بعد. حدد بعض النص في المنشور للتعليق عليه، أو أضف تعليقًا عامًا.", + "channels_still_disabled": "قنواتك معطلة", + "channels_still_disabled_description": "خطتك لا تغطي جميع القنوات المتصلة لديك، لذلك تظل معطلة. فعّل القنوات المطلوبة من قائمة كل قناة حسب حد خطتك، أو قم بترقية خطتك لتفعيل جميع القنوات.", + "channels_enabled_again": "قنواتك مفعلة", + "channels_enabled_again_description": "تم تفعيل جميع القنوات المتصلة لديك مجددًا وهي جاهزة للنشر." } diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index d52209baa8..8e8a884136 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "মন্তব্যটি পোস্ট করা যায়নি", "preview_comment_detach_hint": "নির্বাচিত টেক্সটে মন্তব্য করলে, টেক্সট সম্পাদনার পর হাইলাইট হারিয়ে যেতে পারে।", "preview_comment_text_changed": "টেক্সট পরিবর্তিত হয়েছে", - "preview_no_comments_yet": "এখনও কোনো মন্তব্য নেই। পোস্টে কিছু টেক্সট নির্বাচন করে অথবা সাধারণ মন্তব্য যোগ করে মন্তব্য করুন।" + "preview_no_comments_yet": "এখনও কোনো মন্তব্য নেই। পোস্টে কিছু টেক্সট নির্বাচন করে অথবা সাধারণ মন্তব্য যোগ করে মন্তব্য করুন।", + "channels_still_disabled": "আপনার চ্যানেলগুলো নিষ্ক্রিয় রয়েছে", + "channels_still_disabled_description": "আপনার প্ল্যান আপনার সংযুক্ত সব চ্যানেল কভার করে না, তাই সেগুলো নিষ্ক্রিয় থাকে। প্রতিটি চ্যানেলের মেনু থেকে আপনার পছন্দের চ্যানেলগুলো সক্রিয় করুন, আপনার প্ল্যানের সীমার মধ্যে, অথবা সবগুলো চালু করতে আপনার প্ল্যান আপগ্রেড করুন।", + "channels_enabled_again": "আপনার চ্যানেলগুলো আবার সক্রিয় হয়েছে", + "channels_enabled_again_description": "আপনার সংযুক্ত সব চ্যানেল আবার চালু হয়েছে এবং প্রকাশের জন্য প্রস্তুত।" } diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index c9de6e6b41..70ab2d6a99 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "Kommentar konnte nicht veröffentlicht werden", "preview_comment_detach_hint": "Kommentare zu ausgewähltem Text verlieren ihre Markierung, wenn der Text später bearbeitet wird.", "preview_comment_text_changed": "Text geändert", - "preview_no_comments_yet": "Noch keine Kommentare. Wählen Sie einen Text im Beitrag aus, um ihn zu kommentieren, oder fügen Sie einen allgemeinen Kommentar hinzu." + "preview_no_comments_yet": "Noch keine Kommentare. Wählen Sie einen Text im Beitrag aus, um ihn zu kommentieren, oder fügen Sie einen allgemeinen Kommentar hinzu.", + "channels_still_disabled": "Ihre Kanäle sind deaktiviert", + "channels_still_disabled_description": "Ihr Tarif deckt nicht alle Ihre verbundenen Kanäle ab, daher bleiben einige deaktiviert. Aktivieren Sie die gewünschten Kanäle im jeweiligen Kanäle-Menü – bis zur Obergrenze Ihres Tarifs – oder upgraden Sie Ihren Tarif, um alle Kanäle freizuschalten.", + "channels_enabled_again": "Ihre Kanäle sind aktiviert", + "channels_enabled_again_description": "Alle Ihre verbundenen Kanäle wurden erneut aktiviert und sind bereit zur Veröffentlichung." } diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 290bc15df7..fba6eac3fc 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -787,5 +787,9 @@ "preview_comment_failed": "Could not post the comment", "preview_comment_detach_hint": "Comments on selected text lose their highlight if the text is edited later.", "preview_comment_text_changed": "Text changed", - "preview_no_comments_yet": "No comments yet. Select some text in the post to comment on it, or add a general comment." + "preview_no_comments_yet": "No comments yet. Select some text in the post to comment on it, or add a general comment.", + "channels_still_disabled": "Your channels are disabled", + "channels_still_disabled_description": "Your plan doesn't cover all of your connected channels, so they stay disabled. Enable the ones you want from each channel's menu, up to your plan's limit, or upgrade your plan to enable all of them.", + "channels_enabled_again": "Your channels are enabled", + "channels_enabled_again_description": "All of your connected channels were enabled again and are ready to publish." } diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 8645878588..b50d0dc5d4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "No se pudo publicar el comentario", "preview_comment_detach_hint": "Los comentarios sobre texto seleccionado pierden su resalte si el texto se edita más tarde.", "preview_comment_text_changed": "Texto modificado", - "preview_no_comments_yet": "Aún no hay comentarios. Selecciona texto en la publicación para comentarlo o agrega un comentario general." + "preview_no_comments_yet": "Aún no hay comentarios. Selecciona texto en la publicación para comentarlo o agrega un comentario general.", + "channels_still_disabled": "Tus canales están deshabilitados", + "channels_still_disabled_description": "Tu plan no cubre todos tus canales conectados, por lo que permanecen deshabilitados. Habilita los que desees desde el menú de cada canal, hasta el límite de tu plan, o mejora tu plan para habilitarlos todos.", + "channels_enabled_again": "Tus canales están habilitados nuevamente", + "channels_enabled_again_description": "Todos tus canales conectados se habilitaron nuevamente y están listos para publicar." } diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 23d93eca05..b2bdb7b2dd 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "Impossible de publier le commentaire", "preview_comment_detach_hint": "Les commentaires sur le texte sélectionné perdent leur surlignement si le texte est modifié par la suite.", "preview_comment_text_changed": "Texte modifié", - "preview_no_comments_yet": "Aucun commentaire pour le moment. Sélectionnez du texte dans la publication pour le commenter, ou ajoutez un commentaire général." + "preview_no_comments_yet": "Aucun commentaire pour le moment. Sélectionnez du texte dans la publication pour le commenter, ou ajoutez un commentaire général.", + "channels_still_disabled": "Vos canaux sont désactivés", + "channels_still_disabled_description": "Votre forfait ne couvre pas tous vos canaux connectés, ils restent donc désactivés. Activez ceux que vous souhaitez dans le menu de chaque canal, jusqu'à la limite de votre forfait, ou passez à un forfait supérieur pour tous les activer.", + "channels_enabled_again": "Vos canaux sont activés", + "channels_enabled_again_description": "Tous vos canaux connectés ont été réactivés et sont prêts à publier." } diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index 0ba46e14de..62750c1001 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "לא ניתן היה לפרסם את התגובה", "preview_comment_detach_hint": "תגובות על טקסט נבחר מאבדות הדגשה אם הטקסט נערך מאוחר יותר.", "preview_comment_text_changed": "הטקסט השתנה", - "preview_no_comments_yet": "עדיין אין תגובות. בחר טקסט בפוסט כדי להגיב עליו, או הוסף תגובה כללית." + "preview_no_comments_yet": "עדיין אין תגובות. בחר טקסט בפוסט כדי להגיב עליו, או הוסף תגובה כללית.", + "channels_still_disabled": "הערוצים שלך מושבתים", + "channels_still_disabled_description": "התכנית שלך לא מכסה את כל הערוצים המחוברים שלך, ולכן הם נשארים מושבתים. הפעל את הערוצים שתרצה מהתפריט של כל ערוץ, עד למגבלת התכנית שלך, או שדרג את התכנית כדי להפעיל את כולם.", + "channels_enabled_again": "הערוצים שלך הופעלו מחדש", + "channels_enabled_again_description": "כל הערוצים המחוברים שלך הופעלו מחדש והם מוכנים לפרסום." } diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 63f6eaf463..d14f0be91f 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "Impossibile pubblicare il commento", "preview_comment_detach_hint": "I commenti su testo selezionato perdono l'evidenziazione se il testo viene modificato successivamente.", "preview_comment_text_changed": "Testo modificato", - "preview_no_comments_yet": "Nessun commento ancora. Seleziona del testo nel post per commentarlo, oppure aggiungi un commento generale." + "preview_no_comments_yet": "Nessun commento ancora. Seleziona del testo nel post per commentarlo, oppure aggiungi un commento generale.", + "channels_still_disabled": "I tuoi canali sono disabilitati", + "channels_still_disabled_description": "Il tuo piano non copre tutti i canali collegati, quindi rimangono disabilitati. Abilita quelli che desideri dal menu di ciascun canale, fino al limite del tuo piano, oppure esegui l'upgrade del piano per abilitarli tutti.", + "channels_enabled_again": "I tuoi canali sono stati abilitati", + "channels_enabled_again_description": "Tutti i tuoi canali collegati sono stati nuovamente abilitati e sono pronti per la pubblicazione." } diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 870166c1c5..f45a24eecd 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "コメントの投稿に失敗しました", "preview_comment_detach_hint": "選択テキストへのコメントは、テキストが後から編集されるとハイライトが失われます。", "preview_comment_text_changed": "テキストが変更されました", - "preview_no_comments_yet": "まだコメントはありません。投稿内のテキストを選択してコメントするか、一般的なコメントを追加してください。" + "preview_no_comments_yet": "まだコメントはありません。投稿内のテキストを選択してコメントするか、一般的なコメントを追加してください。", + "channels_still_disabled": "チャンネルが無効になっています", + "channels_still_disabled_description": "ご利用中のプランでは、接続されているすべてのチャンネルをカバーしていないため、いくつかのチャンネルが無効のままです。各チャンネルのメニューから必要なものを有効にするか、またはプランをアップグレードしてすべてのチャンネルを有効にしてください(プランの上限まで有効化できます)。", + "channels_enabled_again": "チャンネルが再度有効になりました", + "channels_enabled_again_description": "接続されているすべてのチャンネルが再び有効になり、公開の準備ができました。" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index e3469e4bce..d8e5a68038 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "댓글을 게시할 수 없습니다.", "preview_comment_detach_hint": "선택한 텍스트에 단 댓글은, 텍스트가 나중에 수정되면 강조가 사라집니다.", "preview_comment_text_changed": "텍스트가 변경됨", - "preview_no_comments_yet": "아직 댓글이 없습니다. 게시물에서 텍스트를 선택해 댓글을 남기거나, 일반 댓글을 추가하세요." + "preview_no_comments_yet": "아직 댓글이 없습니다. 게시물에서 텍스트를 선택해 댓글을 남기거나, 일반 댓글을 추가하세요.", + "channels_still_disabled": "채널이 비활성화되어 있습니다", + "channels_still_disabled_description": "현재 요금제로 연결된 모든 채널을 사용할 수 없어 일부 채널이 비활성화된 상태입니다. 각 채널 메뉴에서 원하는 채널을 요금제 한도 내에서 직접 활성화하거나, 모든 채널을 사용하려면 요금제를 업그레이드하세요.", + "channels_enabled_again": "채널이 다시 활성화되었습니다", + "channels_enabled_again_description": "연결된 모든 채널이 다시 활성화되어 게시할 준비가 되었습니다." } diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 13e301c5ea..47038bbe2b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "Não foi possível publicar o comentário", "preview_comment_detach_hint": "Comentários em textos selecionados perdem o destaque se o texto for editado depois.", "preview_comment_text_changed": "Texto alterado", - "preview_no_comments_yet": "Ainda não há comentários. Selecione um trecho do post para comentar ou adicione um comentário geral." + "preview_no_comments_yet": "Ainda não há comentários. Selecione um trecho do post para comentar ou adicione um comentário geral.", + "channels_still_disabled": "Seus canais estão desativados", + "channels_still_disabled_description": "Seu plano não cobre todos os canais conectados, então eles permanecem desativados. Habilite os que deseja no menu de cada canal, até o limite do seu plano, ou faça um upgrade para habilitar todos.", + "channels_enabled_again": "Seus canais estão ativados", + "channels_enabled_again_description": "Todos os seus canais conectados foram ativados novamente e estão prontos para publicar." } diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index c3cb03dd79..bb234ea778 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "Не удалось опубликовать комментарий", "preview_comment_detach_hint": "Комментарии к выделенному тексту теряют выделение, если текст позже изменяется.", "preview_comment_text_changed": "Текст изменён", - "preview_no_comments_yet": "Пока нет ни одного комментария. Выделите текст в публикации, чтобы оставить к нему комментарий, или добавьте общий комментарий." + "preview_no_comments_yet": "Пока нет ни одного комментария. Выделите текст в публикации, чтобы оставить к нему комментарий, или добавьте общий комментарий.", + "channels_still_disabled": "Ваши каналы отключены", + "channels_still_disabled_description": "Ваш тарифный план не включает все подключённые каналы, поэтому они остаются отключёнными. Включите нужные каналы в меню каждого канала в рамках лимита вашего тарифа или обновите тариф, чтобы включить их все.", + "channels_enabled_again": "Ваши каналы включены", + "channels_enabled_again_description": "Все ваши подключённые каналы снова были включены и готовы к публикации." } diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index 2ec5eaffad..b99c42406b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "Yorum paylaşılırken bir hata oluştu", "preview_comment_detach_hint": "Seçili metindeki yorumlar, metin daha sonra değiştirilirse vurgusunu kaybeder.", "preview_comment_text_changed": "Metin değişti", - "preview_no_comments_yet": "Henüz yorum yok. Yorum yapmak için gönderide bir metin seçin veya genel bir yorum ekleyin." + "preview_no_comments_yet": "Henüz yorum yok. Yorum yapmak için gönderide bir metin seçin veya genel bir yorum ekleyin.", + "channels_still_disabled": "Kanallarınız devre dışı", + "channels_still_disabled_description": "Paketiniz, bağlı olduğunuz tüm kanalları kapsamıyor, bu yüzden bazıları devre dışı kalıyor. Her kanalın menüsünden istediğiniz kanalları paketinizin sınırına kadar etkinleştirebilir veya hepsini etkinleştirmek için paketinizi yükseltebilirsiniz.", + "channels_enabled_again": "Kanallarınız etkinleştirildi", + "channels_enabled_again_description": "Tüm bağlı kanallarınız yeniden etkinleştirildi ve yayınlamaya hazır." } diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 197691d3ad..00c7012d5f 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "Không thể đăng bình luận", "preview_comment_detach_hint": "Bình luận trên đoạn văn bản được chọn sẽ mất nổi bật nếu văn bản bị chỉnh sửa sau đó.", "preview_comment_text_changed": "Nội dung đã thay đổi", - "preview_no_comments_yet": "Chưa có bình luận nào. Chọn một đoạn văn bản trong bài viết để bình luận, hoặc thêm bình luận chung." + "preview_no_comments_yet": "Chưa có bình luận nào. Chọn một đoạn văn bản trong bài viết để bình luận, hoặc thêm bình luận chung.", + "channels_still_disabled": "Các kênh của bạn đang bị tắt", + "channels_still_disabled_description": "Gói của bạn không hỗ trợ tất cả các kênh đã kết nối, vì vậy một số kênh vẫn bị tắt. Hãy bật những kênh bạn muốn từ menu của từng kênh, trong giới hạn gói của bạn, hoặc nâng cấp gói để bật tất cả các kênh.", + "channels_enabled_again": "Các kênh của bạn đã được bật lại", + "channels_enabled_again_description": "Tất cả các kênh đã kết nối của bạn đều đã được bật lại và sẵn sàng để xuất bản." } diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index c7a089b1ba..2781223201 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -785,5 +785,9 @@ "preview_comment_failed": "无法发布评论", "preview_comment_detach_hint": "如果后续编辑了所选文本,所选文本上的评论高亮将丢失。", "preview_comment_text_changed": "文本已更改", - "preview_no_comments_yet": "还没有评论。选中帖子中的某些文本以进行评论,或添加一个总体评论。" + "preview_no_comments_yet": "还没有评论。选中帖子中的某些文本以进行评论,或添加一个总体评论。", + "channels_still_disabled": "您的频道已被禁用", + "channels_still_disabled_description": "您的套餐不支持所有已连接的频道,因此这些频道仍然被禁用。您可以在各频道菜单中选择启用想要的频道(不超过套餐限定数量),或升级套餐以全部启用。", + "channels_enabled_again": "您的频道已启用", + "channels_enabled_again_description": "您所有已连接的频道已重新启用,可以发布内容了。" } From 5848ef2d4336f4d7b6d04094f15accd877cb2844 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 28 Sep 2026 13:23:57 +0700 Subject: [PATCH 19/99] refactor(integrations): enable all channels with a single updateMany Co-Authored-By: Claude Opus 5.5 --- .../integrations/integration.repository.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index 48a33277e4..f058b75d5d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -842,28 +842,17 @@ export class IntegrationRepository { } } - async enableAllIntegrations(org: string) { - const getChannels = await this._integration.model.integration.findMany({ + enableAllIntegrations(org: string) { + return this._integration.model.integration.updateMany({ where: { organizationId: org, disabled: true, deletedAt: null, }, - select: { - id: true, + data: { + disabled: false, }, }); - - for (const channel of getChannels) { - await this._integration.model.integration.update({ - where: { - id: channel.id, - }, - data: { - disabled: false, - }, - }); - } } getPlugsByIntegrationId(org: string, id: string) { From 0cf459623fd363364dec85b800885f3bb908363f Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 28 Sep 2026 16:34:10 +0700 Subject: [PATCH 20/99] fix(frontend): skip the Sentry report dialog for errors from browser 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 --- .../sentry/initialize.sentry.next.basic.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts index 8e040cafe4..48807d225a 100644 --- a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts +++ b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts @@ -30,6 +30,36 @@ export const initializeSentryBasic = (environment: string, dsn: string, extensio 'code' in exception && 'message' in exception; + // Frameless rejections our code can never produce: every chrome.runtime.sendMessage + // call we make passes a callback, so it never rejects with this message. + const thirdPartyFramelessPatterns = [ + /^Could not establish connection\. Receiving end does not exist\.$/, + ]; + + // Sentry wraps timer and event listener callbacks, extensions' included, and the + // wrapper lives in our bundle. Its location is recorded so it is not counted as ours. + let sentryWrapperFrame = ''; + const frameKey = (frame: Sentry.StackFrame) => + `${(frame.filename || '').split('/_next/').pop()}:${frame.lineno}:${frame.colno}`; + + const isThirdPartyOnly = (event: Sentry.ErrorEvent) => { + const values = event.exception?.values || []; + return ( + values.length > 0 && + values.every((value) => { + const frames = value.stacktrace?.frames || []; + const wrapperIndex = frames.map(frameKey).lastIndexOf(sentryWrapperFrame); + const callbackFrames = frames + .slice(wrapperIndex + 1) + .filter((frame) => frame.filename && (frame.lineno || frame.colno)); + if (!callbackFrames.length) { + return thirdPartyFramelessPatterns.some((pattern) => pattern.test(value.value || '')); + } + return callbackFrames.every((frame) => !frame.filename!.includes('/_next/')); + }) + ); + }; + try { Sentry.init({ initialScope: { @@ -72,8 +102,12 @@ export const initializeSentryBasic = (environment: string, dsn: string, extensio } } + if (isThirdPartyOnly(event)) { + event.tags = { ...event.tags, third_party_code: true }; + } + // If there's an exception and an event id, present the user report dialog. - if (event.event_id) { + if (event.event_id && !event.tags?.third_party_code) { // Only attempt to show the dialog in a browser environment. if (typeof window !== 'undefined' && window.document) { // Dynamically import the package that exports showReportDialog to avoid @@ -98,6 +132,13 @@ export const initializeSentryBasic = (environment: string, dsn: string, extensio return event; // Send the event to Sentry }, }); + + if (typeof window !== 'undefined') { + setTimeout(() => { + const frame = Sentry.defaultStackParser(new Error().stack || '')[0]; + sentryWrapperFrame = frame ? frameKey(frame) : ''; + }); + } } catch (err) { // Log initialization errors // eslint-disable-next-line no-console From 277d3620acb527a86f87399ecfecfadf2699c256 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 28 Sep 2026 17:10:27 +0700 Subject: [PATCH 21/99] fix(frontend): record the Sentry wrapper location synchronously at init Co-Authored-By: Claude Opus 5.5 --- .../src/sentry/initialize.sentry.next.basic.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts index 48807d225a..00e2b8ba63 100644 --- a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts +++ b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts @@ -134,10 +134,13 @@ export const initializeSentryBasic = (environment: string, dsn: string, extensio }); if (typeof window !== 'undefined') { - setTimeout(() => { - const frame = Sentry.defaultStackParser(new Error().stack || '')[0]; + const probe = new EventTarget(); + probe.addEventListener('probe', () => { + const frames = Sentry.defaultStackParser(new Error().stack || ''); + const frame = frames[frames.length - 2]; sentryWrapperFrame = frame ? frameKey(frame) : ''; }); + probe.dispatchEvent(new Event('probe')); } } catch (err) { // Log initialization errors From 5a1e92b4c0745baf62b1f1cef56bbf374e00e04a Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 30 Sep 2026 14:33:05 +0700 Subject: [PATCH 22/99] feat(channels): rename a channel in Postiz while keeping its original 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 Claude-Session: https://claude.ai/code/session_0153xf1m3VSQnd9QrMLhDikq --- .../src/api/routes/integrations.controller.ts | 13 +- .../v1/public.integrations.controller.ts | 2 +- .../src/components/launches/bot.picture.tsx | 4 +- .../components/launches/calendar.context.tsx | 1 + .../src/components/launches/menu/menu.tsx | 111 ++++++++++++++++++ i18n.lock | 5 + .../src/chat/tools/integration.list.tool.ts | 2 +- .../integrations/integration.repository.ts | 12 ++ .../integrations/integration.service.ts | 4 + .../src/database/prisma/schema.prisma | 1 + .../dtos/integrations/integration.name.dto.ts | 7 ++ .../translation/locales/ar/translation.json | 7 +- .../translation/locales/bn/translation.json | 7 +- .../translation/locales/de/translation.json | 7 +- .../translation/locales/en/translation.json | 7 +- .../translation/locales/es/translation.json | 7 +- .../translation/locales/fr/translation.json | 7 +- .../translation/locales/he/translation.json | 7 +- .../translation/locales/it/translation.json | 7 +- .../translation/locales/ja/translation.json | 7 +- .../translation/locales/ko/translation.json | 7 +- .../translation/locales/pt/translation.json | 7 +- .../translation/locales/ru/translation.json | 7 +- .../translation/locales/tr/translation.json | 7 +- .../translation/locales/vi/translation.json | 7 +- .../translation/locales/zh/translation.json | 7 +- 26 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 libraries/nestjs-libraries/src/dtos/integrations/integration.name.dto.ts diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 3b719f2a62..47c7de99d5 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -20,6 +20,7 @@ import { ApiTags } from '@nestjs/swagger'; import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto'; +import { IntegrationNameDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.name.dto'; import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto'; import { Disconnect, @@ -88,6 +89,15 @@ export class IntegrationsController { return this._integrationService.updateOnCustomerName(org.id, id, body.name); } + @Put('/:id/custom-name') + async updateCustomName( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: IntegrationNameDto + ) { + return this._integrationService.updateCustomName(org.id, id, body.name); + } + @Get('/list') async getIntegrationList(@GetOrgFromRequest() org: Organization) { return { @@ -99,7 +109,8 @@ export class IntegrationsController { p.providerIdentifier ); return { - name: p.name, + name: p.customName || p.name, + originalName: p.name, id: p.id, internalId: p.internalId, disabled: p.disabled, diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 99ac591814..d5360113cc 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -269,7 +269,7 @@ export class PublicIntegrationsController { .filter((integration) => !group || integration.customer?.id === group) .map((integration) => ({ id: integration.id, - name: integration.name, + name: integration.customName || integration.name, identifier: integration.providerIdentifier, picture: integration.picture, disabled: integration.disabled, diff --git a/apps/frontend/src/components/launches/bot.picture.tsx b/apps/frontend/src/components/launches/bot.picture.tsx index 38cbdc16cb..06bedd3660 100644 --- a/apps/frontend/src/components/launches/bot.picture.tsx +++ b/apps/frontend/src/components/launches/bot.picture.tsx @@ -19,7 +19,9 @@ export const BotPicture: FC<{ const t = useT(); const modal = useModals(); const toast = useToaster(); - const [nick, setNickname] = useState(props.integration.name); + const [nick, setNickname] = useState( + props.integration.originalName || props.integration.name + ); const [picture, setPicture] = useState( props.integration.picture || '/no-picture.jpg' ); diff --git a/apps/frontend/src/components/launches/calendar.context.tsx b/apps/frontend/src/components/launches/calendar.context.tsx index fb5bea3621..d90a4e1cef 100644 --- a/apps/frontend/src/components/launches/calendar.context.tsx +++ b/apps/frontend/src/components/launches/calendar.context.tsx @@ -88,6 +88,7 @@ export const CalendarContext = createContext({ export interface Integrations { name: string; + originalName?: string; id: string; disabled?: boolean; inBetweenSteps: boolean; diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index 053d276650..54519e1fcb 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -31,6 +31,42 @@ import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.m import dayjs from 'dayjs'; import { ModalWrapperComponent } from '@gitroom/frontend/components/new-launch/modal.wrapper.component'; import copy from 'copy-to-clipboard'; +import { Input } from '@gitroom/react/form/input'; +import { Button } from '@gitroom/react/form/button'; + +const RenameChannelModal: FC<{ + name: string; + id: string; + close: () => void; + onSave: () => void; +}> = (props) => { + const t = useT(); + const { close, name, id, onSave } = props; + const fetch = useFetch(); + const [channelName, setChannelName] = useState(name); + const save = useCallback(async () => { + await fetch(`/integrations/${id}/custom-name`, { + method: 'PUT', + body: JSON.stringify({ name: channelName }), + }); + onSave(); + close(); + }, [channelName, id]); + return ( +
+ setChannelName(e.target.value)} + /> + +
+ ); +}; export const Menu: FC<{ canEnable: boolean; @@ -316,6 +352,35 @@ export const Menu: FC<{ }); setShow(false); }, [integrations, t]); + const renameChannel = useCallback(() => { + const findIntegration = integrations.find( + (integration) => integration.id === id + ); + modal.openModal({ + title: t('rename_channel', 'Rename channel'), + children: (close) => ( + { + mutate(); + toast.show(t('channel_renamed', 'Channel Renamed'), 'success'); + }} + /> + ), + }); + setShow(false); + }, [integrations, t]); + const resetChannelName = useCallback(async () => { + await fetch(`/integrations/${id}/custom-name`, { + method: 'PUT', + body: JSON.stringify({ name: '' }), + }); + mutate(); + toast.show(t('channel_name_reset', 'Channel name reset'), 'success'); + setShow(false); + }, [id, t]); const updateCredentials = useCallback(() => { modal.openModal({ title: t('custom_url', 'Custom URL'), @@ -518,6 +583,52 @@ export const Menu: FC<{
)} +
+
+ + + +
+
+ {t('rename_channel', 'Rename channel')} +
+
+ {findIntegration?.name !== findIntegration?.originalName && ( +
+
+ + + +
+
+ {t('reset_channel_name', 'Reset to original name')} +
+
+ )}
!inputData.group || p.customer?.id === inputData.group) .map((p) => ({ - name: p.name, + name: p.customName || p.name, id: p.id, disabled: p.disabled, picture: p.picture || '/no-picture.jpg', diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index 37274a6f37..6557f495a7 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -104,6 +104,18 @@ export class IntegrationRepository { }); } + updateCustomName(org: string, id: string, name: string) { + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + customName: name.trim() || null, + }, + }); + } + async setTimes(org: string, id: string, times: IntegrationTimeDto) { return this._integration.model.integration.update({ select: { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 6a226355b2..b6043119cf 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -167,6 +167,10 @@ export class IntegrationService { ); } + updateCustomName(org: string, id: string, name: string) { + return this._integrationRepository.updateCustomName(org, id, name); + } + updateNameAndUrl(id: string, name: string, url: string) { return this._integrationRepository.updateNameAndUrl(id, name, url); } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 328b0db42d..019e5d9464 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -369,6 +369,7 @@ model Integration { internalId String organizationId String name String + customName String? picture String? providerIdentifier String type String diff --git a/libraries/nestjs-libraries/src/dtos/integrations/integration.name.dto.ts b/libraries/nestjs-libraries/src/dtos/integrations/integration.name.dto.ts new file mode 100644 index 0000000000..c72a059f21 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/integrations/integration.name.dto.ts @@ -0,0 +1,7 @@ +import { IsDefined, IsString } from 'class-validator'; + +export class IntegrationNameDto { + @IsString() + @IsDefined() + name: string; +} diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 0170e62db7..5e4f7e4308 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "التعليقات على النص المحدد تفقد تمييزها إذا تم تعديل النص لاحقًا.", "preview_comment_text_changed": "تم تغيير النص", "preview_no_comments_yet": "لا توجد تعليقات بعد. حدد بعض النص في المنشور للتعليق عليه، أو أضف تعليقًا عامًا.", - "some_files_failed_to_upload": "فشل تحميل بعض الملفات، يرجى المحاولة مرة أخرى" + "some_files_failed_to_upload": "فشل تحميل بعض الملفات، يرجى المحاولة مرة أخرى", + "rename_channel": "إعادة تسمية القناة", + "channel_name": "الاسم", + "channel_renamed": "تمت إعادة تسمية القناة", + "channel_name_reset": "تم إعادة ضبط اسم القناة", + "reset_channel_name": "إعادة الاسم إلى الاسم الأصلي" } diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 61e3aeeceb..3d4c3bd453 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "নির্বাচিত টেক্সটে মন্তব্য করলে, টেক্সট সম্পাদনার পর হাইলাইট হারিয়ে যেতে পারে।", "preview_comment_text_changed": "টেক্সট পরিবর্তিত হয়েছে", "preview_no_comments_yet": "এখনও কোনো মন্তব্য নেই। পোস্টে কিছু টেক্সট নির্বাচন করে অথবা সাধারণ মন্তব্য যোগ করে মন্তব্য করুন।", - "some_files_failed_to_upload": "কিছু ফাইল আপলোড করতে ব্যর্থ হয়েছে, অনুগ্রহ করে আবার চেষ্টা করুন" + "some_files_failed_to_upload": "কিছু ফাইল আপলোড করতে ব্যর্থ হয়েছে, অনুগ্রহ করে আবার চেষ্টা করুন", + "rename_channel": "চ্যানেলের নাম পরিবর্তন করুন", + "channel_name": "নাম", + "channel_renamed": "চ্যানেলের নাম পরিবর্তন হয়েছে", + "channel_name_reset": "চ্যানেলের নাম পুনরায় সেট করা হয়েছে", + "reset_channel_name": "মূল নামে ফিরে যান" } diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 5e0e9912c7..0e8e02c7b5 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "Kommentare zu ausgewähltem Text verlieren ihre Markierung, wenn der Text später bearbeitet wird.", "preview_comment_text_changed": "Text geändert", "preview_no_comments_yet": "Noch keine Kommentare. Wählen Sie einen Text im Beitrag aus, um ihn zu kommentieren, oder fügen Sie einen allgemeinen Kommentar hinzu.", - "some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden. Bitte versuchen Sie es erneut." + "some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden. Bitte versuchen Sie es erneut.", + "rename_channel": "Kanal umbenennen", + "channel_name": "Name", + "channel_renamed": "Kanal umbenannt", + "channel_name_reset": "Kanalname zurückgesetzt", + "reset_channel_name": "Auf ursprünglichen Namen zurücksetzen" } diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index e5b8f2b20b..bc458b5480 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -789,5 +789,10 @@ "preview_comment_detach_hint": "Comments on selected text lose their highlight if the text is edited later.", "preview_comment_text_changed": "Text changed", "preview_no_comments_yet": "No comments yet. Select some text in the post to comment on it, or add a general comment.", - "some_files_failed_to_upload": "Some files failed to upload, please try again" + "some_files_failed_to_upload": "Some files failed to upload, please try again", + "rename_channel": "Rename channel", + "channel_name": "Name", + "channel_renamed": "Channel Renamed", + "channel_name_reset": "Channel name reset", + "reset_channel_name": "Reset to original name" } diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 445cda7df6..35ab496661 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "Los comentarios sobre texto seleccionado pierden su resalte si el texto se edita más tarde.", "preview_comment_text_changed": "Texto modificado", "preview_no_comments_yet": "Aún no hay comentarios. Selecciona texto en la publicación para comentarlo o agrega un comentario general.", - "some_files_failed_to_upload": "Algunos archivos no se pudieron subir, por favor inténtalo de nuevo" + "some_files_failed_to_upload": "Algunos archivos no se pudieron subir, por favor inténtalo de nuevo", + "rename_channel": "Renombrar canal", + "channel_name": "Nombre", + "channel_renamed": "Canal renombrado", + "channel_name_reset": "Nombre del canal restablecido", + "reset_channel_name": "Restablecer al nombre original" } diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 9283eb92a9..cf967bc739 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "Les commentaires sur le texte sélectionné perdent leur surlignement si le texte est modifié par la suite.", "preview_comment_text_changed": "Texte modifié", "preview_no_comments_yet": "Aucun commentaire pour le moment. Sélectionnez du texte dans la publication pour le commenter, ou ajoutez un commentaire général.", - "some_files_failed_to_upload": "Échec du téléversement de certains fichiers, veuillez réessayer." + "some_files_failed_to_upload": "Échec du téléversement de certains fichiers, veuillez réessayer.", + "rename_channel": "Renommer le canal", + "channel_name": "Nom", + "channel_renamed": "Canal renommé", + "channel_name_reset": "Nom du canal réinitialisé", + "reset_channel_name": "Réinitialiser au nom d'origine" } diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index 86fe8509b7..1bae195e42 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "תגובות על טקסט נבחר מאבדות הדגשה אם הטקסט נערך מאוחר יותר.", "preview_comment_text_changed": "הטקסט השתנה", "preview_no_comments_yet": "עדיין אין תגובות. בחר טקסט בפוסט כדי להגיב עליו, או הוסף תגובה כללית.", - "some_files_failed_to_upload": "חלק מהקבצים לא הועלו, אנא נסה שוב" + "some_files_failed_to_upload": "חלק מהקבצים לא הועלו, אנא נסה שוב", + "rename_channel": "שנה שם ערוץ", + "channel_name": "שם", + "channel_renamed": "שם הערוץ שונה", + "channel_name_reset": "שם הערוץ אופס", + "reset_channel_name": "אפס לשם המקורי" } diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 0fc15d80ed..b71ddb47a5 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "I commenti su testo selezionato perdono l'evidenziazione se il testo viene modificato successivamente.", "preview_comment_text_changed": "Testo modificato", "preview_no_comments_yet": "Nessun commento ancora. Seleziona del testo nel post per commentarlo, oppure aggiungi un commento generale.", - "some_files_failed_to_upload": "Alcuni file non sono stati caricati, riprova" + "some_files_failed_to_upload": "Alcuni file non sono stati caricati, riprova", + "rename_channel": "Rinomina canale", + "channel_name": "Nome", + "channel_renamed": "Canale rinominato", + "channel_name_reset": "Nome del canale ripristinato", + "reset_channel_name": "Ripristina il nome originale" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 638a6eaba2..8797c48df8 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "選択テキストへのコメントは、テキストが後から編集されるとハイライトが失われます。", "preview_comment_text_changed": "テキストが変更されました", "preview_no_comments_yet": "まだコメントはありません。投稿内のテキストを選択してコメントするか、一般的なコメントを追加してください。", - "some_files_failed_to_upload": "いくつかのファイルのアップロードに失敗しました。もう一度お試しください。" + "some_files_failed_to_upload": "いくつかのファイルのアップロードに失敗しました。もう一度お試しください。", + "rename_channel": "チャンネル名を変更", + "channel_name": "名前", + "channel_renamed": "チャンネル名を変更しました", + "channel_name_reset": "チャンネル名をリセットしました", + "reset_channel_name": "元の名前にリセット" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index b110b712ef..c71bc690ed 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "선택한 텍스트에 단 댓글은, 텍스트가 나중에 수정되면 강조가 사라집니다.", "preview_comment_text_changed": "텍스트가 변경됨", "preview_no_comments_yet": "아직 댓글이 없습니다. 게시물에서 텍스트를 선택해 댓글을 남기거나, 일반 댓글을 추가하세요.", - "some_files_failed_to_upload": "일부 파일을 업로드하지 못했습니다. 다시 시도해 주세요." + "some_files_failed_to_upload": "일부 파일을 업로드하지 못했습니다. 다시 시도해 주세요.", + "rename_channel": "채널 이름 변경", + "channel_name": "이름", + "channel_renamed": "채널 이름이 변경됨", + "channel_name_reset": "채널 이름이 초기화됨", + "reset_channel_name": "원래 이름으로 되돌리기" } diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 9d30b7e99e..d53c7ce9aa 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "Comentários em textos selecionados perdem o destaque se o texto for editado depois.", "preview_comment_text_changed": "Texto alterado", "preview_no_comments_yet": "Ainda não há comentários. Selecione um trecho do post para comentar ou adicione um comentário geral.", - "some_files_failed_to_upload": "Alguns arquivos não foram carregados, tente novamente" + "some_files_failed_to_upload": "Alguns arquivos não foram carregados, tente novamente", + "rename_channel": "Renomear canal", + "channel_name": "Nome", + "channel_renamed": "Canal renomeado", + "channel_name_reset": "Nome do canal redefinido", + "reset_channel_name": "Redefinir para o nome original" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 12a9349727..7ac5214fae 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "Комментарии к выделенному тексту теряют выделение, если текст позже изменяется.", "preview_comment_text_changed": "Текст изменён", "preview_no_comments_yet": "Пока нет ни одного комментария. Выделите текст в публикации, чтобы оставить к нему комментарий, или добавьте общий комментарий.", - "some_files_failed_to_upload": "Не удалось загрузить некоторые файлы, пожалуйста, попробуйте еще раз" + "some_files_failed_to_upload": "Не удалось загрузить некоторые файлы, пожалуйста, попробуйте еще раз", + "rename_channel": "Переименовать канал", + "channel_name": "Название", + "channel_renamed": "Канал переименован", + "channel_name_reset": "Имя канала сброшено", + "reset_channel_name": "Сбросить до исходного имени" } diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index f70384f398..fbf4b12156 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "Seçili metindeki yorumlar, metin daha sonra değiştirilirse vurgusunu kaybeder.", "preview_comment_text_changed": "Metin değişti", "preview_no_comments_yet": "Henüz yorum yok. Yorum yapmak için gönderide bir metin seçin veya genel bir yorum ekleyin.", - "some_files_failed_to_upload": "Bazı dosyalar yüklenemedi, lütfen tekrar deneyin" + "some_files_failed_to_upload": "Bazı dosyalar yüklenemedi, lütfen tekrar deneyin", + "rename_channel": "Kanalı yeniden adlandır", + "channel_name": "Ad", + "channel_renamed": "Kanal yeniden adlandırıldı", + "channel_name_reset": "Kanal adı sıfırlandı", + "reset_channel_name": "Orijinal adına sıfırla" } diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index b091ae6493..68cc6f2ce4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "Bình luận trên đoạn văn bản được chọn sẽ mất nổi bật nếu văn bản bị chỉnh sửa sau đó.", "preview_comment_text_changed": "Nội dung đã thay đổi", "preview_no_comments_yet": "Chưa có bình luận nào. Chọn một đoạn văn bản trong bài viết để bình luận, hoặc thêm bình luận chung.", - "some_files_failed_to_upload": "Một số tệp không tải lên được, vui lòng thử lại" + "some_files_failed_to_upload": "Một số tệp không tải lên được, vui lòng thử lại", + "rename_channel": "Đổi tên kênh", + "channel_name": "Tên", + "channel_renamed": "Đã đổi tên kênh", + "channel_name_reset": "Đã đặt lại tên kênh", + "reset_channel_name": "Đặt lại tên gốc" } diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index bbba1bd3df..96a9e2601e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -787,5 +787,10 @@ "preview_comment_detach_hint": "如果后续编辑了所选文本,所选文本上的评论高亮将丢失。", "preview_comment_text_changed": "文本已更改", "preview_no_comments_yet": "还没有评论。选中帖子中的某些文本以进行评论,或添加一个总体评论。", - "some_files_failed_to_upload": "部分文件上传失败,请重试" + "some_files_failed_to_upload": "部分文件上传失败,请重试", + "rename_channel": "重命名频道", + "channel_name": "名称", + "channel_renamed": "频道已重命名", + "channel_name_reset": "频道名称已重置", + "reset_channel_name": "重置为原始名称" } From fa4722990ea83e0809781e01b24d679d0253b2dc Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 30 Sep 2026 15:32:54 +0700 Subject: [PATCH 23/99] fix(frontend): classify third-party errors only in the browser Co-Authored-By: Claude Opus 5.5 --- .../src/sentry/initialize.sentry.next.basic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts index 00e2b8ba63..87eeb1ffd3 100644 --- a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts +++ b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts @@ -102,7 +102,7 @@ export const initializeSentryBasic = (environment: string, dsn: string, extensio } } - if (isThirdPartyOnly(event)) { + if (typeof window !== 'undefined' && isThirdPartyOnly(event)) { event.tags = { ...event.tags, third_party_code: true }; } From bb2e01760d5460601222d20748b0f673c994039d Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 1 Oct 2026 11:29:40 +0700 Subject: [PATCH 24/99] fix(channels): return only the id from the custom name update Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0153xf1m3VSQnd9QrMLhDikq --- .../src/database/prisma/integrations/integration.repository.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index 946446d224..6cb86c9df8 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -106,6 +106,9 @@ export class IntegrationRepository { updateCustomName(org: string, id: string, name: string) { return this._integration.model.integration.update({ + select: { + id: true, + }, where: { id, organizationId: org, From e70110fe1ea2998f1129d2bdeedacae4d4efbd9e Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 17 Aug 2026 14:06:37 +0700 Subject: [PATCH 25/99] feat(calendar): add an open-post link icon for published posts 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 --- .../src/components/launches/calendar.tsx | 32 +++++++++++++++++++ i18n.lock | 1 + .../translation/locales/ar/translation.json | 1 + .../translation/locales/bn/translation.json | 1 + .../translation/locales/de/translation.json | 1 + .../translation/locales/en/translation.json | 1 + .../translation/locales/es/translation.json | 1 + .../translation/locales/fr/translation.json | 1 + .../translation/locales/he/translation.json | 1 + .../translation/locales/it/translation.json | 1 + .../translation/locales/ja/translation.json | 1 + .../translation/locales/ko/translation.json | 1 + .../translation/locales/pt/translation.json | 1 + .../translation/locales/ru/translation.json | 1 + .../translation/locales/tr/translation.json | 1 + .../translation/locales/vi/translation.json | 1 + .../translation/locales/zh/translation.json | 1 + 17 files changed, 48 insertions(+) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 0fd2a8dfff..8f7a3b5d5c 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -1122,6 +1122,19 @@ const CalendarItem: FC<{ >
{' '} + {state === 'PUBLISHED' && + !post.intervalInDays && + post.releaseURL?.startsWith('http') && ( +
window.open(post.releaseURL, '_blank')} + > + +
+ )}{' '} {((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? ( <> ) : post.releaseId === 'missing' && missingRelease ? ( @@ -1304,6 +1317,25 @@ const Preview = () => { ); }; +const OpenPost = () => { + const t = useT(); + return ( + + + + ); +}; export const Statistics = () => { const t = useT(); return ( diff --git a/i18n.lock b/i18n.lock index d72791c370..c3e637f62f 100644 --- a/i18n.lock +++ b/i18n.lock @@ -338,6 +338,7 @@ checksums: predicted_github_trending_change: db2e35c6bb26939186948182195e84df duplicate_post: 550d07a1efcab352409ecdbaa16acfcc preview_post: a86240c3451c3d5737b6acf358e1d056 + open_post: 7973a6d7427806d086720278a9b5dba0 post_statistics: 946015ed60528cf3b173135542053d3e draft: e8a92958ad300aacfe46c2bf6644927e week_number: 94b8a343e6e9a25c021e055ad974c68e diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 7935a7f33b..60716c02da 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "التغير المتوقع في ترند GitHub", "duplicate_post": "منشور مكرر", "preview_post": "معاينة المنشور", + "open_post": "فتح المشاركة", "post_statistics": "إحصائيات المنشور", "draft": "مسودة", "week_number": "الأسبوع {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index d028c353d6..619f97040e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "পূর্বাভাসিত GitHub ট্রেন্ডিং পরিবর্তন", "duplicate_post": "পোস্ট ডুপ্লিকেট করুন", "preview_post": "পোস্ট প্রিভিউ", + "open_post": "পোস্ট খুলুন", "post_statistics": "পোস্ট পরিসংখ্যান", "draft": "খসড়া", "week_number": "সপ্তাহ নম্বর", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 9166878e57..db18431a60 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Prognostizierte GitHub-Trendänderung", "duplicate_post": "Dupliziere Beitrag", "preview_post": "Beitragsvorschau", + "open_post": "Beitrag öffnen", "post_statistics": "Beitragsstatistiken", "draft": "Entwurf", "week_number": "Woche {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index b7729393fa..e09b365307 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Predicted GitHub Trending Change", "duplicate_post": "Duplicate Post", "preview_post": "Preview Post", + "open_post": "Open Post", "post_statistics": "Post Statistics", "draft": "Draft", "week_number": "Week {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index c5d398bf89..f33851051a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Cambio de tendencia de GitHub previsto", "duplicate_post": "Publicación duplicada", "preview_post": "Vista previa de la publicación", + "open_post": "Abrir publicación", "post_statistics": "Estadísticas de la publicación", "draft": "Borrador", "week_number": "Semana {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 94de4a792e..0536d43d0b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Changement de tendance GitHub prédit", "duplicate_post": "Publication en double", "preview_post": "Aperçu de la publication", + "open_post": "Ouvrir le post", "post_statistics": "Statistiques de la publication", "draft": "Brouillon", "week_number": "Semaine {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index e0556c9a48..acc738056a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "שינוי מגמה חזוי ב-GitHub", "duplicate_post": "פוסט כפול", "preview_post": "תצוגה מקדימה של הפוסט", + "open_post": "פתח פוסט", "post_statistics": "סטטיסטיקות פוסט", "draft": "טיוטה", "week_number": "שבוע {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 92f5e5bfe0..300f611acc 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Variazione prevista delle tendenze di GitHub", "duplicate_post": "Post duplicato", "preview_post": "Anteprima post", + "open_post": "Apri post", "post_statistics": "Statistiche del post", "draft": "Bozza", "week_number": "Settimana {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 7883d3c1df..5213f69bef 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "GitHubトレンド予測の変化", "duplicate_post": "重複した投稿", "preview_post": "投稿をプレビュー", + "open_post": "投稿を開く", "post_statistics": "投稿の統計", "draft": "下書き", "week_number": "第{{number}}週", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index 266fa2c3fd..10a2935486 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "예상 GitHub 트렌드 변화", "duplicate_post": "중복 게시물", "preview_post": "게시물 미리보기", + "open_post": "게시물 열기", "post_statistics": "게시물 통계", "draft": "임시 저장", "week_number": "{{number}}주차", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 336ae9ecf0..4f5fba1376 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Mudança prevista no Trending do GitHub", "duplicate_post": "Post duplicado", "preview_post": "Visualizar post", + "open_post": "Abrir postagem", "post_statistics": "Estatísticas do post", "draft": "Rascunho", "week_number": "Semana {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 4121107592..06805a58d4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Прогнозируемое изменение трендов GitHub", "duplicate_post": "Дублировать пост", "preview_post": "Предпросмотр поста", + "open_post": "Открыть пост", "post_statistics": "Статистика поста", "draft": "Черновик", "week_number": "Неделя {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index 6dc594ae5e..2c3a8f2aba 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Tahmini GitHub Trend Değişimi", "duplicate_post": "Yinelenen Gönderi", "preview_post": "Gönderiyi Önizle", + "open_post": "Gönderiyi Aç", "post_statistics": "Gönderi İstatistikleri", "draft": "Taslak", "week_number": "{{number}}. Hafta", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index b9eb25bbc2..631ffaaea6 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "Dự đoán thay đổi xu hướng GitHub", "duplicate_post": "Bài viết trùng lặp", "preview_post": "Xem trước bài viết", + "open_post": "Mở bài viết", "post_statistics": "Thống kê bài viết", "draft": "Bản nháp", "week_number": "Tuần {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 449a805b52..f50cff2744 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -334,6 +334,7 @@ "predicted_github_trending_change": "预测 GitHub 趋势变化", "duplicate_post": "重复帖子", "preview_post": "预览帖子", + "open_post": "打开帖子", "post_statistics": "帖子统计", "draft": "草稿", "week_number": "第 {{number}} 周", From 163cfbfb21895d501398cc44fc9a53366614ce28 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 10 Sep 2026 19:41:56 +0700 Subject: [PATCH 26/99] feat(calendar): show the open-post link on failed posts that still have 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. --- apps/frontend/src/components/launches/calendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 8f7a3b5d5c..2951e389e6 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -1122,7 +1122,7 @@ const CalendarItem: FC<{ >
{' '} - {state === 'PUBLISHED' && + {(state === 'PUBLISHED' || state === 'ERROR') && !post.intervalInDays && post.releaseURL?.startsWith('http') && (
Date: Thu, 10 Sep 2026 21:32:25 +0700 Subject: [PATCH 27/99] fix(calendar): open the first URL of a multi-target post and sever the 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. --- apps/frontend/src/components/launches/calendar.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 2951e389e6..c65f52aec5 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -1130,7 +1130,15 @@ const CalendarItem: FC<{ 'hidden group-hover:block hover:underline cursor-pointer', post?.tags?.[0]?.tag?.color && 'mix-blend-difference' )} - onClick={() => window.open(post.releaseURL, '_blank')} + onClick={() => + window.open( + // multi-target posts (several subreddits / communities / + // channels) join their URLs with commas: open the first one + post.releaseURL.split(',')[0], + '_blank', + 'noopener,noreferrer' + ) + } >
From f10296a74115cbe58d6aab5a48ea5c732e373ca5 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 2 Oct 2026 17:00:01 +0700 Subject: [PATCH 28/99] feat(calendar): resolve the release URL on click before opening the post 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 --- .../src/api/routes/posts.controller.ts | 8 +++ .../src/components/launches/calendar.tsx | 38 +++++++----- .../database/prisma/posts/posts.service.ts | 58 +++++++++++++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 230d2cef3e..cc3692fe70 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -60,6 +60,14 @@ export class PostsController { return this._postsService.getMissingContent(org.id, id); } + @Get('/:id/release-url') + async getReleaseURL( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string + ) { + return this._postsService.getReleaseURL(org.id, id); + } + @Put('/:id/release-id') async updateReleaseId( @GetOrgFromRequest() org: Organization, diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index c65f52aec5..01d15868d6 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -257,7 +257,23 @@ const usePostActions = (onMutate?: () => void) => { [modal, t, mutate] ); - return { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease }; + const openPost = useCallback( + (post: Post) => async () => { + const { releaseURL } = await ( + await fetch(`/posts/${post.id}/release-url`) + ).json(); + // multi-target posts (several subreddits / communities / channels) + // join their URLs with commas: open the first one + window.open( + (releaseURL || post.releaseURL).split(',')[0], + '_blank', + 'noopener,noreferrer' + ); + }, + [fetch] + ); + + return { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease, openPost }; }; export const DayView = () => { @@ -504,7 +520,7 @@ export const ListView = () => { : t('no_posts', 'No posts'); // Use shared post actions hook - const { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease } = usePostActions(); + const { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease, openPost } = usePostActions(); // Group posts by date const groupedPosts = useMemo(() => { @@ -553,6 +569,7 @@ export const ListView = () => { state={post.state} statistics={openStatistics(post.id)} missingRelease={openMissingRelease(post.id)} + openPost={openPost(post)} editPost={editPost(post, false)} duplicatePost={editPost(post, true)} copyDebugJson={user?.isSuperAdmin ? copyDebugJson(post) : undefined} @@ -609,7 +626,7 @@ export const CalendarColumn: FC<{ const fetch = useFetch(); // Use shared post actions hook - const { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease } = usePostActions(); + const { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease, openPost } = usePostActions(); const postList = useMemo(() => { return posts.filter((post) => { const pList = dayjs.utc(post.publishDate).local(); @@ -886,6 +903,7 @@ export const CalendarColumn: FC<{ state={post.state} statistics={openStatistics(post.id)} missingRelease={openMissingRelease(post.id)} + openPost={openPost(post)} editPost={editPost(post, false)} duplicatePost={editPost(post, true)} copyDebugJson={user?.isSuperAdmin ? copyDebugJson(post) : undefined} @@ -998,6 +1016,7 @@ const CalendarItem: FC<{ deletePost: () => void; statistics: () => void; missingRelease?: () => void; + openPost: () => void; integrations: Integrations[]; state: State; display: 'day' | 'week' | 'month'; @@ -1023,6 +1042,7 @@ const CalendarItem: FC<{ deletePost, showTime, missingRelease, + openPost, } = props; const { disableXAnalytics } = useVariables(); const user = useUser(); @@ -1079,7 +1099,7 @@ const CalendarItem: FC<{ )}
- window.open( - // multi-target posts (several subreddits / communities / - // channels) join their URLs with commas: open the first one - post.releaseURL.split(',')[0], - '_blank', - 'noopener,noreferrer' - ) - } + onClick={openPost} >
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 4c63d3d728..835919fd73 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -187,6 +187,64 @@ export class PostsService { return { releaseId: resolved.postId, releaseURL: resolved.releaseURL }; } + async getReleaseURL( + orgId: string, + postId: string, + forceRefresh = false + ): Promise<{ releaseURL: string }> { + const post = await this._postRepository.getPostById(postId, orgId); + if (!post || !post.releaseURL) { + return { releaseURL: '' }; + } + + const integrationProvider = this._integrationManager.getSocialIntegration( + post.integration.providerIdentifier + ); + + if (!integrationProvider.resolveReleaseId) { + return { releaseURL: post.releaseURL }; + } + + const getIntegration = post.integration!; + + if ( + dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || + forceRefresh + ) { + const data = await this._refreshIntegrationService.refresh( + getIntegration + ); + if (!data) { + return { releaseURL: post.releaseURL }; + } + + const { accessToken } = data; + + if (accessToken) { + getIntegration.token = accessToken; + + if (integrationProvider.refreshWait) { + await timer(10000); + } + } else { + await this._integrationService.disconnectChannel(orgId, getIntegration); + return { releaseURL: post.releaseURL }; + } + } + + try { + const { releaseURL } = await this.resolveRelease(orgId, post); + return { releaseURL }; + } catch (e) { + console.log(e); + if (e instanceof RefreshToken) { + return this.getReleaseURL(orgId, postId, true); + } + } + + return { releaseURL: post.releaseURL }; + } + async checkPostAnalytics( orgId: string, postId: string, From fc5b4ca29f04b7c4bd9d34ab2341892fa982082a Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 2 Oct 2026 17:21:52 +0700 Subject: [PATCH 29/99] fix(calendar): open the post tab before the release URL request The open-post icon opened its tab only after awaiting the release URL request, so a browser could treat it as an unprompted popup and block it, most likely in Safari or when the request is slow (token refresh). The tab is now opened synchronously in the click and navigated once the request returns. The opener is cleared on the new tab, and a failed or empty response falls back to the URL loaded with the calendar. Co-Authored-By: Claude Fable 5.1 --- .../src/components/launches/calendar.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 01d15868d6..8aa88aa75e 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -259,16 +259,21 @@ const usePostActions = (onMutate?: () => void) => { const openPost = useCallback( (post: Post) => async () => { - const { releaseURL } = await ( - await fetch(`/posts/${post.id}/release-url`) - ).json(); + // opened before the request so popup blockers still see the click + const tab = window.open('', '_blank'); + if (!tab) { + return; + } + tab.opener = null; + let releaseURL = post.releaseURL; + try { + releaseURL = + (await (await fetch(`/posts/${post.id}/release-url`)).json()) + .releaseURL || releaseURL; + } catch (e) {} // multi-target posts (several subreddits / communities / channels) // join their URLs with commas: open the first one - window.open( - (releaseURL || post.releaseURL).split(',')[0], - '_blank', - 'noopener,noreferrer' - ); + tab.location.href = releaseURL.split(',')[0]; }, [fetch] ); From 4583ac5d62596b03a68731125e547238a9e4d9df Mon Sep 17 00:00:00 2001 From: Nevo David Date: Fri, 2 Oct 2026 19:13:46 +0700 Subject: [PATCH 30/99] feat: fix some tools --- .../src/chat/load.tools.service.ts | 10 ++++ .../src/chat/mcp.relay.service.ts | 53 ++++++++++++++----- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/libraries/nestjs-libraries/src/chat/load.tools.service.ts b/libraries/nestjs-libraries/src/chat/load.tools.service.ts index dcc2a85564..27371cb032 100644 --- a/libraries/nestjs-libraries/src/chat/load.tools.service.ts +++ b/libraries/nestjs-libraries/src/chat/load.tools.service.ts @@ -46,6 +46,16 @@ export class LoadToolsService { ); } + // Tools every install has, whatever features it configured + coreToolNames() { + return toolList + .map( + (p) => this._moduleRef.get(p, { strict: false }) as AgentToolInterface + ) + .filter((p) => !p.mcpOnly && !p.available) + .map((p) => p.name); + } + async agent() { const tools = await this.loadTools(); return new Agent({ diff --git a/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts b/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts index 7cb0bd0a92..1149d9bf65 100644 --- a/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts +++ b/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts @@ -168,11 +168,12 @@ export class McpRelayService { ); } - // Every tool this server lists has to run on the instance, an older one - // would answer some calls with errors - const missing = Object.keys(await this._loadToolsService.loadTools()).filter( - (name) => !tools.includes(name) - ); + // Every tool any install has must run on the instance, an older one + // would answer some calls with errors (tools of optional features, like + // clipping, depend on the instance setup, not its version) + const missing = this._loadToolsService + .coreToolNames() + .filter((name) => !tools.includes(name)); if (missing.length) { throw new HttpException( `Your Postiz instance is missing tools this connection needs (${missing.join(', ')}). Update it to the latest Postiz version and connect again`, @@ -211,12 +212,18 @@ export class McpRelayService { ); } - const result = await this.callTool( - instance, - name, - inputData, - context?.mcp?.extra?.signal - ); + let result: CallToolResult; + try { + result = await this.callTool( + instance, + name, + inputData, + context?.mcp?.extra?.signal + ); + } catch (err) { + return this.failure(tool, (err as Error).message); + } + const text = this.text(result); if (!tool.outputSchema) { return text; @@ -229,7 +236,8 @@ export class McpRelayService { try { output = JSON.parse(text); } catch { - throw new Error( + return this.failure( + tool, text || `${name} returned an unexpected answer from your Postiz instance` ); @@ -239,7 +247,8 @@ export class McpRelayService { // an instance on another Postiz version can answer in another shape const check = await tool.outputSchema['~standard']?.validate(output); if (check?.issues) { - throw new Error( + return this.failure( + tool, `Your Postiz instance answered ${name} in a format this connection does not know, update the instance to the latest Postiz version` ); } @@ -280,7 +289,7 @@ export class McpRelayService { // an instance older than this server doesn't have every tool yet if (text.startsWith('Unknown tool:')) { throw new Error( - `Your Postiz instance does not have ${name} yet, update it to the latest Postiz version to use it` + `${name} is not available on your Postiz instance: the feature it needs is not set up there, or the instance needs an update to the latest Postiz version` ); } // arguments this server validated but an older instance doesn't accept @@ -323,6 +332,22 @@ export class McpRelayService { } } + // A tool whose output has an `error` field answers normally with it (the + // clients count error results against the connector), any other one + // returns an error result + private async failure(tool: any, message: string) { + if (!tool.outputSchema) { + return message; + } + const check = await tool.outputSchema['~standard']?.validate({ + error: message, + }); + if (check && !check.issues) { + return { error: message }; + } + throw new Error(message); + } + private text(result: CallToolResult) { return (result.content || []) .filter((part) => part.type === 'text') From 6b102afb7eb535d677aec3302d7816e2a211fe89 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Fri, 2 Oct 2026 20:46:19 +0700 Subject: [PATCH 31/99] feat: destructiveHint --- chatgpt-app-submission.json | 4 ++-- .../nestjs-libraries/src/chat/tools/post.settings.tool.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index c2c1758e95..d9f9e78dec 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -84,12 +84,12 @@ "annotations": { "readOnlyHint": false, "openWorldHint": false, - "destructiveHint": false + "destructiveHint": true }, "justifications": { "read_only_justification": "Merges new provider settings into an existing draft or scheduled post.", "open_world_justification": "Only updates settings stored on an unpublished Postiz post and does not reach any external or publicly visible system.", - "destructive_justification": "Does not delete posts, change content, change publish dates, revoke access, or publish content immediately." + "destructive_justification": "Overwrites existing provider settings on an unpublished draft or scheduled post, which changes how it will be published; it does not delete posts, change content or dates, or publish immediately." } }, "generateVideoOptions": { diff --git a/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts b/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts index 99a95d3cb5..05346ca655 100644 --- a/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts @@ -17,7 +17,7 @@ export class PostSettingsTool implements AgentToolInterface { annotations: { title: 'Update Post Settings', readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: false, }, From f66844f7f9da56d7b17c182efb9d9e08ff93deae Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Fri, 2 Oct 2026 20:31:30 +0200 Subject: [PATCH 32/99] docs(security): a role address for time-critical reports instead of a personal one --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 911a28459e..d478464872 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,7 +66,7 @@ If you discover a security vulnerability in the Postiz app, report it through [G Email the maintainers only when the report requires immediate, time-critical attention. Email does not replace submitting the report through GAdvisory. -- @egelhaus ([E-Mail](mailto:egelhaus@ennogelhaus.de)) +- Security contact: [contact@gplatform.org](mailto:contact@gplatform.org) ### AI Reports From e25dd934d6b1085297f163c9e16c842d33e631ba Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Fri, 2 Oct 2026 20:36:16 +0200 Subject: [PATCH 33/99] feat(security): clarify new email entry --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index d478464872..277ff24a1a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,7 +66,7 @@ If you discover a security vulnerability in the Postiz app, report it through [G Email the maintainers only when the report requires immediate, time-critical attention. Email does not replace submitting the report through GAdvisory. -- Security contact: [contact@gplatform.org](mailto:contact@gplatform.org) +- Gelhaus Solutions (@egelhaus): [contact@gplatform.org](mailto:contact@gplatform.org) ### AI Reports From 2aae02fe034f3f788337ed0ac097304dfd0e4912 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sat, 3 Oct 2026 15:40:20 +0700 Subject: [PATCH 34/99] feat: inline picture --- apps/frontend/src/app/global.scss | 27 +++ .../launches/helpers/dnd.provider.tsx | 43 +++- .../src/components/media/media.component.tsx | 25 ++- .../src/components/new-launch/editor.tsx | 207 ++++++++++++++++-- .../providers/high.order.provider.tsx | 21 +- .../providers/listmonk/listmonk.provider.tsx | 1 + .../wordpress/wordpress.provider.tsx | 1 + .../new-launch/providers/x/x.provider.tsx | 1 + .../src/components/new-launch/store.ts | 8 + .../src/activities/post.activity.ts | 3 +- i18n.lock | 1 + .../src/utils/sanitize.post.content.ts | 14 ++ .../src/utils/strip.html.validation.ts | 30 ++- libraries/helpers/src/utils/valid.url.path.ts | 19 +- .../integrations/social/listmonk.provider.ts | 8 + .../social/social.integrations.interface.ts | 1 + .../integrations/social/wordpress.provider.ts | 4 + .../src/integrations/social/x.provider.ts | 116 +++++++--- .../translation/locales/ar/translation.json | 1 + .../translation/locales/bn/translation.json | 1 + .../translation/locales/de/translation.json | 1 + .../translation/locales/en/translation.json | 1 + .../translation/locales/es/translation.json | 1 + .../translation/locales/fr/translation.json | 1 + .../translation/locales/he/translation.json | 1 + .../translation/locales/it/translation.json | 1 + .../translation/locales/ja/translation.json | 1 + .../translation/locales/ko/translation.json | 1 + .../translation/locales/pt/translation.json | 1 + .../translation/locales/ru/translation.json | 1 + .../translation/locales/tr/translation.json | 1 + .../translation/locales/vi/translation.json | 1 + .../translation/locales/zh/translation.json | 1 + 33 files changed, 480 insertions(+), 65 deletions(-) diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index 0c6d5cc031..a6ea6d537a 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -604,6 +604,17 @@ html[dir='rtl'] [dir='ltr'] { color: #ae8afc; } +.ProseMirror img:not(.ProseMirror-separator) { + max-height: 300px; + margin: 16px 0; + border-radius: 6px; + cursor: grab; +} + +.ProseMirror img.ProseMirror-selectednode { + outline: 2px solid var(--new-btn-primary); +} + .ProseMirror ul, .preview ul { list-style: disc; @@ -637,6 +648,22 @@ html[dir='rtl'] [dir='ltr'] { font-weight: bold; } +// paragraphs stay tight, in most channels every line is a paragraph +.ProseMirror h1, +.ProseMirror h2, +.ProseMirror h3 { + margin: 16px 0 8px; + line-height: 1.3; +} + +.ProseMirror ul { + margin: 8px 0; +} + +.ProseMirror li + li { + margin-top: 4px; +} + .preview p { min-height: 24px; } diff --git a/apps/frontend/src/components/launches/helpers/dnd.provider.tsx b/apps/frontend/src/components/launches/helpers/dnd.provider.tsx index 82ff70c99c..2c5931f696 100644 --- a/apps/frontend/src/components/launches/helpers/dnd.provider.tsx +++ b/apps/frontend/src/components/launches/helpers/dnd.provider.tsx @@ -3,8 +3,49 @@ import { FC, ReactNode } from 'react'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { DndProvider } from 'react-dnd'; + +const DRAG_HANDLERS = [ + 'handleTopDragStart', + 'handleTopDragStartCapture', + 'handleTopDragEndCapture', + 'handleTopDragEnter', + 'handleTopDragEnterCapture', + 'handleTopDragLeaveCapture', + 'handleTopDragOver', + 'handleTopDragOverCapture', + 'handleTopDrop', + 'handleTopDropCapture', +]; + +// the path is kept from the start of the dispatch, the editor can re-render +// the target out of the page before the event bubbles up to the window +const isInsideEditor = (event: Event) => + event + .composedPath() + .some((node) => (node as HTMLElement).isContentEditable); + +// the backend listens to every drag on the window and cancels the native drops +// it doesn't own, so dragging text or pictures inside the post editor did nothing +const CalendarBackend: typeof HTML5Backend = (manager, context, options) => { + const backend = HTML5Backend(manager, context, options) as any; + for (const name of DRAG_HANDLERS) { + const handler = backend[name]; + if (typeof handler !== 'function') { + continue; + } + + backend[name] = (event: DragEvent) => { + if (!isInsideEditor(event)) { + handler(event); + } + }; + } + + return backend; +}; + export const DNDProvider: FC<{ children: ReactNode; }> = ({ children }) => { - return {children}; + return {children}; }; diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index 4eadb0e84c..eccfad8341 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -655,6 +655,7 @@ export const MultiMediaComponent: FC<{ onClose?: () => void; toolBar?: React.ReactNode; information?: React.ReactNode; + insertInContent?: (media: Array<{ id: string; path: string }>) => void; onChange: (event: { target: { name: string; @@ -679,6 +680,7 @@ export const MultiMediaComponent: FC<{ toolBar, information, mediaNotAvailable, + insertInContent, } = props; const user = useUser(); const modals = useModals(); @@ -704,6 +706,11 @@ export const MultiMediaComponent: FC<{ }[] ) => { const mediaArray = Array.isArray(m) ? m : [m]; + if (insertInContent) { + insertInContent(mediaArray); + return; + } + const newMedia = [...(currentMedia || []), ...mediaArray]; setCurrentMedia(newMedia); onChange({ @@ -713,7 +720,7 @@ export const MultiMediaComponent: FC<{ }, }); }, - [currentMedia] + [currentMedia, insertInContent] ); const showModal = useCallback(() => { modals.openModal({ @@ -724,10 +731,14 @@ export const MultiMediaComponent: FC<{ size: 'calc(100% - 80px)', height: 'calc(100% - 80px)', children: (close) => ( - + ), }); - }, [changeMedia, t]); + }, [changeMedia, insertInContent, t]); const clearMedia = useCallback( (topIndex: number) => () => { @@ -857,12 +868,16 @@ export const MultiMediaComponent: FC<{
- + {!insertInContent && ( + + )} {!!user?.tier?.ai && ( <> - + {!insertInContent && ( + + )} )}
diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx index 83214e2a07..83a55a98a8 100644 --- a/apps/frontend/src/components/new-launch/editor.tsx +++ b/apps/frontend/src/components/new-launch/editor.tsx @@ -38,6 +38,7 @@ import { useEditor, EditorContent, Extension, + Node, mergeAttributes, } from '@tiptap/react'; import Document from '@tiptap/extension-document'; @@ -50,12 +51,13 @@ import { History } from '@tiptap/extension-history'; import { BulletList, ListItem } from '@tiptap/extension-list'; import { Bullets } from '@gitroom/frontend/components/new-launch/bullets.component'; import Heading from '@tiptap/extension-heading'; +import { Slice } from '@tiptap/pm/model'; import { HeadingComponent } from '@gitroom/frontend/components/new-launch/heading.component'; import Mention from '@tiptap/extension-mention'; import { suggestion } from '@gitroom/frontend/components/new-launch/mention.component'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { AComponent } from '@gitroom/frontend/components/new-launch/a.component'; -import { Placeholder } from '@tiptap/extensions'; +import { Dropcursor, Placeholder, TrailingNode } from '@tiptap/extensions'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { InformationComponent } from '@gitroom/frontend/components/launches/information.component'; import { @@ -98,6 +100,48 @@ const InterceptUnderlineShortcut = Extension.create({ }, }); +const EditorImage = Node.create({ + name: 'image', + group: 'block', + draggable: true, + + addAttributes() { + return { + src: { default: null }, + alt: { default: null }, + }; + }, + + // same rule as the server sanitizer, any other source is dropped on save + parseHTML() { + return [{ tag: 'img[src^="https://"], img[src^="http://"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['img', HTMLAttributes]; + }, +}); + +const insertImages = ( + editor: any, + media: { path: string; alt?: string }[] +) => { + if (!media.length) { + return; + } + + editor + ?.chain() + ?.focus() + ?.insertContent( + media.map((p) => ({ + type: 'image', + attrs: { src: p.path, alt: p.alt }, + })) + ) + ?.run(); +}; + export const EditorWrapper: FC<{ totalPosts: number; value: string; @@ -130,6 +174,7 @@ export const EditorWrapper: FC<{ postComment, dummy, editor, + inlineImages, loadedState, setLoadedState, selectedIntegration, @@ -164,6 +209,7 @@ export const EditorWrapper: FC<{ appendGlobalValueMedia: state.appendGlobalValueMedia, postComment: state.postComment, editor: state.editor, + inlineImages: state.inlineImages, loadedState: state.loaded, setLoadedState: state.setLoaded, selectedIntegration: state.selectedIntegrations, @@ -436,6 +482,7 @@ export const EditorWrapper: FC<{ = (props) => { const { editorType = 'normal', + inlineImages = false, allValues, pictures, setImages, @@ -564,9 +613,18 @@ export const Editor: FC<{ const toaster = useToaster(); const editorRef = useRef(undefined); const [loading, setLoading] = useState(false); + // the uploader is created once, so it reads the latest value from here + const inlineImagesRef = useRef(inlineImages); + inlineImagesRef.current = inlineImages; const uppy = useUppyUploader({ onUploadSuccess: (result: any) => { + const editor = editorRef?.current?.editor; + if (inlineImagesRef.current && editor) { + insertImages(editor, result); + return; + } + appendImages(result); }, allowedFileTypes: 'image/*,video/mp4', @@ -574,8 +632,36 @@ export const Editor: FC<{ onEnd: () => setLoading(false), }); + // inline images go into the content, which only holds pictures + const filterFiles = useCallback( + (files: File[]) => { + if (!inlineImages) { + return files; + } + + const images = files.filter((file) => file.type.startsWith('image/')); + if (images.length < files.length) { + toaster.show( + t( + 'only_images_can_be_added_to_the_content', + 'Only images can be added to the content' + ), + 'warning' + ); + } + + return images; + }, + [inlineImages, toaster, t] + ); + const onDrop = useCallback( - (acceptedFiles: File[]) => { + (droppedFiles: File[]) => { + const acceptedFiles = filterFiles(droppedFiles); + if (!acceptedFiles.length) { + return; + } + const totalSize = acceptedFiles.reduce((acc, file) => acc + file.size, 0); if (totalSize > MAX_UPLOAD_SIZE) { @@ -595,31 +681,43 @@ export const Editor: FC<{ uppy.addFile(file); } }, - [uppy, toaster, t] + [uppy, toaster, t, filterFiles] ); const paste = useCallback( - async (event: ClipboardEvent | File[]) => { + (event: ClipboardEvent | File[], slice?: Slice) => { if (num > 0 && comments === 'no-media') { - return; + return false; } // @ts-ignore const clipboardItems = event.clipboardData?.items; if (!clipboardItems) { - return; + return false; } - const files: File[] = []; + const clipboardFiles: File[] = []; // @ts-ignore for (const item of clipboardItems) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { - files.push(file); + clipboardFiles.push(file); } } } + // 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'); + const uploadInline = + !!clipboardFiles.length && + (!html || !slice?.content.textBetween(0, slice.content.size).trim()); + if (inlineImages && !uploadInline) { + return false; + } + + const files = filterFiles(clipboardFiles); const totalSize = files.reduce((acc, file) => acc + file.size, 0); if (totalSize > MAX_UPLOAD_SIZE) { @@ -630,7 +728,7 @@ export const Editor: FC<{ ), 'warning' ); - return; + return false; } if (files.length > 0) { @@ -640,12 +738,41 @@ export const Editor: FC<{ for (const file of files) { uppy.addFile(file); } + + // the uploaded pictures replace the pasted html + return inlineImages; }, - [uppy, num, comments, toaster, t] + [uppy, num, comments, toaster, t, inlineImages, filterFiles] ); + // a drag that starts on the page (like moving a picture inside the editor) + // is not an upload, but Chrome reports a dragged as "Files" + const [internalDrag, setInternalDrag] = useState(false); + useEffect(() => { + if (!inlineImages) { + return; + } + + const dragStart = (event: DragEvent) => { + if (!event.defaultPrevented) { + setInternalDrag(true); + } + }; + const dragEnd = () => setInternalDrag(false); + + window.addEventListener('dragstart', dragStart); + window.addEventListener('dragend', dragEnd); + window.addEventListener('drop', dragEnd); + return () => { + window.removeEventListener('dragstart', dragStart); + window.removeEventListener('dragend', dragEnd); + window.removeEventListener('drop', dragEnd); + setInternalDrag(false); + }; + }, [inlineImages]); + const { getRootProps, isDragActive } = useDropzone({ - onDrop: (files) => { + onDrop: (files, _, event) => { if (loading) { toaster.show( 'Upload current in progress, please wait and then try again.', @@ -653,9 +780,23 @@ export const Editor: FC<{ ); return; } + + if (inlineImages) { + // uploads land at the cursor, move it to where the files were dropped + const editor = editorRef?.current?.editor; + const { clientX, clientY } = event as DragEvent; + const position = editor?.view?.posAtCoords({ + left: clientX, + top: clientY, + }); + if (position) { + editor.commands.focus(position.pos); + } + } + onDrop(files); }, - noDrag: num > 0 && comments === 'no-media', + noDrag: internalDrag || (num > 0 && comments === 'no-media'), }); const valueWithoutHtml = useMemo(() => { @@ -670,15 +811,17 @@ export const Editor: FC<{ [props.value, id] ); - const [loadedEditor, setLoadedEditor] = useState(editorType); + // the extensions are fixed once the editor is created, rebuild it when they change + const editorKey = `${editorType}-${inlineImages}`; + const [loadedEditor, setLoadedEditor] = useState(editorKey); const [showEditor, setShowEditor] = useState(true); useEffect(() => { - if (editorType === loadedEditor) { + if (editorKey === loadedEditor) { return; } - setLoadedEditor(editorType); + setLoadedEditor(editorKey); setShowEditor(false); - }, [editorType]); + }, [editorKey]); useEffect(() => { if (showEditor) { @@ -716,6 +859,7 @@ export const Editor: FC<{ + insertImages(editorRef?.current?.editor, media) + : undefined + } information={ 0} @@ -859,11 +1009,12 @@ export const OnlyEditor = forwardRef< any, { editorType: 'none' | 'normal' | 'markdown' | 'html'; + inlineImages?: boolean; value: string; onChange: (value: string) => void; - paste?: (event: ClipboardEvent | File[]) => void; + paste?: (event: ClipboardEvent | File[], slice?: Slice) => boolean; } ->(({ editorType, value, onChange, paste }, ref) => { +>(({ editorType, inlineImages, value, onChange, paste }, ref) => { const t = useT(); const fetch = useFetch(); @@ -1023,6 +1174,21 @@ export const OnlyEditor = forwardRef< }), ] : []), + ...(inlineImages + ? [ + EditorImage, + Dropcursor.configure({ + color: 'var(--new-btn-primary)', + width: 2, + }), + // a picture can't hold the cursor, keep a paragraph after a + // trailing one so clicking below the content doesn't select it + TrailingNode.configure({ + node: 'paragraph', + notAfter: ['heading', 'bulletList'], + }), + ] + : []), History.configure({ depth: 100, // default is 100 newGroupDelay: 100, // default is 500ms @@ -1031,8 +1197,9 @@ export const OnlyEditor = forwardRef< content: value || '', shouldRerenderOnTransaction: true, immediatelyRender: false, - // @ts-ignore - onPaste: paste, + editorProps: { + handlePaste: (view, event, slice) => !!paste?.(event as any, slice), + }, onUpdate: (innerProps) => { onChange?.(innerProps.editor.getHTML()); }, diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx index 1d31a2aedb..58087ce66d 100644 --- a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx @@ -8,7 +8,7 @@ import React, { useImperativeHandle, useMemo, } from 'react'; -import { useForm, FormProvider } from 'react-hook-form'; +import { useForm, useWatch, FormProvider } from 'react-hook-form'; import { IsOptional } from 'class-validator'; import { classValidatorResolver } from '@hookform/resolvers/class-validator'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; @@ -49,6 +49,7 @@ export const withProvider = function (params: { }>; dto?: any; maximumCharacters?: number | ((settings: any) => number); + inlineImages?: boolean | ((values: any) => boolean); }) { const { postComment, @@ -75,6 +76,7 @@ export const withProvider = function (params: { allIntegrations, setPostComment, setEditor, + setInlineImages, dummy, setChars, setComments, @@ -97,6 +99,7 @@ export const withProvider = function (params: { setTotalChars: state.setTotalChars, setPostComment: state.setPostComment, setEditor: state.setEditor, + setInlineImages: state.setInlineImages, setChars: state.setChars, selectedIntegration: state.selectedIntegrations.find( (p) => p.integration.id === props.id @@ -125,6 +128,7 @@ export const withProvider = function (params: { setPostComment(PostComment.ALL); setTotalChars(0); setEditor('normal'); + setInlineImages(false); } if (current) { @@ -178,6 +182,21 @@ export const withProvider = function (params: { reValidateMode: 'onChange', }); + // the post settings can turn inline images on, like an X article + const values = useWatch({ + control: form.control, + disabled: typeof params.inlineImages !== 'function', + }); + const inlineImages = + typeof params.inlineImages === 'function' + ? params.inlineImages(values) + : !!params.inlineImages; + useEffect(() => { + if (current) { + setInlineImages(inlineImages); + } + }, [current, inlineImages]); + useImperativeHandle( ref, () => ({ diff --git a/apps/frontend/src/components/new-launch/providers/listmonk/listmonk.provider.tsx b/apps/frontend/src/components/new-launch/providers/listmonk/listmonk.provider.tsx index 05bb3fa902..462405b868 100644 --- a/apps/frontend/src/components/new-launch/providers/listmonk/listmonk.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/listmonk/listmonk.provider.tsx @@ -30,4 +30,5 @@ export default withProvider({ CustomPreviewComponent: undefined, dto: ListmonkDto, maximumCharacters: 300000, + inlineImages: true, }); diff --git a/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx index 7876038fc2..7ad52173ed 100644 --- a/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx @@ -50,4 +50,5 @@ export default withProvider({ CustomPreviewComponent: undefined, // WordpressPreview, dto: WordpressDto, maximumCharacters: 100000, + inlineImages: true, }); diff --git a/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx b/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx index 11625b4319..e98bd0c1cd 100644 --- a/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx @@ -145,4 +145,5 @@ export default withProvider({ } return 280; }, + inlineImages: (values) => values?.post_type === 'article', }); diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts index 2ea620b826..1c0f517962 100644 --- a/apps/frontend/src/components/new-launch/store.ts +++ b/apps/frontend/src/components/new-launch/store.ts @@ -27,6 +27,7 @@ export interface SelectedIntegrations { interface StoreState { editor: undefined | 'none' | 'normal' | 'markdown' | 'html'; + inlineImages: boolean; loaded: boolean; date: dayjs.Dayjs; postComment: PostComment; @@ -129,6 +130,7 @@ interface StoreState { setActivateExitButton?: (activateExitButton: boolean) => void; setDummy: (dummy: boolean) => void; setEditor: (editor: 'none' | 'normal' | 'markdown' | 'html') => void; + setInlineImages: (inlineImages: boolean) => void; setLoaded?: (loaded: boolean) => void; setChars: (id: string, chars: number) => void; chars: Record; @@ -137,6 +139,7 @@ interface StoreState { const initialState = { editor: undefined as undefined, + inlineImages: false, loaded: true, dummy: false, comments: true, @@ -187,6 +190,7 @@ export const useLaunchStore = create()((set) => ({ ? { current: 'global', editor: 'normal', + inlineImages: false, } : {}), }; @@ -617,6 +621,10 @@ export const useLaunchStore = create()((set) => ({ set((state) => ({ editor, })), + setInlineImages: (inlineImages: boolean) => + set((state) => ({ + inlineImages, + })), setLoaded: (loaded: boolean) => set((state) => ({ loaded, diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index 5c18c34759..ace2804856 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -361,7 +361,8 @@ export class PostActivity { true, false, !/<\/?[a-z][\s\S]*>/i.test(p.content), - getIntegration.mentionFormat + getIntegration.mentionFormat, + !!getIntegration.inlineImages?.(JSON.parse(p.settings || '{}')) ), settings: JSON.parse(p.settings || '{}'), media: await this._postService.updateMedia( diff --git a/i18n.lock b/i18n.lock index 1f9a81b9d9..93a43fef56 100644 --- a/i18n.lock +++ b/i18n.lock @@ -706,6 +706,7 @@ checksums: delete_post_tooltip: bcf081392c76d2f2a8522944ddd36b78 drop_files_here_to_upload: a7971302d02a19a461c519cffedfa5e5 insert_emoji: 6e2ab0e239c0ee87d385b1cd17185e00 + only_images_can_be_added_to_the_content: 39229242be2e514ce641c5edce713caa write_something: bc9257cede62880c411001e61310ca43 click_channel_to_add: 7dc67c45eba252d1fa931e5a101d568b connect_your_channels: a8303310a9cc5f4e11510f8f55c0978d diff --git a/libraries/helpers/src/utils/sanitize.post.content.ts b/libraries/helpers/src/utils/sanitize.post.content.ts index 204fee139f..d7a571069c 100644 --- a/libraries/helpers/src/utils/sanitize.post.content.ts +++ b/libraries/helpers/src/utils/sanitize.post.content.ts @@ -13,6 +13,7 @@ const ALLOWED_TAGS = [ 'h2', 'h3', 'span', + 'img', ]; const ALLOWED_ATTR = [ @@ -22,8 +23,21 @@ const ALLOWED_ATTR = [ 'class', 'data-mention-id', 'data-mention-label', + 'src', + 'alt', ]; +// keeps data: URIs whatever ALLOWED_URI_REGEXP says, so a picture +// that doesn't point to a real file is dropped +DOMPurify.addHook('uponSanitizeElement', (node, data) => { + if ( + data.tagName === 'img' && + !/^https?:\/\//i.test((node as Element).getAttribute('src') || '') + ) { + node.parentNode?.removeChild(node); + } +}); + export const sanitizePostContent = (value: unknown): string => { if (typeof value !== 'string' || !value) { return ''; diff --git a/libraries/helpers/src/utils/strip.html.validation.ts b/libraries/helpers/src/utils/strip.html.validation.ts index d780fc7145..0b1c9eadab 100644 --- a/libraries/helpers/src/utils/strip.html.validation.ts +++ b/libraries/helpers/src/utils/strip.html.validation.ts @@ -137,7 +137,8 @@ export const stripHtmlValidation = ( replaceBold = false, none = false, plain = false, - convertMentionFunction?: (idOrHandle: string, name: string) => string + convertMentionFunction?: (idOrHandle: string, name: string) => string, + inlineImages = false ): string => { if (plain) { return val; @@ -156,7 +157,12 @@ export const stripHtmlValidation = ( } if (type === 'html') { - return striptags(convertMention(value, convertMentionFunction), [ + return striptags( + convertMention( + inlineImages ? convertImages(value) : value, + convertMentionFunction + ), + [ 'ul', 'li', 'h1', @@ -166,7 +172,9 @@ export const stripHtmlValidation = ( 'strong', 'u', 'a', - ]) + ...(inlineImages ? ['img'] : []), + ] + ) .replace(/>/gi, '>') .replace(/</gi, '<') .replace(/&/gi, '&') @@ -268,6 +276,22 @@ export const stripHtmlValidation = ( .replace(/</gi, '<'); }; +// keeps only an http(s) src and the alt of a picture, a value holding a quote +// is dropped since " is decoded afterwards and would close the attribute +export const convertImages = (value: string) => { + return value.replace(/]*>/gi, (match) => { + const src = /\ssrc="(https?:\/\/[^"]*)"/i.exec(match)?.[1]; + const alt = /\salt="([^"]*)"/i.exec(match)?.[1]; + if (!src || src.includes('"')) { + return ''; + } + + return ``; + }); +}; + export const convertMention = ( value: string, process?: (idOrHandle: string, name: string) => string diff --git a/libraries/helpers/src/utils/valid.url.path.ts b/libraries/helpers/src/utils/valid.url.path.ts index aeedbe4158..c270c27c04 100644 --- a/libraries/helpers/src/utils/valid.url.path.ts +++ b/libraries/helpers/src/utils/valid.url.path.ts @@ -25,16 +25,21 @@ export class ValidUrlExtension implements ValidatorConstraintInterface { } } +// also used for media urls that don't come through a DTO, like pictures in a post body +export const isAllowedUploadPath = (text: string) => { + if (!process.env.RESTRICT_UPLOAD_DOMAINS) { + return true; + } + + return ( + (text || 'invalid url').indexOf(process.env.RESTRICT_UPLOAD_DOMAINS) > -1 + ); +}; + @ValidatorConstraint({ name: 'checkValidPath', async: false }) export class ValidUrlPath implements ValidatorConstraintInterface { validate(text: string, args: ValidationArguments) { - if (!process.env.RESTRICT_UPLOAD_DOMAINS) { - return true; - } - - return ( - (text || 'invalid url').indexOf(process.env.RESTRICT_UPLOAD_DOMAINS) > -1 - ); + return isAllowedUploadPath(text); } defaultMessage(args: ValidationArguments) { diff --git a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts index 9366e50306..86d2ee20cc 100644 --- a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts @@ -26,6 +26,10 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider { return 100000000; } + inlineImages() { + return true; + } + async customFields() { return [ { @@ -189,6 +193,10 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider { font-size: 15px; line-height: 1.6; } +.content img { + max-width: 100%; + height: auto; +} )} - {tab === 'teams' && !!user?.tier?.team_members && isGeneral && ( -
- -
- )} + {tab === 'teams' && + !!user?.tier?.team_members && + isGeneral && + user?.role !== 'USER' && ( +
+ +
+ )} {tab === 'webhooks' && !!user?.tier?.webhooks && (
diff --git a/apps/frontend/src/components/settings/teams.component.tsx b/apps/frontend/src/components/settings/teams.component.tsx index 070ed9b298..5033ac47a0 100644 --- a/apps/frontend/src/components/settings/teams.component.tsx +++ b/apps/frontend/src/components/settings/teams.component.tsx @@ -50,12 +50,14 @@ export const AddMember = () => { }); const submit = useCallback( async (values: { email: string; role: string; sendEmail: boolean }) => { - const { url } = await ( - await fetch('/settings/team', { - method: 'POST', - body: JSON.stringify(values), - }) - ).json(); + const response = await fetch('/settings/team', { + method: 'POST', + body: JSON.stringify(values), + }); + if (!response.ok) { + return; + } + const { url } = await response.json(); if (values.sendEmail) { modals.closeAll(); toast.show(t('invitation_link_sent', 'Invitation link sent')); From 185044d5004afd5cacf0fb635f34d49a2be476a1 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 10:44:35 +0700 Subject: [PATCH 37/99] feat: fix workflows without continue as new --- .../src/workflows/autopost.workflow.ts | 20 ++++++++++++++++++- .../src/workflows/missing.post.workflow.ts | 18 ++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/orchestrator/src/workflows/autopost.workflow.ts b/apps/orchestrator/src/workflows/autopost.workflow.ts index 62afe98252..8aa025747d 100644 --- a/apps/orchestrator/src/workflows/autopost.workflow.ts +++ b/apps/orchestrator/src/workflows/autopost.workflow.ts @@ -1,4 +1,10 @@ -import { proxyActivities, sleep } from '@temporalio/workflow'; +import { + continueAsNew, + patched, + proxyActivities, + sleep, + workflowInfo, +} from '@temporalio/workflow'; import { AutopostActivity } from '@gitroom/orchestrator/activities/autopost.activity'; const { autoPost } = proxyActivities({ @@ -18,6 +24,7 @@ export async function autoPostWorkflow({ id: string; immediately: boolean; }) { + let loops = 0; while (true) { try { if (immediately) { @@ -26,5 +33,16 @@ export async function autoPostWorkflow({ } catch (err) {} immediately = true; await sleep(3600000); + + // Keep the history small, runs started before this patch continue-as-new on their first live loop + if ( + patched('autopost-continue-as-new') && + (++loops >= 24 || workflowInfo().historyLength > 1000) + ) { + return await continueAsNew({ + id, + immediately: true, + }); + } } } diff --git a/apps/orchestrator/src/workflows/missing.post.workflow.ts b/apps/orchestrator/src/workflows/missing.post.workflow.ts index 17babfd236..427a1b71bb 100644 --- a/apps/orchestrator/src/workflows/missing.post.workflow.ts +++ b/apps/orchestrator/src/workflows/missing.post.workflow.ts @@ -1,4 +1,10 @@ -import { proxyActivities, sleep } from '@temporalio/workflow'; +import { + continueAsNew, + patched, + proxyActivities, + sleep, + workflowInfo, +} from '@temporalio/workflow'; import { PostActivity } from '@gitroom/orchestrator/activities/post.activity'; const { searchForMissingThreeHoursPosts } = proxyActivities({ @@ -11,9 +17,19 @@ const { searchForMissingThreeHoursPosts } = proxyActivities({ }); export async function missingPostWorkflow() { + let loops = 0; await searchForMissingThreeHoursPosts(); while (true) { await sleep('1 hour'); + + // Keep the history small, the new run searches immediately so the hourly cadence is kept + if ( + patched('missing-post-continue-as-new') && + (++loops >= 24 || workflowInfo().historyLength > 1000) + ) { + return await continueAsNew(); + } + await searchForMissingThreeHoursPosts(); } } From 1f5f24e5fd55e791def5b78560c6f896541c414f Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 12:05:10 +0700 Subject: [PATCH 38/99] feat: workflow changes --- .../backend/src/services/auth/auth.service.ts | 14 ++-- .../src/workflows/send.email.workflow.ts | 20 +++++ .../src/emails/resend.provider.ts | 24 +++--- .../src/services/email.service.ts | 14 ++-- .../src/temporal/email.queues.ts | 12 +++ .../src/temporal/temporal.module.ts | 84 +++++++++++-------- 6 files changed, 107 insertions(+), 61 deletions(-) create mode 100644 libraries/nestjs-libraries/src/temporal/email.queues.ts diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index eceac92ef5..31e2c528c1 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -7,7 +7,6 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service'; import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager'; import dayjs from 'dayjs'; -import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto'; import { EmailService } from '@gitroom/nestjs-libraries/services/email.service'; import { NewsletterService } from '@gitroom/nestjs-libraries/newsletter/newsletter.service'; @@ -17,7 +16,6 @@ export class AuthService { constructor( private _userService: UsersService, private _organizationService: OrganizationService, - private _notificationService: NotificationService, private _emailService: EmailService, private _providerManager: AuthProviderManager ) {} @@ -73,11 +71,10 @@ export class AuthService { : false; const obj = { addedOrg, jwt: await this.jwt(create.users[0].user) }; - await this._emailService.sendEmail( + await this._emailService.sendEmailSync( body.email, 'Activate your account', - `Click here to activate your account`, - 'top' + `Click here to activate your account` ); return obj; } @@ -225,7 +222,7 @@ export class AuthService { expires: dayjs().add(20, 'minutes').format('YYYY-MM-DD HH:mm:ss'), }); - await this._notificationService.sendEmail( + await this._emailService.sendEmailSync( user.email, 'Reset your password', `You have requested to reset your passsord.
Click here to reset your password
The link will expire in 20 minutes` @@ -278,11 +275,10 @@ export class AuthService { const jwt = await this.jwt(user); - await this._emailService.sendEmail( + await this._emailService.sendEmailSync( user.email, 'Activate your account', - `Click here to activate your account`, - 'top' + `Click here to activate your account` ); return true; diff --git a/apps/orchestrator/src/workflows/send.email.workflow.ts b/apps/orchestrator/src/workflows/send.email.workflow.ts index fc1038880f..82c7062932 100644 --- a/apps/orchestrator/src/workflows/send.email.workflow.ts +++ b/apps/orchestrator/src/workflows/send.email.workflow.ts @@ -10,6 +10,7 @@ import { SendEmail, sendEmailSignal, } from '@gitroom/orchestrator/signals/send.email.signal'; +import { emailQueues } from '@gitroom/nestjs-libraries/temporal/email.queues'; const { sendEmail } = proxyActivities({ startToCloseTimeout: '10 minute', @@ -17,8 +18,27 @@ const { sendEmail } = proxyActivities({ cancellationType: 'ABANDON', }); +const { sendEmail: sendPriorityEmail } = proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue: emailQueues.priority.taskQueue, + cancellationType: 'ABANDON', +}); + +const { sendEmail: sendBulkEmail } = proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue: emailQueues.bulk.taskQueue, + cancellationType: 'ABANDON', +}); + +// One workflow per email, the Resend rate limit is enforced on the email task queues +export async function sendSingleEmailWorkflow(email: SendEmail) { + const send = email.addTo === 'top' ? sendPriorityEmail : sendBulkEmail; + await send(email.to, email.subject, email.html, email.replyTo); +} + const RATE_LIMIT_MS = 700; +// Deprecated: no longer started, kept registered so the running `send_email` can drain its queue export async function sendEmailWorkflow({ queue = [], }: { diff --git a/libraries/nestjs-libraries/src/emails/resend.provider.ts b/libraries/nestjs-libraries/src/emails/resend.provider.ts index c0519b0f11..6aa9bd467f 100644 --- a/libraries/nestjs-libraries/src/emails/resend.provider.ts +++ b/libraries/nestjs-libraries/src/emails/resend.provider.ts @@ -14,20 +14,20 @@ export class ResendProvider implements EmailInterface { emailFromAddress: string, replyTo?: string ) { - try { - const sends = await resend.emails.send({ - from: `${emailFromName} <${emailFromAddress}>`, - to, - subject, - html, - ...(replyTo && { reply_to: replyTo }), - }); + const sends = await resend.emails.send({ + from: `${emailFromName} <${emailFromAddress}>`, + to, + subject, + html, + ...(replyTo && { reply_to: replyTo }), + }); - return sends; - } catch (err) { - console.log(err); + // The SDK returns API errors (like 429 rate limits) instead of throwing, + // throw so sendEmailSync retries the send + if (sends.error) { + throw new Error(`${sends.error.name}: ${sends.error.message}`); } - return { sent: false }; + return sends; } } diff --git a/libraries/nestjs-libraries/src/services/email.service.ts b/libraries/nestjs-libraries/src/services/email.service.ts index 75157f58d4..b2d31f68fa 100644 --- a/libraries/nestjs-libraries/src/services/email.service.ts +++ b/libraries/nestjs-libraries/src/services/email.service.ts @@ -5,6 +5,7 @@ import { EmptyProvider } from '@gitroom/nestjs-libraries/emails/empty.provider'; import { NodeMailerProvider } from '@gitroom/nestjs-libraries/emails/node.mailer.provider'; import { TemporalService } from 'nestjs-temporal-core'; import { timer } from '@gitroom/helpers/utils/timer'; +import { randomUUID } from 'crypto'; @Injectable() export class EmailService { @@ -41,15 +42,16 @@ export class EmailService { addTo: 'top' | 'bottom', replyTo?: string ) { + if (!to || !subject) { + return; + } + return this._temporalService.client .getRawClient() - ?.workflow.signalWithStart('sendEmailWorkflow', { + ?.workflow.start('sendSingleEmailWorkflow', { taskQueue: 'main', - workflowId: 'send_email', - signal: 'sendEmail', - args: [{ queue: [] }], - signalArgs: [{ to, subject, html, replyTo, addTo }], - workflowIdConflictPolicy: 'USE_EXISTING', + workflowId: `send_email_${randomUUID()}`, + args: [{ to, subject, html, replyTo, addTo }], }); } diff --git a/libraries/nestjs-libraries/src/temporal/email.queues.ts b/libraries/nestjs-libraries/src/temporal/email.queues.ts new file mode 100644 index 0000000000..18ee1898c6 --- /dev/null +++ b/libraries/nestjs-libraries/src/temporal/email.queues.ts @@ -0,0 +1,12 @@ +// Resend allows 10 requests per second for the whole team (ratelimit-limit +// header). The limits are enforced by the Temporal server across every worker +// polling the queue. The server splits a queue's rate over its 4 partitions, +// so after an idle period a burst can briefly go above the rate. 5/s together +// keeps that burst small, sends rejected by Resend are retried by +// sendEmailSync, and the rest is left for the auth emails sent directly. +export const emailQueues = { + // Notifications, billing, invites ('top') + priority: { taskQueue: 'email', perSecond: 3 }, + // Digests and streak emails ('bottom') + bulk: { taskQueue: 'email-bulk', perSecond: 2 }, +}; diff --git a/libraries/nestjs-libraries/src/temporal/temporal.module.ts b/libraries/nestjs-libraries/src/temporal/temporal.module.ts index f188933d30..74a98f35fc 100644 --- a/libraries/nestjs-libraries/src/temporal/temporal.module.ts +++ b/libraries/nestjs-libraries/src/temporal/temporal.module.ts @@ -1,5 +1,6 @@ import { TemporalModule } from 'nestjs-temporal-core'; import { socialIntegrationList } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { emailQueues } from '@gitroom/nestjs-libraries/temporal/email.queues'; export const getTemporalModule = ( isWorkers: boolean, @@ -38,46 +39,61 @@ export const getTemporalModule = ( ...(isWorkers ? { workers: [ - { identifier: 'main', maxConcurrentJob: undefined }, - ...socialIntegrationList, - ] - .filter((f) => f.identifier.indexOf('-') === -1) - .map((integration) => ({ - integration, - taskQueue: integration.identifier.split('-')[0], - })) - .filter(({ taskQueue }) => !excludeQueues.includes(taskQueue)) - .map(({ integration, taskQueue }) => { - // Split the per-provider cap across the servers sharing this - // queue. Floor (never below 1) so the global total never exceeds - // the provider's limit. Providers whose limit is smaller than the - // server count must be pinned via EXCLUDE_QUEUE instead. - const concurrency = integration.maxConcurrentJob - ? Math.max( - 1, - Math.floor(integration.maxConcurrentJob / divider) - ) - : undefined; + ...[ + { identifier: 'main', maxConcurrentJob: undefined }, + ...socialIntegrationList, + ] + .filter((f) => f.identifier.indexOf('-') === -1) + .map((integration) => ({ + integration, + taskQueue: integration.identifier.split('-')[0], + })) + .filter(({ taskQueue }) => !excludeQueues.includes(taskQueue)) + .map(({ integration, taskQueue }) => { + // Split the per-provider cap across the servers sharing this + // queue. Floor (never below 1) so the global total never exceeds + // the provider's limit. Providers whose limit is smaller than the + // server count must be pinned via EXCLUDE_QUEUE instead. + const concurrency = integration.maxConcurrentJob + ? Math.max( + 1, + Math.floor(integration.maxConcurrentJob / divider) + ) + : undefined; - // Workflows only ever run on the `main` queue; the other Workers - // are activity-only, so skip the workflow bundle (webpack build, - // workflow thread + V8 isolate, sticky cache) on them. - return { + // Workflows only ever run on the `main` queue; the other Workers + // are activity-only, so skip the workflow bundle (webpack build, + // workflow thread + V8 isolate, sticky cache) on them. + return { + taskQueue, + ...(taskQueue === 'main' ? { workflowsPath: path! } : {}), + activityClasses: activityClasses!, + autoStart: true, + workerOptions: { + maxConcurrentActivityTaskExecutions: concurrency || 1000000, + // By default the SDK throttles heartbeat sends to 60s, so + // against the workflow's heartbeatTimeout one dropped send + // or a minute of event-loop lag eats most of the margin. + // Sending every 15s keeps the recorded heartbeat fresh even + // when individual sends fail or fire late. + maxHeartbeatThrottleInterval: '15s', + }, + }; + }), + // Activity-only Workers for emails, rate limited server side + // across all workers to stay under the Resend limit + ...Object.values(emailQueues) + .filter(({ taskQueue }) => !excludeQueues.includes(taskQueue)) + .map(({ taskQueue, perSecond }) => ({ taskQueue, - ...(taskQueue === 'main' ? { workflowsPath: path! } : {}), activityClasses: activityClasses!, autoStart: true, workerOptions: { - maxConcurrentActivityTaskExecutions: concurrency || 1000000, - // By default the SDK throttles heartbeat sends to 60s, so - // against the workflow's heartbeatTimeout one dropped send - // or a minute of event-loop lag eats most of the margin. - // Sending every 15s keeps the recorded heartbeat fresh even - // when individual sends fail or fire late. - maxHeartbeatThrottleInterval: '15s', + maxConcurrentActivityTaskExecutions: 10, + maxTaskQueueActivitiesPerSecond: perSecond, }, - }; - }), + })), + ], } : {}), }); From f65de34e2e13e88b0ca2b72530a4379702700dc3 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 12:12:39 +0700 Subject: [PATCH 39/99] feat: redeploy backend and mcp Co-Authored-By: Claude Opus 5.5 From 36834cda235620b345d6d28b88638fabf7716356 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 12:14:20 +0700 Subject: [PATCH 40/99] fix: typo in password reset email Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/services/auth/auth.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index 31e2c528c1..d52c553ec7 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -225,7 +225,7 @@ export class AuthService { await this._emailService.sendEmailSync( user.email, 'Reset your password', - `You have requested to reset your passsord.
Click here to reset your password
The link will expire in 20 minutes` + `You have requested to reset your password.
Click here to reset your password
The link will expire in 20 minutes` ); } From 273c7b504cb6248a63c59e911971fd3173ceb125 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 12:35:35 +0700 Subject: [PATCH 41/99] feat: streak load --- .../src/activities/email.activity.ts | 5 ++ .../src/activities/post.activity.ts | 5 +- .../src/workflows/streak.workflow.ts | 72 ++++++++++++++++++- .../organizations/organization.repository.ts | 26 +++++++ .../organizations/organization.service.ts | 4 ++ 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/apps/orchestrator/src/activities/email.activity.ts b/apps/orchestrator/src/activities/email.activity.ts index 8ecfc236e6..377c2fe304 100644 --- a/apps/orchestrator/src/activities/email.activity.ts +++ b/apps/orchestrator/src/activities/email.activity.ts @@ -30,4 +30,9 @@ export class EmailActivity { async setStreak(organizationId: string, type: 'start' | 'end') { return this._organizationService.setStreak(organizationId, type); } + + @ActivityMethod() + async getLastPublishDate(organizationId: string) { + return this._organizationService.getLastPublishDate(organizationId); + } } diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index ace2804856..419f61e0df 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -395,11 +395,12 @@ export class PostActivity { try { await this._temporalService.client .getRawClient() - .workflow.start('streakWorkflow', { + .workflow.start('streakWorkflowV2', { args: [{ organizationId: integration.organizationId }], workflowId: `streak_${integration.organizationId}`, taskQueue: 'main', - workflowIdConflictPolicy: 'TERMINATE_EXISTING', + // Keep a running streak, it checks for newer posts before it ends + workflowIdConflictPolicy: 'USE_EXISTING', typedSearchAttributes: new TypedSearchAttributes([ { key: organizationId, diff --git a/apps/orchestrator/src/workflows/streak.workflow.ts b/apps/orchestrator/src/workflows/streak.workflow.ts index 28845e4d37..e4f30bad8b 100644 --- a/apps/orchestrator/src/workflows/streak.workflow.ts +++ b/apps/orchestrator/src/workflows/streak.workflow.ts @@ -1,13 +1,21 @@ -import { patched, proxyActivities, sleep } from '@temporalio/workflow'; +import { + continueAsNew, + makeContinueAsNewFunc, + patched, + proxyActivities, + sleep, +} from '@temporalio/workflow'; import { EmailActivity } from '@gitroom/orchestrator/activities/email.activity'; -const { sendEmailAsync, getUserOrgs, setStreak } = +const { sendEmailAsync, getUserOrgs, setStreak, getLastPublishDate } = proxyActivities({ startToCloseTimeout: '10 minute', taskQueue: 'main', cancellationType: 'ABANDON', }); +const DAY = 86400000; + export async function streakWorkflow({ organizationId, }: { @@ -33,6 +41,17 @@ export async function streakWorkflow({ await sleep(7200000); + // Posts no longer restart this workflow, hand the streak over to + // streakWorkflowV2 if the organization posted since it started + if (patched('streak-last-post')) { + const lastPost = await getLastPublishDate(organizationId); + if (lastPost && lastPost + DAY > Date.now()) { + return await makeContinueAsNewFunc({ + workflowType: 'streakWorkflowV2', + })({ organizationId, lastPost }); + } + } + if (patched('reminder')) { for (const user of userOrgs.users) { if (!user.user.sendStreakEmails) { @@ -48,3 +67,52 @@ export async function streakWorkflow({ } await setStreak(organizationId, 'end'); } + +// One run per organization that posts don't restart: it wakes up when the +// last post is a day old and ends the streak if nothing newer was published +export async function streakWorkflowV2({ + organizationId, + lastPost, +}: { + organizationId: string; + lastPost?: number; +}) { + if (lastPost === undefined) { + // Started by a publish + await setStreak(organizationId, 'start'); + lastPost = Date.now(); + } + + for (let check = 0; check < 30; check++) { + await sleep(Math.max(1000, lastPost + DAY - Date.now())); + lastPost = Math.max( + lastPost, + (await getLastPublishDate(organizationId)) || 0 + ); + + if (lastPost + DAY > Date.now()) { + continue; + } + + const userOrgs = await getUserOrgs(organizationId); + for (const user of userOrgs.users) { + if (!user.user.sendStreakEmails) { + continue; + } + await sendEmailAsync( + user.user.email, + 'Streak Ended', + "

Your streak has ended! You didn't post anything in the last 24 hours. Schedule a post now to start a new streak!

", + 'bottom' + ); + } + await setStreak(organizationId, 'end'); + return; + } + + // Keep the history small for organizations that post every day + return await continueAsNew({ + organizationId, + lastPost, + }); +} diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts index ad06901738..f70f71c9d2 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -528,6 +528,32 @@ export class OrganizationRepository { } catch (err) {} } + async getLastPublishDate(organizationId: string) { + const org = await this._organization.model.organization.findUnique({ + where: { + id: organizationId, + }, + select: { + post: { + where: { + state: 'PUBLISHED', + }, + orderBy: { + publishDate: 'desc', + }, + take: 1, + select: { + publishDate: true, + }, + }, + }, + }); + + const publishDate = org?.post?.[0]?.publishDate; + // Never in the future, the streak workflow sleeps until a day after it + return publishDate ? Math.min(publishDate.getTime(), Date.now()) : null; + } + async getTeam(orgId: string) { return this._organization.model.organization.findUnique({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts index dae6677e47..c6cf15bf07 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts @@ -92,6 +92,10 @@ export class OrganizationService { return this._organizationRepository.setStreak(organizationId, type); } + async getLastPublishDate(organizationId: string) { + return this._organizationRepository.getLastPublishDate(organizationId); + } + getOrgByCustomerId(customerId: string) { return this._organizationRepository.getOrgByCustomerId(customerId); } From c9138d355e2dc7c6b54a8cefb99a6c66f26e4d8f Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 13:02:48 +0700 Subject: [PATCH 42/99] feat: fix agent media sso --- apps/backend/src/api/routes/users.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index ca15e470e3..455d31b8ad 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -93,7 +93,7 @@ export class UsersController { process.env.AGENT_MEDIA_SSO_KEY ); - return { url: `https://agent-media.ai/sso/${token}` }; + return { url: `https://app.agent-media.ai/sso/${token}` }; } @Get('/self') From f28c2522191734d00610e4fee953609c01a06d11 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 13:15:33 +0700 Subject: [PATCH 43/99] feat: make ugc --- .../src/components/layout/top.menu.tsx | 106 ++++++++++++++---- 1 file changed, 87 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/components/layout/top.menu.tsx b/apps/frontend/src/components/layout/top.menu.tsx index defa47bb65..f8929c0030 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -160,31 +160,99 @@ export const useMenuItem = () => { const secondMenu = [ { - name: t('UGC', 'UGC'), + name: t('make_ugc', 'Make UGC'), icon: ( - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), path: '#', From d883bfa159dc00b292f61c897f6fa458b5c78b57 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 13:21:58 +0700 Subject: [PATCH 44/99] feat: fix auto post --- apps/orchestrator/src/app.module.ts | 4 ++-- .../src/database/prisma/autopost/autopost.service.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/orchestrator/src/app.module.ts b/apps/orchestrator/src/app.module.ts index 1e5ca89ade..62fc5ab5b1 100644 --- a/apps/orchestrator/src/app.module.ts +++ b/apps/orchestrator/src/app.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { PostActivity } from '@gitroom/orchestrator/activities/post.activity'; import { getTemporalModule } from '@gitroom/nestjs-libraries/temporal/temporal.module'; import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module'; -import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service'; +import { AutopostActivity } from '@gitroom/orchestrator/activities/autopost.activity'; import { EmailActivity } from '@gitroom/orchestrator/activities/email.activity'; import { IntegrationsActivity } from '@gitroom/orchestrator/activities/integrations.activity'; import { VideoActivity } from '@gitroom/orchestrator/activities/video.activity'; @@ -13,7 +13,7 @@ import { HealthController } from '@gitroom/orchestrator/health.controller'; const activities = [ PostActivity, - AutopostService, + AutopostActivity, EmailActivity, IntegrationsActivity, VideoActivity, diff --git a/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts index 9b7f9e9eea..fb3aff9423 100644 --- a/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts @@ -103,12 +103,14 @@ export class AutopostService { async processCron(active: boolean, orgId: string, id: string) { if (active) { try { - return this._temporalService.client + return await this._temporalService.client .getRawClient() ?.workflow.start('autoPostWorkflow', { workflowId: `autopost-${id}`, taskQueue: 'main', args: [{ id, immediately: true }], + // Keep a running workflow, it reads the autopost settings on every run + workflowIdConflictPolicy: 'USE_EXISTING', typedSearchAttributes: new TypedSearchAttributes([ { key: organizationId, @@ -116,7 +118,10 @@ export class AutopostService { }, ]), }); - } catch (err) {} + } catch (err) { + // Don't fall through and terminate an active autopost + return false; + } } try { From 96b784768104c5af3ea1ac23c2d83beaf512072b Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 14:11:30 +0700 Subject: [PATCH 45/99] fixes: analytics --- .../routes/no.auth.integrations.controller.ts | 6 +++++- .../services/auth/providers/google.provider.ts | 16 ++++++++++++++-- .../prisma/integrations/integration.service.ts | 8 +++++--- .../src/database/prisma/posts/posts.service.ts | 9 +++++++-- .../src/integrations/social/threads.provider.ts | 7 +++++-- .../social/tiktok.business.provider.ts | 7 +++++-- .../src/integrations/social/youtube.provider.ts | 15 +++++++++++++-- 7 files changed, 54 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index f5260b9aae..70025aa8ca 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -62,7 +62,11 @@ export class NoAuthIntegrationsController { ? 'none' : await ioRedis.get(`login:${body.state}`); if (!getCodeVerifier) { - throw new Error('Invalid state'); + // The state expires after an hour and is deleted once used (page reload) + throw new HttpException( + 'This connection link expired or was already used, please connect the channel again', + 400 + ); } const organization = await ioRedis.get(`organization:${body.state}`); diff --git a/apps/backend/src/services/auth/providers/google.provider.ts b/apps/backend/src/services/auth/providers/google.provider.ts index 47204573eb..5c5866b6c4 100644 --- a/apps/backend/src/services/auth/providers/google.provider.ts +++ b/apps/backend/src/services/auth/providers/google.provider.ts @@ -1,4 +1,5 @@ import { google } from 'googleapis'; +import { HttpException } from '@nestjs/common'; import { AuthProvider, AuthProviderAbstract, @@ -32,8 +33,19 @@ export class GoogleProvider extends AuthProviderAbstract { async getToken(code: string, redirectUri?: string) { const client = makeClient(redirectUri || defaultRedirect()); - const { tokens } = await client.getToken(code); - return tokens.access_token!; + try { + const { tokens } = await client.getToken(code); + return tokens.access_token!; + } catch (err: any) { + // The code is single use and short lived (callback reloaded or too slow) + if (err?.response?.data?.error === 'invalid_grant') { + throw new HttpException( + 'Google sign-in expired, please sign in again', + 400 + ); + } + throw err; + } } async getUser(providerToken: string) { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index b10d8831e5..d1a1cae166 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -449,6 +449,8 @@ export class IntegrationService { date: string, forceRefresh = false ): Promise { + // Days to load, missing or invalid on some public API calls (same default as the app) + const days = Number(date) > 0 ? Number(date) : 7; const getIntegration = await this.getIntegrationById(org.id, integration); if (!getIntegration) { @@ -489,7 +491,7 @@ export class IntegrationService { } const getIntegrationData = await ioRedis.get( - `integration:${org.id}:${integration}:${date}` + `integration:${org.id}:${integration}:${days}` ); if (getIntegrationData) { return JSON.parse(getIntegrationData); @@ -500,10 +502,10 @@ export class IntegrationService { const loadAnalytics = await integrationProvider.analytics( getIntegration.internalId, getIntegration.token, - +date + days ); await ioRedis.set( - `integration:${org.id}:${integration}:${date}`, + `integration:${org.id}:${integration}:${days}`, JSON.stringify(loadAnalytics), 'EX', !process.env.NODE_ENV || process.env.NODE_ENV === 'development' diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 4c63d3d728..2360379b78 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -263,10 +263,11 @@ export class PostsService { ); return loadAnalytics; } catch (e) { - console.log(e); - if (e instanceof RefreshToken) { + // Retry once with a refreshed token + if (e instanceof RefreshToken && !forceRefresh) { return this.checkPostAnalytics(orgId, postId, date, true); } + console.log(e); } return []; @@ -585,6 +586,10 @@ export class PostsService { async getPost(orgId: string, id: string, convertToJPEG = false) { const posts = await this.getPostsRecursively(id, true, orgId, true); + if (!posts?.[0]) { + throw new NotFoundException('Post not found'); + } + const list = { group: posts?.[0]?.group, posts: await Promise.all( diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts index 26a4a123e5..f701c61b71 100644 --- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts @@ -877,8 +877,11 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider { } return result; - } catch (err) { - console.error('Error fetching Threads post analytics:', err); + } catch (err: any) { + console.error( + 'Error fetching Threads post analytics:', + err?.cause?.code || err?.message || err + ); return []; } } diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts index f3c78fcedb..875ac6a738 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts @@ -1215,11 +1215,14 @@ export class TiktokBusinessProvider } return result; - } catch (err) { + } catch (err: any) { if (err instanceof RefreshToken || err instanceof Disconnect) { throw err; } - console.error('Error fetching TikTok Business post analytics:', err); + console.error( + 'Error fetching TikTok Business post analytics:', + err?.cause?.code || err?.message || err + ); return []; } } diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 7c5be707dd..f9cf0413fb 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -1029,8 +1029,19 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { } return result; - } catch (err) { - console.error('Error fetching YouTube post analytics:', err); + } catch (err: any) { + // Expired or revoked token, checkPostAnalytics refreshes it and retries once + if (err?.response?.status === 401) { + throw new RefreshToken( + this.identifier, + JSON.stringify(err.response.data || {}), + '{}' + ); + } + console.error( + 'Error fetching YouTube post analytics:', + err?.message || err + ); return []; } } From 4e55a959395ae4782dda49d4e0fcf0912f107ad2 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 4 Oct 2026 17:10:26 +0700 Subject: [PATCH 46/99] feat: provider increase --- .../src/integrations/social/bluesky.provider.ts | 2 +- .../src/integrations/social/linkedin.provider.ts | 2 +- .../src/integrations/social/mastodon.provider.ts | 2 +- .../src/integrations/social/pinterest.provider.ts | 2 +- .../nestjs-libraries/src/integrations/social/slack.provider.ts | 2 +- .../src/integrations/social/threads.provider.ts | 2 +- .../nestjs-libraries/src/integrations/social/twitch.provider.ts | 2 +- .../nestjs-libraries/src/integrations/social/vk.provider.ts | 2 +- .../nestjs-libraries/src/integrations/social/whop.provider.ts | 1 + .../src/integrations/social/wordpress.provider.ts | 2 +- 10 files changed, 10 insertions(+), 9 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts index 67521a3293..29064dbd35 100644 --- a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts @@ -233,7 +233,7 @@ type BlueskyPendingData = { 'Bluesky can have maximum 1 video or 4 pictures in one post, it can also be without attachments' ) export class BlueskyProvider extends SocialAbstract implements SocialProvider { - override maxConcurrentJob = 2; // Bluesky has moderate rate limits + override maxConcurrentJob = 6; // Bluesky limits are per account and per IP identifier = 'bluesky'; name = 'Bluesky'; toolTip = diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index daaca54f81..36205c48a3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -70,7 +70,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { 'w_organization_social', 'r_organization_social', ]; - override maxConcurrentJob = 2; + override maxConcurrentJob = 8; // LinkedIn limits are daily budgets refreshWait = true; editor = 'normal' as const; dto = LinkedinDto; diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts index c0ec3920f9..38c0297e61 100644 --- a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts @@ -32,7 +32,7 @@ type MastodonPendingData = { }; export class MastodonProvider extends SocialAbstract implements SocialProvider { - override maxConcurrentJob = 5; // Mastodon instances typically have generous limits + override maxConcurrentJob = 10; // Mastodon limits are per user, not per app identifier = 'mastodon'; name = 'Mastodon'; isBetweenSteps = false; diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts index 2c52e1b827..5d58cdef63 100644 --- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts @@ -61,7 +61,7 @@ export class PinterestProvider 'pins:write', 'user_accounts:read', ]; - override maxConcurrentJob = 3; // Pinterest has more lenient rate limits + override maxConcurrentJob = 10; // Pinterest limits are per user maxLength() { return 500; } diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts index 481c19ff5b..ebbf1b042c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts @@ -17,7 +17,7 @@ import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-setting import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; export class SlackProvider extends SocialAbstract implements SocialProvider { - override maxConcurrentJob = 3; // Slack has moderate API limits + override maxConcurrentJob = 10; // Slack limits are per workspace identifier = 'slack'; name = 'Slack'; isBetweenSteps = false; diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts index f701c61b71..797c5e302c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts @@ -30,7 +30,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider { 'threads_manage_insights', // 'threads_profile_discovery', ]; - override maxConcurrentJob = 2; // Threads has moderate rate limits + override maxConcurrentJob = 20; // Threads limits are per profile refreshCron = true; editor = 'normal' as const; diff --git a/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts index 37c6c2ae3a..731f9f6690 100644 --- a/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts @@ -12,7 +12,7 @@ import { TwitchDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settin import { timer } from '@gitroom/helpers/utils/timer'; export class TwitchProvider extends SocialAbstract implements SocialProvider { - override maxConcurrentJob = 1; + override maxConcurrentJob = 5; // Twitch limits are per user identifier = 'twitch'; name = 'Twitch'; isBetweenSteps = false; diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts index f9db95efff..25adc2fd6b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts @@ -18,7 +18,7 @@ import { Integration } from '@prisma/client'; import { hasExtension } from '@gitroom/helpers/utils/has.extension'; export class VkProvider extends SocialAbstract implements SocialProvider { - override maxConcurrentJob = 2; // VK has moderate API limits + override maxConcurrentJob = 5; // VK limits are per user token identifier = 'vk'; name = 'VK'; isBetweenSteps = false; diff --git a/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts index 6130381d93..a7b0a9f304 100644 --- a/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts @@ -18,6 +18,7 @@ import { Integration } from '@prisma/client'; import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; export class WhopProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Whop limits are per token identifier = 'whop'; name = 'Whop'; isBetweenSteps = false; diff --git a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts index c7fc44a211..a449122048 100644 --- a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts @@ -30,7 +30,7 @@ export class WordpressProvider isBetweenSteps = false; editor = 'html' as const; scopes = [] as string[]; - override maxConcurrentJob = 5; // WordPress self-hosted typically has generous limits + override maxConcurrentJob = 8; // WordPress sites are the customer's own servers dto = WordpressDto; maxLength() { return 100000; From ac27b00373c624d597c0d41933cc17054f059843 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Sun, 4 Oct 2026 16:49:32 +0200 Subject: [PATCH 47/99] feat(sponsors): remove rapidproxy from readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 1b21e0a0d8..052c80e463 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,6 @@ |---------|:-----------------------------------------------------------------------:|-----------------| | [Hostinger](https://www.hostinger.com/vps/docker/postiz?ref=postiz) | Hostinger | Hostinger is on a mission to make online success possible for anyone – from developers to aspiring bloggers and business owners | | [Virlo](https://dev.virlo.ai/?ref=postiz) | Virlo | Virlo is the #1 social media trend spotting and all-in-one GTM tool for teams leveraging short-form video | -| [ChatbotX](https://chatbotx.io/?ref=postiz) | ChatbotX | The ManyChat alternative that you can self-host, white-label, and resell to your clients. Bring your own OpenClaw, Hermes, or Claude agents! | | [RapidProxy](https://www.rapidproxy.io/?ref=postiz) | WP-PROXY-THUMBNAIL-41 | RapidProxy provides 90M+ residential IPs for social media, browser automation, and AI workflows, with smart rotation and stable sessions. From $0.55/GB; use RAPID10 for 10% off. From 8c5f3565628a1f9c482f06c03162daf2b99263b4 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 5 Oct 2026 09:13:04 +0700 Subject: [PATCH 48/99] fix(settings): show a warning toast when the invite request fails The early return on a failed invite left the modal open with no feedback for non-402 failures. Show the server's message, or a generic fallback, as a warning toast, matching the other failed-request handlers in the frontend. Adds the could_not_send_invitation key and its translations. Co-Authored-By: Claude Fable 5.1 --- apps/frontend/src/components/settings/teams.component.tsx | 6 ++++++ i18n.lock | 1 + .../src/translation/locales/ar/translation.json | 1 + .../src/translation/locales/bn/translation.json | 1 + .../src/translation/locales/de/translation.json | 1 + .../src/translation/locales/en/translation.json | 1 + .../src/translation/locales/es/translation.json | 1 + .../src/translation/locales/fr/translation.json | 1 + .../src/translation/locales/he/translation.json | 1 + .../src/translation/locales/it/translation.json | 1 + .../src/translation/locales/ja/translation.json | 1 + .../src/translation/locales/ko/translation.json | 1 + .../src/translation/locales/pt/translation.json | 1 + .../src/translation/locales/ru/translation.json | 1 + .../src/translation/locales/tr/translation.json | 1 + .../src/translation/locales/vi/translation.json | 1 + .../src/translation/locales/zh/translation.json | 1 + 17 files changed, 22 insertions(+) diff --git a/apps/frontend/src/components/settings/teams.component.tsx b/apps/frontend/src/components/settings/teams.component.tsx index 5033ac47a0..a96f2aaec5 100644 --- a/apps/frontend/src/components/settings/teams.component.tsx +++ b/apps/frontend/src/components/settings/teams.component.tsx @@ -55,6 +55,12 @@ export const AddMember = () => { body: JSON.stringify(values), }); if (!response.ok) { + const { message } = await response.json().catch(() => ({} as any)); + toast.show( + (Array.isArray(message) ? message[0] : message) || + t('could_not_send_invitation', 'Could not send the invitation'), + 'warning' + ); return; } const { url } = await response.json(); diff --git a/i18n.lock b/i18n.lock index 93a43fef56..5ff501f107 100644 --- a/i18n.lock +++ b/i18n.lock @@ -620,6 +620,7 @@ checksums: profile_updated: 4a55423a795e43ff4bd1a8b3956de4b9 sets: a8e00731555775ef06d2d8933898c457 invitation_link_sent: 3d67ff64ec205998378cf7c8f124799f + could_not_send_invitation: 808475fc63732c879eb30eac5c288d6b send_invitation_link: 8eccac7afd966c2bf358b9e04fb02ee8 copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35 are_you_sure_remove_team_member: fdd24783fdaefbfcd8150d51d088cc9d diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index a54763d0c1..6af124d64f 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -616,6 +616,7 @@ "profile_updated": "تم تحديث الملف الشخصي", "sets": "مجموعات", "invitation_link_sent": "تم إرسال رابط الدعوة", + "could_not_send_invitation": "تعذّر إرسال الدعوة", "send_invitation_link": "إرسال رابط الدعوة", "copy_link": "نسخ الرابط", "are_you_sure_remove_team_member": "هل أنت متأكد أنك تريد إزالة هذا العضو من الفريق؟", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 31663d694c..9851456b0e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -616,6 +616,7 @@ "profile_updated": "প্রোফাইল আপডেট হয়েছে", "sets": "সেট", "invitation_link_sent": "আমন্ত্রণ লিঙ্ক পাঠানো হয়েছে", + "could_not_send_invitation": "আমন্ত্রণপত্র পাঠানো যায়নি", "send_invitation_link": "আমন্ত্রণ লিঙ্ক পাঠান", "copy_link": "লিঙ্ক কপি করুন", "are_you_sure_remove_team_member": "আপনি কি নিশ্চিত এই টিম সদস্যকে সরাতে চান?", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 9f3c2ddb91..b31a681bcf 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Profil aktualisiert", "sets": "Sets", "invitation_link_sent": "Einladungslink gesendet", + "could_not_send_invitation": "Die Einladung konnte nicht gesendet werden", "send_invitation_link": "Einladungslink senden", "copy_link": "Link kopieren", "are_you_sure_remove_team_member": "Bist du sicher, dass du dieses Teammitglied entfernen möchtest?", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 4ead7f9a5b..26dfbe9861 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -618,6 +618,7 @@ "sets": "Sets", "email_address": "Email Address", "invitation_link_sent": "Invitation link sent", + "could_not_send_invitation": "Could not send the invitation", "send_invitation_link": "Send Invitation Link", "copy_link": "Copy Link", "are_you_sure_remove_team_member": "Are you sure you want to remove this team member?", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 8e13f52930..67922500d0 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Perfil actualizado", "sets": "Conjuntos", "invitation_link_sent": "Enlace de invitación enviado", + "could_not_send_invitation": "No se pudo enviar la invitación", "send_invitation_link": "Enviar enlace de invitación", "copy_link": "Copiar enlace", "are_you_sure_remove_team_member": "¿Estás seguro de que deseas eliminar a este miembro del equipo?", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 328accd702..1bdcea4282 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Profil mis à jour", "sets": "Ensembles", "invitation_link_sent": "Lien d'invitation envoyé", + "could_not_send_invitation": "Impossible d'envoyer l'invitation", "send_invitation_link": "Envoyer le lien d'invitation", "copy_link": "Copier le lien", "are_you_sure_remove_team_member": "Êtes-vous sûr de vouloir retirer ce membre de l'équipe ?", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index e22a740635..c24f8fc67a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -616,6 +616,7 @@ "profile_updated": "הפרופיל עודכן", "sets": "סטים", "invitation_link_sent": "קישור ההזמנה נשלח", + "could_not_send_invitation": "לא ניתן היה לשלוח את ההזמנה", "send_invitation_link": "שלח קישור הזמנה", "copy_link": "העתק קישור", "are_you_sure_remove_team_member": "האם אתה בטוח שברצונך להסיר את חבר הצוות הזה?", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index d917f74025..f47978e9d8 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Profilo aggiornato", "sets": "Set", "invitation_link_sent": "Link di invito inviato", + "could_not_send_invitation": "Impossibile inviare l'invito", "send_invitation_link": "Invia link di invito", "copy_link": "Copia link", "are_you_sure_remove_team_member": "Sei sicuro di voler rimuovere questo membro del team?", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index f952bf02f6..97a89ca71a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -616,6 +616,7 @@ "profile_updated": "プロフィールが更新されました", "sets": "セット", "invitation_link_sent": "招待リンクが送信されました", + "could_not_send_invitation": "招待状を送信できませんでした", "send_invitation_link": "招待リンクを送信", "copy_link": "リンクをコピー", "are_you_sure_remove_team_member": "このチームメンバーを削除してもよろしいですか?", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index 47ab8a4e54..d10c0bcbb4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -616,6 +616,7 @@ "profile_updated": "프로필이 업데이트되었습니다", "sets": "세트", "invitation_link_sent": "초대 링크가 전송되었습니다", + "could_not_send_invitation": "초대를 보낼 수 없습니다", "send_invitation_link": "초대 링크 보내기", "copy_link": "링크 복사", "are_you_sure_remove_team_member": "이 팀 멤버를 삭제하시겠습니까?", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 67dd663e1a..431fc50ea0 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Perfil atualizado", "sets": "Conjuntos", "invitation_link_sent": "Link de convite enviado", + "could_not_send_invitation": "Não foi possível enviar o convite", "send_invitation_link": "Enviar link de convite", "copy_link": "Copiar link", "are_you_sure_remove_team_member": "Tem certeza de que deseja remover este membro da equipe?", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 36176fea64..afcba20292 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Профиль обновлён", "sets": "Наборы", "invitation_link_sent": "Ссылка-приглашение отправлена", + "could_not_send_invitation": "Не удалось отправить приглашение", "send_invitation_link": "Отправить ссылку-приглашение", "copy_link": "Скопировать ссылку", "are_you_sure_remove_team_member": "Вы уверены, что хотите удалить этого участника команды?", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index 889a89cb8e..e2121e6de0 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Profil güncellendi", "sets": "Setler", "invitation_link_sent": "Davet bağlantısı gönderildi", + "could_not_send_invitation": "Davetiye gönderilemedi", "send_invitation_link": "Davet Bağlantısı Gönder", "copy_link": "Bağlantıyı Kopyala", "are_you_sure_remove_team_member": "Bu ekip üyesini kaldırmak istediğinizden emin misiniz?", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 3121edd33d..350c6e7b68 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -616,6 +616,7 @@ "profile_updated": "Cập nhật hồ sơ thành công", "sets": "Bộ", "invitation_link_sent": "Đã gửi liên kết mời", + "could_not_send_invitation": "Không thể gửi lời mời", "send_invitation_link": "Gửi liên kết mời", "copy_link": "Sao chép liên kết", "are_you_sure_remove_team_member": "Bạn có chắc chắn muốn xóa thành viên này khỏi nhóm không?", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 46960af9fa..c619549752 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -616,6 +616,7 @@ "profile_updated": "个人资料已更新", "sets": "集合", "invitation_link_sent": "邀请链接已发送", + "could_not_send_invitation": "无法发送邀请", "send_invitation_link": "发送邀请链接", "copy_link": "复制链接", "are_you_sure_remove_team_member": "您确定要移除此团队成员吗?", From 256f2b468f530e0357783a3fbe9903f6c874181c Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 5 Oct 2026 12:41:26 +0700 Subject: [PATCH 49/99] docs(claude): code invoked by external webhooks must not throw Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac91bf6dc9..9c89fb7f7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,4 +76,5 @@ const useCommunity = () => { - Before adding a field to a shared repository/service `select` or changing a shared method's return shape, grep for all its consumers (frontend, public API, MCP/agent tools, orchestrator, webhooks) and confirm the change is intended for each. - Exposing a stored field to a new external surface (public API, MCP, webhooks) is a data-exposure decision — ask first, and check what existing production rows hold for that column before shipping it. - Never silently change the meaning or format of a value persisted in an existing DB column; that affects every reader and all historical rows, so ask first. -- Never return a raw Prisma row from a model that holds credentials (`Integration`, `User` and similar) in a controller response. Repository writes on those models must use a `select` with only the fields the caller needs (like `setTimes` with `select: { id: true }`), or the controller returns nothing. This applies even where nearby code looks different. \ No newline at end of file +- Never return a raw Prisma row from a model that holds credentials (`Integration`, `User` and similar) in a controller response. Repository writes on those models must use a `select` with only the fields the caller needs (like `setTimes` with `select: { id: true }`), or the controller returns nothing. This applies even where nearby code looks different. +- Be extra careful with code invoked by webhooks of external systems (Stripe, RevenueCat and other payment providers), including everything they call down the stack (e.g. `SubscriptionService.createOrUpdateSubscription`, `modifySubscription`, `deleteSubscription`). That code must not throw exceptions: never add a `throw`, and wrap any call that can fail (DB, provider API) so a failure is caught and logged instead of propagating. A webhook that throws leaves billing state half-applied for a paying customer. From 2b4fcbed7c93abc30bd12d3a85f26a67efd6f37d Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 5 Oct 2026 12:49:32 +0700 Subject: [PATCH 50/99] fix(subscription): log webhook-path failures instead of swallowing them The catches around modifySubscription / modifySubscriptionByOrg in createOrUpdateSubscription(ByOrg) returned {} without logging, so a failure there silently skipped writing the subscription row. They now log the error. The channel re-enable calls are wrapped so a failure is logged and the rest of the subscription update still runs. Co-Authored-By: Claude Opus 5.5 --- .../subscriptions/subscription.service.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index ff9c962aac..58f32662aa 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -137,7 +137,14 @@ export class SubscriptionService { allChannels.length <= totalChannels && totalChannels > (getCurrentSubscription?.totalChannels || 0) ) { - await this._integrationService.enableAllIntegrations(organizationId); + try { + await this._integrationService.enableAllIntegrations(organizationId); + } catch (err) { + console.error( + 'Error enabling channels after subscription change:', + err + ); + } } if (from.team_members && !to.team_members) { @@ -212,9 +219,16 @@ export class SubscriptionService { allChannels.length <= totalChannels && totalChannels > (getCurrentSubscription?.totalChannels || 0) ) { - await this._integrationService.enableAllIntegrations( - getOrgByCustomerId?.id! - ); + try { + await this._integrationService.enableAllIntegrations( + getOrgByCustomerId?.id! + ); + } catch (err) { + console.error( + 'Error enabling channels after subscription change:', + err + ); + } } if (from.team_members && !to.team_members) { @@ -264,6 +278,7 @@ export class SubscriptionService { return {}; } } catch (e) { + console.error('Error modifying subscription:', e); return {}; } } @@ -308,6 +323,7 @@ export class SubscriptionService { return {}; } } catch (e) { + console.error('Error modifying subscription:', e); return {}; } From 8ad0df3df0f21299b8640568358c1b68c6464e4f Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 1 Oct 2026 16:53:38 +0700 Subject: [PATCH 51/99] fix(api): return only the needed fields from channel delete and the organizations list Co-Authored-By: Claude Fable 5.1 --- apps/backend/src/api/routes/users.controller.ts | 11 ++++++++--- .../prisma/integrations/integration.repository.ts | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index ca15e470e3..96fa7daaa3 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -298,9 +298,14 @@ export class UsersController { @Get('/organizations') async getOrgs(@GetUserFromRequest() user: User) { - return (await this._orgService.getOrgsByUserId(user.id)).filter( - (f) => !f.users[0].disabled - ); + return (await this._orgService.getOrgsByUserId(user.id)) + .filter((f) => !f.users[0].disabled) + .map((p) => ({ + id: p.id, + name: p.name, + users: p.users, + subscription: p.subscription, + })); } @Post('/change-org') diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index 0a9f2b1fc1..818d87ef70 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -765,6 +765,9 @@ export class IntegrationRepository { deleteChannel(org: string, id: string) { return this._integration.model.integration.update({ + select: { + id: true, + }, where: { id, organizationId: org, From 4913031d9b326fd293336e970647ac6c671cd066 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 5 Oct 2026 12:52:41 +0700 Subject: [PATCH 52/99] fix: skip cancelling an already-cancelled duplicate on webhook retry --- libraries/nestjs-libraries/src/services/stripe.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index e812ef1861..c757ed4213 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -161,7 +161,9 @@ export class StripeService extends PaymentProviderAbstract { f.id < event.data.object.id)) ); if (isDuplicate) { - await stripe.subscriptions.cancel(event.data.object.id); + if (liveSubscriptions.some((f) => f.id === event.data.object.id)) { + await stripe.subscriptions.cancel(event.data.object.id); + } console.log('stripe_duplicate_subscription_cancelled', { stripe_event_type: event.type, stripe_event_id: event.id, From ac65e200a7b03444426b2a5b838e8d63cc089087 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 5 Oct 2026 15:51:48 +0700 Subject: [PATCH 53/99] fix: never treat incomplete Stripe subscriptions as live --- libraries/nestjs-libraries/src/services/stripe.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index c757ed4213..b46e6529c9 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -438,7 +438,12 @@ export class StripeService extends PaymentProviderAbstract { status: 'all', expand: ['data.latest_invoice'], }) - ).data.filter((f) => f.status !== 'canceled'); + ).data.filter( + (f) => + f.status !== 'canceled' && + f.status !== 'incomplete' && + f.status !== 'incomplete_expired' + ); } async setToCancel(organizationId: string) { From b95168ff1adc6ba20be38a339f76270703ec8fbf Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 5 Oct 2026 17:25:20 +0700 Subject: [PATCH 54/99] feat: show login method in Global Settings Adds an Authentication section at the top of Global Settings that shows how the user logs in, read from the existing User columns (providerName, email). Password and GitHub/Google logins show the email; Farcaster, Wallet and generic SSO show the label only. Translations added for all locales. Co-Authored-By: Claude Sonnet 5.5 --- .../settings/authentication.component.tsx | 48 +++++++++++++++++++ .../components/settings/global.settings.tsx | 2 + i18n.lock | 7 +++ .../translation/locales/ar/translation.json | 9 +++- .../translation/locales/bn/translation.json | 9 +++- .../translation/locales/de/translation.json | 9 +++- .../translation/locales/en/translation.json | 9 +++- .../translation/locales/es/translation.json | 9 +++- .../translation/locales/fr/translation.json | 9 +++- .../translation/locales/he/translation.json | 9 +++- .../translation/locales/it/translation.json | 9 +++- .../translation/locales/ja/translation.json | 9 +++- .../translation/locales/ko/translation.json | 9 +++- .../translation/locales/pt/translation.json | 9 +++- .../translation/locales/ru/translation.json | 9 +++- .../translation/locales/tr/translation.json | 9 +++- .../translation/locales/vi/translation.json | 9 +++- .../translation/locales/zh/translation.json | 9 +++- 18 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 apps/frontend/src/components/settings/authentication.component.tsx diff --git a/apps/frontend/src/components/settings/authentication.component.tsx b/apps/frontend/src/components/settings/authentication.component.tsx new file mode 100644 index 0000000000..af3e37326f --- /dev/null +++ b/apps/frontend/src/components/settings/authentication.component.tsx @@ -0,0 +1,48 @@ +'use client'; + +import React from 'react'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; + +const AuthenticationComponent = () => { + const t = useT(); + const user = useUser(); + + const methods: Record = { + LOCAL: { + label: t('auth_method_local', 'Email and password'), + showEmail: true, + }, + GITHUB: { label: t('auth_method_github', 'GitHub'), showEmail: true }, + GOOGLE: { label: t('auth_method_google', 'Google'), showEmail: true }, + FARCASTER: { + label: t('auth_method_farcaster', 'Farcaster'), + showEmail: false, + }, + WALLET: { label: t('auth_method_wallet', 'Wallet'), showEmail: false }, + GENERIC: { + label: t('auth_method_generic', 'Single sign-on'), + showEmail: false, + }, + }; + + const method = user?.providerName && methods[user.providerName]; + + if (!method) { + return null; + } + + return ( +
+
{t('authentication', 'Authentication')}
+
+
{method.label}
+ {method.showEmail && ( +
{user.email}
+ )} +
+
+ ); +}; + +export default AuthenticationComponent; diff --git a/apps/frontend/src/components/settings/global.settings.tsx b/apps/frontend/src/components/settings/global.settings.tsx index ddb4fa6799..f45bafd6d0 100644 --- a/apps/frontend/src/components/settings/global.settings.tsx +++ b/apps/frontend/src/components/settings/global.settings.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import dynamic from 'next/dynamic'; +import AuthenticationComponent from '@gitroom/frontend/components/settings/authentication.component'; import EmailNotificationsComponent from '@gitroom/frontend/components/settings/email-notifications.component'; import ShortlinkPreferenceComponent from '@gitroom/frontend/components/settings/shortlink-preference.component'; import DeleteAccountComponent from '@gitroom/frontend/components/settings/delete-account.component'; @@ -19,6 +20,7 @@ export const GlobalSettings = () => { return (

{t('global_settings', 'Global Settings')}

+ diff --git a/i18n.lock b/i18n.lock index f8f0d2d2f4..643ca645b6 100644 --- a/i18n.lock +++ b/i18n.lock @@ -811,3 +811,10 @@ checksums: channel_renamed: a1551cd9d9669ac7602efc3f3c888c59 channel_name_reset: 2c7c64bbf0fce057e61d4942f191c861 reset_channel_name: ac06e9718420527b4bb04cee342e60cf + authentication: 491fbb938cfe44df0438ad3e198d23e6 + auth_method_local: 4087f0eca4d4989ad0cdf469a1d28af8 + auth_method_github: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4 + auth_method_google: 6cc462fb53d90d404f48d5a6695c1de1 + auth_method_farcaster: b09af25fcb497663e8594b9fc514b969 + auth_method_wallet: 4599a5c87198f2113d1e54d435a24cd5 + auth_method_generic: 58c80e21cf5e88d556ae604726c18e4d diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 3a3521054a..d1ea6c969a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -806,5 +806,12 @@ "channel_name": "الاسم", "channel_renamed": "تمت إعادة تسمية القناة", "channel_name_reset": "تم إعادة ضبط اسم القناة", - "reset_channel_name": "إعادة الاسم إلى الاسم الأصلي" + "reset_channel_name": "إعادة الاسم إلى الاسم الأصلي", + "authentication": "المصادقة", + "auth_method_local": "البريد الإلكتروني وكلمة المرور", + "auth_method_github": "غيت هاب", + "auth_method_google": "جوجل", + "auth_method_farcaster": "فاركاستر", + "auth_method_wallet": "المحفظة", + "auth_method_generic": "تسجيل الدخول الموحد" } diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 4cda949bcb..07044656a4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -806,5 +806,12 @@ "channel_name": "নাম", "channel_renamed": "চ্যানেলের নাম পরিবর্তন হয়েছে", "channel_name_reset": "চ্যানেলের নাম পুনরায় সেট করা হয়েছে", - "reset_channel_name": "মূল নামে ফিরে যান" + "reset_channel_name": "মূল নামে ফিরে যান", + "authentication": "প্রমাণীকরণ", + "auth_method_local": "ইমেল এবং পাসওয়ার্ড", + "auth_method_github": "গিটহাব", + "auth_method_google": "গুগল", + "auth_method_farcaster": "ফারক্যাস্টার", + "auth_method_wallet": "ওয়ালেট", + "auth_method_generic": "একক সাইন-অন" } diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 0352a77670..255bed6abb 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -806,5 +806,12 @@ "channel_name": "Name", "channel_renamed": "Kanal umbenannt", "channel_name_reset": "Kanalname zurückgesetzt", - "reset_channel_name": "Auf ursprünglichen Namen zurücksetzen" + "reset_channel_name": "Auf ursprünglichen Namen zurücksetzen", + "authentication": "Authentifizierung", + "auth_method_local": "E-Mail und Passwort", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Wallet", + "auth_method_generic": "Single Sign-on" } diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index ce0b7887e5..04c4b12ca6 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -808,5 +808,12 @@ "channel_name": "Name", "channel_renamed": "Channel Renamed", "channel_name_reset": "Channel name reset", - "reset_channel_name": "Reset to original name" + "reset_channel_name": "Reset to original name", + "authentication": "Authentication", + "auth_method_local": "Email and password", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Wallet", + "auth_method_generic": "Single sign-on" } diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 0e87833b20..b47fcbb80a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -806,5 +806,12 @@ "channel_name": "Nombre", "channel_renamed": "Canal renombrado", "channel_name_reset": "Nombre del canal restablecido", - "reset_channel_name": "Restablecer al nombre original" + "reset_channel_name": "Restablecer al nombre original", + "authentication": "Autenticación", + "auth_method_local": "Correo electrónico y contraseña", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Billetera", + "auth_method_generic": "Inicio de sesión único" } diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index dd48fb7d63..a96c7289fd 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -806,5 +806,12 @@ "channel_name": "Nom", "channel_renamed": "Canal renommé", "channel_name_reset": "Nom du canal réinitialisé", - "reset_channel_name": "Réinitialiser au nom d'origine" + "reset_channel_name": "Réinitialiser au nom d'origine", + "authentication": "Authentification", + "auth_method_local": "Email et mot de passe", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Portefeuille", + "auth_method_generic": "Authentification unique" } diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index f01e2f4186..f5c9288e49 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -806,5 +806,12 @@ "channel_name": "שם", "channel_renamed": "שם הערוץ שונה", "channel_name_reset": "שם הערוץ אופס", - "reset_channel_name": "אפס לשם המקורי" + "reset_channel_name": "אפס לשם המקורי", + "authentication": "אימות", + "auth_method_local": "אימייל וסיסמה", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "ארנק", + "auth_method_generic": "כניסה דרך מערכת אחת (SSO)" } diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 2d5fba1580..6321534b52 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -806,5 +806,12 @@ "channel_name": "Nome", "channel_renamed": "Canale rinominato", "channel_name_reset": "Nome del canale ripristinato", - "reset_channel_name": "Ripristina il nome originale" + "reset_channel_name": "Ripristina il nome originale", + "authentication": "Autenticazione", + "auth_method_local": "Email e password", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Portafoglio", + "auth_method_generic": "Single sign-on" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 25bc675bbe..2d2e2610c6 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -806,5 +806,12 @@ "channel_name": "名前", "channel_renamed": "チャンネル名を変更しました", "channel_name_reset": "チャンネル名をリセットしました", - "reset_channel_name": "元の名前にリセット" + "reset_channel_name": "元の名前にリセット", + "authentication": "認証", + "auth_method_local": "メールアドレスとパスワード", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "ウォレット", + "auth_method_generic": "シングルサインオン" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index bd56044814..8fde1e8cc6 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -806,5 +806,12 @@ "channel_name": "이름", "channel_renamed": "채널 이름이 변경됨", "channel_name_reset": "채널 이름이 초기화됨", - "reset_channel_name": "원래 이름으로 되돌리기" + "reset_channel_name": "원래 이름으로 되돌리기", + "authentication": "인증", + "auth_method_local": "이메일과 비밀번호", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "지갑", + "auth_method_generic": "싱글 사인온" } diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 094f48211c..284ccfc479 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -806,5 +806,12 @@ "channel_name": "Nome", "channel_renamed": "Canal renomeado", "channel_name_reset": "Nome do canal redefinido", - "reset_channel_name": "Redefinir para o nome original" + "reset_channel_name": "Redefinir para o nome original", + "authentication": "Autenticação", + "auth_method_local": "E-mail e senha", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Carteira", + "auth_method_generic": "Login único" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 19648360ab..449f64c929 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -806,5 +806,12 @@ "channel_name": "Название", "channel_renamed": "Канал переименован", "channel_name_reset": "Имя канала сброшено", - "reset_channel_name": "Сбросить до исходного имени" + "reset_channel_name": "Сбросить до исходного имени", + "authentication": "Аутентификация", + "auth_method_local": "Электронная почта и пароль", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Криптокошелёк", + "auth_method_generic": "Единый вход" } diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index 42c101ecee..a07243976a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -806,5 +806,12 @@ "channel_name": "Ad", "channel_renamed": "Kanal yeniden adlandırıldı", "channel_name_reset": "Kanal adı sıfırlandı", - "reset_channel_name": "Orijinal adına sıfırla" + "reset_channel_name": "Orijinal adına sıfırla", + "authentication": "Kimlik doğrulama", + "auth_method_local": "E-posta ve şifre", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Cüzdan", + "auth_method_generic": "Tek oturum açma" } diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index ffe5703e0b..caddbf942c 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -806,5 +806,12 @@ "channel_name": "Tên", "channel_renamed": "Đã đổi tên kênh", "channel_name_reset": "Đã đặt lại tên kênh", - "reset_channel_name": "Đặt lại tên gốc" + "reset_channel_name": "Đặt lại tên gốc", + "authentication": "Xác thực", + "auth_method_local": "Email và mật khẩu", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "Ví", + "auth_method_generic": "Đăng nhập một lần" } diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index a0ad29e6dd..0873b74dcb 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -806,5 +806,12 @@ "channel_name": "名称", "channel_renamed": "频道已重命名", "channel_name_reset": "频道名称已重置", - "reset_channel_name": "重置为原始名称" + "reset_channel_name": "重置为原始名称", + "authentication": "身份验证", + "auth_method_local": "邮箱和密码", + "auth_method_github": "GitHub", + "auth_method_google": "Google", + "auth_method_farcaster": "Farcaster", + "auth_method_wallet": "钱包", + "auth_method_generic": "单点登录" } From b08bad8433c5eb77a956440b6d1541694f7e44ae Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 5 Oct 2026 17:52:16 +0700 Subject: [PATCH 55/99] feat(calendar): frame published posts in green with a checkmark Co-Authored-By: Claude Sonnet 5.5 --- apps/frontend/src/components/launches/calendar.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 3c57031b57..e3f86ba732 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -1079,7 +1079,8 @@ const CalendarItem: FC<{ className={clsx( 'w-full flex h-full flex-1 flex-col group', 'relative', - state === 'ERROR' && 'rounded-[10px] ring-2 ring-red-500' + state === 'ERROR' && 'rounded-[10px] ring-2 ring-red-500', + state === 'PUBLISHED' && 'rounded-[10px] ring-2 ring-green-500' )} style={{ opacity, @@ -1095,6 +1096,11 @@ const CalendarItem: FC<{ !
)} + {state === 'PUBLISHED' && ( +
+ ✓ +
+ )} {showCreationMethodBadge && (
Date: Mon, 5 Oct 2026 17:08:42 +0700 Subject: [PATCH 56/99] fix(calendar): make the post delete icon and confirmation red, swap Delete and Preview The calendar's delete icon sits right next to Preview and its confirmation looks like any other dialog, so a mis-click is easy to confirm unnoticed. The delete icon is now red, the Delete and Preview icons swap places so they are no longer adjacent, and the confirmation opens with a red frame and a red approve button. The red styling is opt-in through a new destructive flag on deleteDialog / areYouSure / the modal shell, passed only by the calendar post delete, so every other confirmation keeps its current look. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/launches/calendar.tsx | 16 ++++++++++------ .../src/components/layout/new-modal.tsx | 18 +++++++++++++++++- .../src/helpers/delete.dialog.tsx | 4 +++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 3c57031b57..d150545ab0 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -201,7 +201,11 @@ const usePostActions = (onMutate?: () => void) => { t( 'are_you_sure_you_want_to_delete_post', 'Are you sure you want to delete post?' - ) + ), + undefined, + undefined, + undefined, + true )) ) { return; @@ -1141,12 +1145,12 @@ const CalendarItem: FC<{
- +
{' '} {(state === 'PUBLISHED' || state === 'ERROR') && !post.intervalInDays && @@ -1191,9 +1195,9 @@ const CalendarItem: FC<{ 'hidden group-hover:block hover:underline cursor-pointer', post?.tags?.[0]?.tag?.color && 'text-shadow-tags' )} - onClick={deletePost} + onClick={preview} > - +
void; children: ReactNode | ((close: () => void) => ReactNode); @@ -206,6 +207,7 @@ export const Component: FC<{ !modal.removeLayout && 'gap-[40px] p-[32px] mobile:gap-[24px] mobile:p-[16px]', 'bg-newBgColorInner mx-auto flex flex-col w-fit rounded-[24px] mobile:rounded-[16px] relative mobile:!max-w-full', + modal.destructive && 'border-2 border-red-700', modal.size ? '' : 'min-w-[600px] mobile:min-w-0 mobile:w-full', modal.fullScreen && 'h-full' )} @@ -344,14 +346,23 @@ export const DecisionModal: FC<{ approveLabel: string; cancelLabel: string; onlyApprove: boolean; + destructive?: boolean; resolution: (value: boolean) => void; -}> = ({ description, cancelLabel, approveLabel, resolution, onlyApprove }) => { +}> = ({ + description, + cancelLabel, + approveLabel, + resolution, + onlyApprove, + destructive, +}) => { const { closeCurrent } = useModals(); return (
{description}
-
+
diff --git a/apps/frontend/src/components/launches/information.component.tsx b/apps/frontend/src/components/launches/information.component.tsx index 718a8eb643..dcb1fc2317 100644 --- a/apps/frontend/src/components/launches/information.component.tsx +++ b/apps/frontend/src/components/launches/information.component.tsx @@ -30,7 +30,7 @@ const Valid: FC = () => { ); }; -const Invalid: FC = () => { +const Invalid: FC<{ className?: string }> = ({ className }) => { return ( { height="16" viewBox="0 0 16 16" fill="none" + className={className} > - {isValid ? : } + {isValid ? ( + + ) : ( + + )} {!isGlobal && ( -
+
{currentChars}/{totalAllowedChars}
)} {isGlobal && globalDisplayLimit !== null && ( -
+
{globalDisplayLimit.count}/{globalDisplayLimit.limit}
)} {((isGlobal && selectedIntegrations.length) || !isValid) && ( string) => [ export const RepeatComponent: FC<{ repeat: number | null; onChange: (newVal: number) => void; + list?: boolean; }> = (props) => { const { repeat } = props; const t = useT(); @@ -72,6 +73,42 @@ export const RepeatComponent: FC<{ return list.find((p) => p.value === repeat)?.label; }, [repeat, list]); + if (props.list) { + // the radio list starts with "do not repeat", the dropdown ends with cancel + const options = [ + ...list.filter((p) => !p.value), + ...list.filter((p) => p.value), + ]; + return ( +
+ {options.map((p) => { + const selected = p.value ? repeat === p.value : !repeat; + return ( +
props.onChange(Number(p.value))} + key={p.label} + className="min-h-[48px] px-[12px] flex items-center justify-between gap-[12px] cursor-pointer select-none" + > +
+ {p.value ? p.label : t('do_not_repeat', 'Do Not Repeat')} +
+
+ {selected && ( +
+ )} +
+
+ ); + })} +
+ ); + } + return (
Promise; onChange: (event: { target: { @@ -57,7 +59,7 @@ export const TagsComponentInner: FC<{ name: string; }; }) => void; -}> = ({ initial, onChange, name, mutate, allTags: data }) => { +}> = ({ initial, onChange, name, mutate, allTags: data, list }) => { const t = useT(); const fetch = useFetch(); const [isOpen, setIsOpen] = useState(false); @@ -103,7 +105,30 @@ export const TagsComponentInner: FC<{ }, }); } - }, []); + }, [tagValue, name, onChange, mutate, modals, t]); + + const toggleTag = useCallback( + (tag: any) => () => { + const exists = !!tagValue.find((a) => a.id === tag.id); + let modify = []; + if (exists) { + modify = tagValue.filter((a) => a.id !== tag.id); + } else { + modify = [...tagValue, tag]; + } + setTagValue(modify); + onChange({ + target: { + value: modify.map((p: any) => ({ + label: p.name, + value: p.name, + })), + name, + }, + }); + }, + [tagValue, name, onChange] + ); const deleteTag = useCallback( async (tag: any, e: React.MouseEvent) => { @@ -157,6 +182,42 @@ export const TagsComponentInner: FC<{ [tagValue, name, onChange, mutate, fetch, modals, t] ); + if (list) { + return ( +
+ {(data?.tags || []).map((p: any) => ( +
+
+ + {p.name} + +
+ {}} + value={!!tagValue.find((a) => a.id === p.id)} + /> +
+ ))} +
+ +
+ {t('add_new_tag', 'Add New Tag')} +
+
+
+ ); + } + return (
{(data?.tags || []).map((p: any) => (
{ - const exists = !!tagValue.find((a) => a.id === p.id); - let modify = []; - if (exists) { - modify = tagValue.filter((a) => a.id !== p.id); - } else { - modify = [...tagValue, p]; - } - setTagValue(modify); - onChange({ - target: { - value: modify.map((p: any) => ({ - label: p.name, - value: p.name, - })), - name, - }, - }); - }} + onClick={toggleTag(p)} key={p.name} className="min-h-[40px] py-[8px] px-[20px] -mx-[12px] flex gap-[8px] items-center group" > @@ -258,10 +301,10 @@ export const TagsComponentInner: FC<{ ); }; -const Check: FC<{ value: boolean; onChange: (value: boolean) => void }> = ({ - value, - onChange, -}) => { +export const Check: FC<{ + value: boolean; + onChange: (value: boolean) => void; +}> = ({ value, onChange }) => { return (
onChange(!value)} diff --git a/apps/frontend/src/components/layout/new-modal.tsx b/apps/frontend/src/components/layout/new-modal.tsx index 53a210f3a4..cb7146f188 100644 --- a/apps/frontend/src/components/layout/new-modal.tsx +++ b/apps/frontend/src/components/layout/new-modal.tsx @@ -195,7 +195,7 @@ export const Component: FC<{ ? modal.top ? '' : 'min-h-full pt-[100px] pb-[100px] mobile:pt-[24px] mobile:pb-[24px] mobile:px-[8px]' - : 'h-screen', + : 'h-screen mobile:h-dvh', modal.size && modal.height ? 'flex justify-center items-center' : 'top-0 left-0' diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index eccfad8341..dbd5bf9b83 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -48,6 +48,7 @@ import { DesignMediaIcon, VerticalDividerIcon, NoMediaIcon, + TrashIcon, } from '@gitroom/frontend/components/ui/icons'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; import { useShallow } from 'zustand/react/shallow'; @@ -57,6 +58,11 @@ const Polonto = dynamic( () => import('@gitroom/frontend/components/launches/polonto') ); const showModalEmitter = new EventEmitter(); +// on phones the media library takes the whole screen +export const mediaLibrarySize = () => + window.matchMedia('(max-width: 1025px)').matches + ? '100%' + : 'calc(100% - 80px)'; export const Pagination: FC<{ current: number; totalPages: number; @@ -131,7 +137,8 @@ export const Pagination: FC<{
{paginationItems.map((item, index) => ( -
  • + // phones show "2 / 6" instead of the page numbers +
  • {item === '...' ? ( ... @@ -152,6 +159,9 @@ export const Pagination: FC<{ )}
  • ))} +
  • + {current + 1} / {totalPages} +
  • { askClose: false, closeOnEscape: true, fullScreen: true, - size: 'calc(100% - 80px)', - height: 'calc(100% - 80px)', + size: mediaLibrarySize(), + height: mediaLibrarySize(), children: (close) => ( cCallback(media[0])} @@ -254,6 +264,19 @@ export const MediaBox: FC<{ onEnd: () => setLoading(false), }); + // uploader messages (errors, processing warnings) stay after the upload ends + const [uploadInfo, setUploadInfo] = useState(false); + useEffect(() => { + const show = () => setUploadInfo(true); + const hide = () => setUploadInfo(false); + uppy.on('info-visible', show); + uppy.on('info-hidden', hide); + return () => { + uppy.off('info-visible', show); + uppy.off('info-hidden', hide); + }; + }, [uppy]); + const addRemoveSelected = useCallback( (media: any) => () => { if (standalone) { @@ -396,6 +419,37 @@ export const MediaBox: FC<{ [mutate] ); + // phones have no hover, so the selected media are deleted together + const deleteSelected = useCallback(async () => { + if ( + !(await deleteDialog( + t( + 'are_you_sure_you_want_to_delete_selected_media', + 'Are you sure you want to delete the selected media?' + ) + )) + ) { + return; + } + const toDelete = selected; + setSelected([]); + try { + await Promise.all( + toDelete.map((media: any) => + fetch(`/media/${media.id}`, { + method: 'DELETE', + }) + ) + ); + } finally { + const result = await mutate(); + // deleting a whole last page leaves nothing to show there + if (page >= (result?.pages || 1)) { + setPage(Math.max(0, (result?.pages || 1) - 1)); + } + } + }, [selected, mutate, page, t]); + const btn = useMemo(() => { return ( @@ -620,7 +715,7 @@ export const MediaBox: FC<{ @@ -654,6 +749,10 @@ export const MultiMediaComponent: FC<{ onOpen?: () => void; onClose?: () => void; toolBar?: React.ReactNode; + // after the tools, outside of their scroll + toolBarEnd?: React.ReactNode; + toolsClassName?: string; + toolsStyle?: React.CSSProperties; information?: React.ReactNode; insertInContent?: (media: Array<{ id: string; path: string }>) => void; onChange: (event: { @@ -678,6 +777,9 @@ export const MultiMediaComponent: FC<{ allData, dummy, toolBar, + toolBarEnd, + toolsClassName, + toolsStyle, information, mediaNotAvailable, insertInContent, @@ -728,8 +830,8 @@ export const MultiMediaComponent: FC<{ askClose: false, closeOnEscape: true, fullScreen: true, - size: 'calc(100% - 80px)', - height: 'calc(100% - 80px)', + size: mediaLibrarySize(), + height: mediaLibrarySize(), children: (close) => ( -
    -
    - {!!currentMedia && ( +
    +
    + {!!currentMedia?.length && ( @@ -783,7 +885,7 @@ export const MultiMediaComponent: FC<{ handle=".dragging" > {currentMedia.map((media, index) => ( -
    +
    @@ -823,7 +925,7 @@ export const MultiMediaComponent: FC<{ ) : ( )} @@ -831,69 +933,90 @@ export const MultiMediaComponent: FC<{
    ))} )}
    -
    - {!mediaNotAvailable && ( -
    -
    -
    -
    - + {/* on phones the info stays with the post, the tools can move to the bottom */} + {information && ( +
    {information}
    + )} +
    +
    + {!mediaNotAvailable && ( +
    +
    +
    +
    + +
    +
    + {t('insert_media', 'Insert Media')} +
    -
    - {t('insert_media', 'Insert Media')} +
    +
    +
    +
    + +
    +
    + {t('design_media', 'Design Media')} +
    + + {!insertInContent && ( + + )} + + {!!user?.tier?.ai && ( + <> + + {!insertInContent && ( + + )} + + )}
    + )} + {!mediaNotAvailable && (
    -
    -
    - -
    -
    - {t('design_media', 'Design Media')} -
    -
    +
    - - {!insertInContent && ( - - )} - - {!!user?.tier?.ai && ( - <> - - {!insertInContent && ( - - )} - - )} -
    - )} - {!mediaNotAvailable && ( -
    - -
    - )} - {!!toolBar && ( -
    - {toolBar} -
    - )} + )} + {!!toolBar && ( +
    + {toolBar} +
    + )} + {!!toolBar && ( +
    + )} +
    + {toolBarEnd} {information && ( -
    +
    {information}
    )} @@ -973,8 +1096,8 @@ export const MediaComponent: FC<{ askClose: false, closeOnEscape: true, fullScreen: true, - size: 'calc(100% - 80px)', - height: 'calc(100% - 80px)', + size: mediaLibrarySize(), + height: mediaLibrarySize(), children: (close) => ( ), @@ -1004,7 +1127,10 @@ export const MediaComponent: FC<{ )}
    - )} {!addEditSets && ( -
    +
    + {mobileSheet === 'channels' && ( + setMobileSheet(null)} + button={{ + label: t('done', 'Done'), + onClick: () => setMobileSheet(null), + disabled: selectedIntegrations.length === 0, + }} + > + {!dummy && ( +
    + +
    + )} + +
    + )} + {mobileSheet === 'settings' && ( + setMobileSheet(null)} + > +
    + {hasChannelSettings && ( + } + label={ + current === 'global' + ? t('channels_settings', 'Channel Settings') + : `${integrations.find((p) => p.id === current)?.name} ${t( + 'channel_settings', + 'Settings' + )}` + } + onClick={() => { + setMobileSheet(null); + setShowSettings(true); + }} + /> + )} + } + label={t('add_tag', 'Add Tag')} + onClick={() => setMobileSheet('tags')} + /> + } + label={t('repeat_post', 'Repeat Post')} + onClick={() => setMobileSheet('repeat')} + /> + {existingData?.integration && ( +
    + +
    + {t('delete_post', 'Delete Post')} +
    +
    + )} +
    +
    + )} + {mobileSheet === 'tags' && ( + setMobileSheet(null)} + button={{ + label: t('done', 'Done'), + onClick: () => setMobileSheet(null), + }} + > + { + setTags(e.target.value); + }} + list={true} + /> + + )} + {mobileSheet === 'date' && ( + setMobileSheet(null)} + button={{ + label: t('save', 'Save'), + onClick: () => { + setDate(dateDraft); + setMobileSheet(null); + }, + }} + > + + + )} + {mobileSheet === 'repeat' && ( + setMobileSheet(null)} + button={{ + label: t('done', 'Done'), + onClick: () => setMobileSheet(null), + }} + > + + + )} void; +} + +const actionColors = { + primary: 'bg-btnPrimary text-white', + secondary: 'bg-[#D82D7E] text-white', + tertiary: 'bg-btnSimple text-btnText', +}; + +// the composer header on phones, a single action is triggered directly, +// more than one opens a menu +export const MobileTopBar: FC<{ + onBack: () => void; + actions: MobileTopBarAction[]; + disabled: boolean; + loading: boolean; + children: ReactNode; +}> = ({ onBack, actions, disabled, loading, children }) => { + const t = useT(); + const [open, setOpen] = useState(false); + const ref = useClickOutside(() => setOpen(false)); + const single = actions.length === 1; + + return ( +
    +
    + +
    +
    {children}
    +
    + + {open && ( +
    + {actions.map((action, index) => ( + + ))} +
    + )} +
    +
    + ); +}; diff --git a/apps/frontend/src/components/new-launch/picks.socials.component.tsx b/apps/frontend/src/components/new-launch/picks.socials.component.tsx index 116395ca91..0979a1931b 100644 --- a/apps/frontend/src/components/new-launch/picks.socials.component.tsx +++ b/apps/frontend/src/components/new-launch/picks.socials.component.tsx @@ -6,12 +6,32 @@ import SafeImage from '@gitroom/react/helpers/safe.image'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; import { useShallow } from 'zustand/react/shallow'; import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback'; +import { Check } from '@gitroom/frontend/components/launches/tags.component'; +import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; -export const PicksSocialsComponent: FC<{ toolTip?: boolean }> = ({ - toolTip, -}) => { +const PlatformBadge: FC<{ integration: Integrations }> = ({ integration }) => { + return integration.identifier === 'youtube' ? ( + + ) : ( + + ); +}; + +export const PicksSocialsComponent: FC<{ + toolTip?: boolean; + list?: boolean; +}> = ({ toolTip, list }) => { const exising = useExistingData(); const { @@ -28,76 +48,105 @@ export const PicksSocialsComponent: FC<{ toolTip?: boolean }> = ({ })) ); + const availableIntegrations = integrations.filter((f) => { + if (exising.integration) { + return f.id === exising.integration; + } + return !f.inBetweenSteps && !f.disabled; + }); + + const toggleIntegration = (integration: Integrations) => () => { + if (exising.integration) { + return; + } + addOrRemoveSelectedIntegration(integration, {}); + }; + + if (list) { + return ( +
    + {availableIntegrations.map((integration) => { + const selected = selectedIntegrations.some( + (p) => p.integration.id === integration.id + ); + + return ( +
    +
    + + +
    +
    + {integration.name} +
    + {}} value={selected} /> +
    + ); + })} +
    + ); + } + return (
    - {integrations - .filter((f) => { - if (exising.integration) { - return f.id === exising.integration; - } - return !f.inBetweenSteps && !f.disabled; - }) - .map((integration) => ( + {availableIntegrations.map((integration) => ( +
    p.integration.id === integration.id + ) === -1 + ? 'grayscale border-transparent' + : 'border-[#622FF6]' + )} > -
    { - if (exising.integration) { - return; - } - addOrRemoveSelectedIntegration(integration, {}); - }} + p.integration.id === integration.id ) === -1 - ? 'grayscale border-transparent' - : 'border-[#622FF6]' - )} - > - p.integration.id === integration.id - ) === -1 - ? 'border-transparent' - : 'border-[#000]' - )} - alt={integration.identifier} - width={42} - height={42} - /> - {integration.identifier === 'youtube' ? ( - - ) : ( - + ? 'border-transparent' + : 'border-[#000]' )} -
    + alt={integration.identifier} + width={42} + height={42} + /> +
    - ))} +
    + ))}
    diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx index 58087ce66d..93d29f2b2f 100644 --- a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx @@ -307,7 +307,7 @@ export const withProvider = function (params: { ))} {(SettingsComponent || !!data?.internalPlugs?.length) && createPortal( -
    +
    {isGlobal && ( )} diff --git a/apps/frontend/src/components/new-launch/select.current.tsx b/apps/frontend/src/components/new-launch/select.current.tsx index e94b5f6209..bea6d8edf6 100644 --- a/apps/frontend/src/components/new-launch/select.current.tsx +++ b/apps/frontend/src/components/new-launch/select.current.tsx @@ -94,11 +94,11 @@ export const SelectCurrent: FC = () => { return ( <> -
    +
    @@ -108,14 +108,14 @@ export const SelectCurrent: FC = () => { setCurrent('global'); }} className={clsx( - 'cursor-pointer flex gap-[8px] rounded-[8px] w-[40px] h-[40px] justify-center items-center bg-newBgLineColor', + 'cursor-pointer flex gap-[8px] rounded-[8px] w-[40px] h-[40px] mobile:w-[44px] mobile:h-[44px] mobile:shrink-0 justify-center items-center bg-newBgLineColor mobile:bg-newSettings', current !== 'global' ? 'text-[#A3A3A3]' - : 'border border-[#FC69FF] text-[#FC69FF]' + : 'border mobile:border-[1.5px] border-[#FC69FF] text-[#FC69FF]' )} >
    - +
    {selectedIntegrations.map(({ integration }) => ( @@ -126,7 +126,7 @@ export const SelectCurrent: FC = () => { }} key={integration.id} className={clsx( - 'border cursor-pointer relative flex gap-[8px] w-[40px] h-[40px] rounded-[8px] items-center bg-newBgLineColor justify-center', + 'border mobile:border-[1.5px] cursor-pointer relative flex gap-[8px] w-[40px] h-[40px] mobile:w-[44px] mobile:h-[44px] mobile:shrink-0 rounded-[8px] items-center bg-newBgLineColor mobile:bg-newSettings justify-center', current === integration.id ? 'border-[#FC69FF] text-[#FC69FF]' : 'border-transparent' @@ -134,7 +134,7 @@ export const SelectCurrent: FC = () => { >
    X
    @@ -179,7 +179,9 @@ export const SelectCurrent: FC = () => { ))}
    -
    +
    ); }; diff --git a/apps/frontend/src/components/new-launch/u.text.tsx b/apps/frontend/src/components/new-launch/u.text.tsx index a03d392b97..68b266fafb 100644 --- a/apps/frontend/src/components/new-launch/u.text.tsx +++ b/apps/frontend/src/components/new-launch/u.text.tsx @@ -84,12 +84,13 @@ export const UText: FC<{ data-tooltip-id="tooltip" data-tooltip-content="Underline" onClick={mark} - className="select-none cursor-pointer rounded-[6px] w-[30px] h-[30px] bg-newColColor flex justify-center items-center" + className="select-none cursor-pointer rounded-[6px] mobile:rounded-[8px] w-[30px] h-[30px] mobile:w-[40px] mobile:h-[40px] bg-newColColor mobile:bg-newSettings flex justify-center items-center" > diff --git a/apps/frontend/src/components/settings/signatures.component.tsx b/apps/frontend/src/components/settings/signatures.component.tsx index 6ed08fdb79..cf10632e61 100644 --- a/apps/frontend/src/components/settings/signatures.component.tsx +++ b/apps/frontend/src/components/settings/signatures.component.tsx @@ -1,4 +1,4 @@ -import React, { FC, Fragment, useCallback } from 'react'; +import React, { FC, useCallback } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import useSWR from 'swr'; import { Button } from '@gitroom/react/form/button'; @@ -67,7 +67,7 @@ export const SignaturesComponent: FC<{ 'You can add signatures to your account to be used in your posts.' )}
    -
    +
    {!!data?.length && (
    -
    {t('content', 'Content')}
    -
    {t('auto_add', 'Auto Add?')}
    +
    {t('content', 'Content')}
    +
    + {t('auto_add', 'Auto Add?')} +
    {!!appendSignature && ( -
    {t('actions', 'Actions')}
    +
    + {t('actions', 'Actions')} +
    )} -
    {t('edit', 'Edit')}
    -
    {t('delete', 'Delete')}
    +
    {t('edit', 'Edit')}
    +
    + {t('delete', 'Delete')} +
    {data?.map((p: any) => ( - -
    -
    + // a row of the table, a card on phones +
    +
    +
    {p.content.slice(0, 15) + '...'}
    -
    -
    +
    +
    + + {t('auto_add', 'Auto Add?')}{' '} + {p.autoAdd ? 'Yes' : 'No'}
    {!!appendSignature && ( -
    -
    )} -
    -
    -
    -
    -
    -
    - +
    ))}
    )}
    diff --git a/apps/frontend/src/components/signature.tsx b/apps/frontend/src/components/signature.tsx index fbacf23743..afe26ee869 100644 --- a/apps/frontend/src/components/signature.tsx +++ b/apps/frontend/src/components/signature.tsx @@ -26,12 +26,13 @@ export const SignatureBox: FC<{ onClick={addSignature} data-tooltip-id="tooltip" data-tooltip-content="Add Signature" - className="select-none cursor-pointer rounded-[6px] w-[30px] h-[30px] bg-newColColor flex justify-center items-center" + className="select-none cursor-pointer rounded-[6px] mobile:rounded-[8px] w-[30px] h-[30px] mobile:w-[40px] mobile:h-[40px] bg-newColColor mobile:bg-newSettings flex justify-center items-center" > diff --git a/apps/frontend/src/components/third-parties/third-party.media.tsx b/apps/frontend/src/components/third-parties/third-party.media.tsx index c523bcdc5a..b75b346a8c 100644 --- a/apps/frontend/src/components/third-parties/third-party.media.tsx +++ b/apps/frontend/src/components/third-parties/third-party.media.tsx @@ -181,7 +181,7 @@ export const ThirdPartyMedia: FC<{ return ( <> -
    +
    void; +}> = ({ title, onClose }) => { + return ( + <> +
    +
    +
    {title}
    +
    + +
    +
    + + ); +}; + +export const BottomSheetButton: FC<{ + label: string; + onClick: () => void; + disabled?: boolean; +}> = ({ label, onClick, disabled }) => { + return ( +
    + +
    + ); +}; + +// slides up from the bottom of the screen, opened from the mobile layout +export const BottomSheet: FC<{ + title: string; + onClose: () => void; + children: ReactNode; + // the main action under the content, like Done or Save + button?: { label: string; onClick: () => void; disabled?: boolean }; +}> = ({ title, onClose, children, button }) => { + return ( +
    +
    +
    + +
    + {children} +
    + {button && } +
    +
    + ); +}; + +export const BottomSheetRow: FC<{ + icon: ReactNode; + label: string; + onClick: () => void; +}> = ({ icon, label, onClick }) => { + return ( +
    +
    {icon}
    +
    {label}
    + +
    + ); +}; diff --git a/apps/frontend/src/components/ui/icons/index.tsx b/apps/frontend/src/components/ui/icons/index.tsx index 5991386a00..0ef85072d8 100644 --- a/apps/frontend/src/components/ui/icons/index.tsx +++ b/apps/frontend/src/components/ui/icons/index.tsx @@ -41,6 +41,58 @@ export const SettingsIcon: FC = ({ ); +// Settings/Gear Outline Icon +export const SettingsOutlineIcon: FC = ({ + size = 20, + className, + ...props +}) => ( + + + + +); + +// Eye Icon +export const EyeIcon: FC = ({ size = 20, className, ...props }) => ( + + + + +); + // Chevron Down Icon (rotatable) export const ChevronDownIcon: FC = ({ size = 20, diff --git a/i18n.lock b/i18n.lock index 93a43fef56..b6242e31d0 100644 --- a/i18n.lock +++ b/i18n.lock @@ -799,3 +799,12 @@ checksums: preview_comment_text_changed: 6a4298cc0304d150e0d150689fa049f4 preview_no_comments_yet: e9bda0977d617e8ee0079feba73b3c3a some_files_failed_to_upload: 87c15cf17373c535da85470188df68c7 + done: ffd408fa29d5bc9039ef8ea1b9b699bb + add_tag: 2cfa04ceea966149f2b5d40d9c131141 + repeat_post: fb5ad8292bcc2fe131e69a62ff1fa9d3 + do_not_repeat: 4b92afcdc3206cb17da5257809c21fb6 + change_date_or_time: 76fcdf62ab2834115a1bea54d2ef0779 + time: b504a03d52e8001bfdc5cb6205364f42 + channels_settings: 770e5ae7cec0a83e1454246b52591093 + delete_selected: adad57b0659260858fbf786ef31e9612 + are_you_sure_you_want_to_delete_selected_media: 2fcf9a6ddcd40aa74d2cf914730ecc4b diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index a54763d0c1..d8a536ac3a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "التعليقات على النص المحدد تفقد تمييزها إذا تم تعديل النص لاحقًا.", "preview_comment_text_changed": "تم تغيير النص", "preview_no_comments_yet": "لا توجد تعليقات بعد. حدد بعض النص في المنشور للتعليق عليه، أو أضف تعليقًا عامًا.", - "some_files_failed_to_upload": "فشل تحميل بعض الملفات، يرجى المحاولة مرة أخرى" + "some_files_failed_to_upload": "فشل تحميل بعض الملفات، يرجى المحاولة مرة أخرى", + "done": "تم", + "add_tag": "إضافة وسم", + "repeat_post": "تكرار المنشور", + "do_not_repeat": "عدم التكرار", + "change_date_or_time": "تغيير التاريخ أو الوقت", + "time": "الوقت", + "channels_settings": "إعدادات القناة", + "delete_selected": "حذف المحدد", + "are_you_sure_you_want_to_delete_selected_media": "هل أنت متأكد أنك تريد حذف الوسائط المحددة؟" } diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 31663d694c..e94990edff 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "নির্বাচিত টেক্সটে মন্তব্য করলে, টেক্সট সম্পাদনার পর হাইলাইট হারিয়ে যেতে পারে।", "preview_comment_text_changed": "টেক্সট পরিবর্তিত হয়েছে", "preview_no_comments_yet": "এখনও কোনো মন্তব্য নেই। পোস্টে কিছু টেক্সট নির্বাচন করে অথবা সাধারণ মন্তব্য যোগ করে মন্তব্য করুন।", - "some_files_failed_to_upload": "কিছু ফাইল আপলোড করতে ব্যর্থ হয়েছে, অনুগ্রহ করে আবার চেষ্টা করুন" + "some_files_failed_to_upload": "কিছু ফাইল আপলোড করতে ব্যর্থ হয়েছে, অনুগ্রহ করে আবার চেষ্টা করুন", + "done": "সম্পন্ন", + "add_tag": "ট্যাগ যোগ করুন", + "repeat_post": "পোস্ট পুনরাবৃত্তি", + "do_not_repeat": "পুনরাবৃত্তি করবেন না", + "change_date_or_time": "তারিখ বা সময় পরিবর্তন করুন", + "time": "সময়", + "channels_settings": "চ্যানেল সেটিংস", + "delete_selected": "নির্বাচিতগুলো মুছে ফেলুন", + "are_you_sure_you_want_to_delete_selected_media": "আপনি কি নিশ্চিত যে আপনি নির্বাচিত মিডিয়া মুছে ফেলতে চান?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 9f3c2ddb91..f20dcf9b38 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "Kommentare zu ausgewähltem Text verlieren ihre Markierung, wenn der Text später bearbeitet wird.", "preview_comment_text_changed": "Text geändert", "preview_no_comments_yet": "Noch keine Kommentare. Wählen Sie einen Text im Beitrag aus, um ihn zu kommentieren, oder fügen Sie einen allgemeinen Kommentar hinzu.", - "some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden. Bitte versuchen Sie es erneut." + "some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden. Bitte versuchen Sie es erneut.", + "done": "Fertig", + "add_tag": "Schlagwort hinzufügen", + "repeat_post": "Beitrag wiederholen", + "do_not_repeat": "Nicht wiederholen", + "change_date_or_time": "Datum oder Uhrzeit ändern", + "time": "Uhrzeit", + "channels_settings": "Kanaleinstellungen", + "delete_selected": "Ausgewählte löschen", + "are_you_sure_you_want_to_delete_selected_media": "Bist du sicher, dass du die ausgewählten Medien löschen möchtest?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 4ead7f9a5b..915a52f437 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -796,5 +796,14 @@ "preview_comment_detach_hint": "Comments on selected text lose their highlight if the text is edited later.", "preview_comment_text_changed": "Text changed", "preview_no_comments_yet": "No comments yet. Select some text in the post to comment on it, or add a general comment.", - "some_files_failed_to_upload": "Some files failed to upload, please try again" + "some_files_failed_to_upload": "Some files failed to upload, please try again", + "done": "Done", + "add_tag": "Add Tag", + "repeat_post": "Repeat Post", + "do_not_repeat": "Do Not Repeat", + "change_date_or_time": "Change Date or Time", + "time": "Time", + "channels_settings": "Channel Settings", + "delete_selected": "Delete Selected", + "are_you_sure_you_want_to_delete_selected_media": "Are you sure you want to delete the selected media?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 8e13f52930..2fecea932d 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "Los comentarios sobre texto seleccionado pierden su resalte si el texto se edita más tarde.", "preview_comment_text_changed": "Texto modificado", "preview_no_comments_yet": "Aún no hay comentarios. Selecciona texto en la publicación para comentarlo o agrega un comentario general.", - "some_files_failed_to_upload": "Algunos archivos no se pudieron subir, por favor inténtalo de nuevo" + "some_files_failed_to_upload": "Algunos archivos no se pudieron subir, por favor inténtalo de nuevo", + "done": "Listo", + "add_tag": "Agregar etiqueta", + "repeat_post": "Repetir publicación", + "do_not_repeat": "No repetir", + "change_date_or_time": "Cambiar fecha u hora", + "time": "Hora", + "channels_settings": "Configuración del canal", + "delete_selected": "Eliminar seleccionados", + "are_you_sure_you_want_to_delete_selected_media": "¿Estás seguro de que quieres eliminar los medios seleccionados?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 328accd702..80d19af6e9 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "Les commentaires sur le texte sélectionné perdent leur surlignement si le texte est modifié par la suite.", "preview_comment_text_changed": "Texte modifié", "preview_no_comments_yet": "Aucun commentaire pour le moment. Sélectionnez du texte dans la publication pour le commenter, ou ajoutez un commentaire général.", - "some_files_failed_to_upload": "Échec du téléversement de certains fichiers, veuillez réessayer." + "some_files_failed_to_upload": "Échec du téléversement de certains fichiers, veuillez réessayer.", + "done": "Terminé", + "add_tag": "Ajouter un tag", + "repeat_post": "Répéter la publication", + "do_not_repeat": "Ne pas répéter", + "change_date_or_time": "Modifier la date ou l'heure", + "time": "Heure", + "channels_settings": "Paramètres des canaux", + "delete_selected": "Supprimer la sélection", + "are_you_sure_you_want_to_delete_selected_media": "Êtes-vous sûr de vouloir supprimer les médias sélectionnés ?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index e22a740635..71b5a5218e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "תגובות על טקסט נבחר מאבדות הדגשה אם הטקסט נערך מאוחר יותר.", "preview_comment_text_changed": "הטקסט השתנה", "preview_no_comments_yet": "עדיין אין תגובות. בחר טקסט בפוסט כדי להגיב עליו, או הוסף תגובה כללית.", - "some_files_failed_to_upload": "חלק מהקבצים לא הועלו, אנא נסה שוב" + "some_files_failed_to_upload": "חלק מהקבצים לא הועלו, אנא נסה שוב", + "done": "סיום", + "add_tag": "הוסף תגית", + "repeat_post": "חזרה על הפוסט", + "do_not_repeat": "ללא חזרה", + "change_date_or_time": "שינוי תאריך או שעה", + "time": "שעה", + "channels_settings": "הגדרות ערוץ", + "delete_selected": "מחק את הנבחרים", + "are_you_sure_you_want_to_delete_selected_media": "האם אתה בטוח שברצונך למחוק את המדיה שנבחרה?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index d917f74025..ae3befca3e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "I commenti su testo selezionato perdono l'evidenziazione se il testo viene modificato successivamente.", "preview_comment_text_changed": "Testo modificato", "preview_no_comments_yet": "Nessun commento ancora. Seleziona del testo nel post per commentarlo, oppure aggiungi un commento generale.", - "some_files_failed_to_upload": "Alcuni file non sono stati caricati, riprova" + "some_files_failed_to_upload": "Alcuni file non sono stati caricati, riprova", + "done": "Fatto", + "add_tag": "Aggiungi tag", + "repeat_post": "Ripeti post", + "do_not_repeat": "Non ripetere", + "change_date_or_time": "Cambia data o ora", + "time": "Ora", + "channels_settings": "Impostazioni del canale", + "delete_selected": "Elimina selezionati", + "are_you_sure_you_want_to_delete_selected_media": "Sei sicuro di voler eliminare i media selezionati?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index f952bf02f6..c5ee8d26dc 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "選択テキストへのコメントは、テキストが後から編集されるとハイライトが失われます。", "preview_comment_text_changed": "テキストが変更されました", "preview_no_comments_yet": "まだコメントはありません。投稿内のテキストを選択してコメントするか、一般的なコメントを追加してください。", - "some_files_failed_to_upload": "いくつかのファイルのアップロードに失敗しました。もう一度お試しください。" + "some_files_failed_to_upload": "いくつかのファイルのアップロードに失敗しました。もう一度お試しください。", + "done": "完了", + "add_tag": "タグを追加", + "repeat_post": "投稿を繰り返す", + "do_not_repeat": "繰り返さない", + "change_date_or_time": "日付または時刻を変更", + "time": "時刻", + "channels_settings": "チャンネル設定", + "delete_selected": "選択したものを削除", + "are_you_sure_you_want_to_delete_selected_media": "選択したメディアを削除してもよろしいですか?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index 47ab8a4e54..e4c8efd40b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "선택한 텍스트에 단 댓글은, 텍스트가 나중에 수정되면 강조가 사라집니다.", "preview_comment_text_changed": "텍스트가 변경됨", "preview_no_comments_yet": "아직 댓글이 없습니다. 게시물에서 텍스트를 선택해 댓글을 남기거나, 일반 댓글을 추가하세요.", - "some_files_failed_to_upload": "일부 파일을 업로드하지 못했습니다. 다시 시도해 주세요." + "some_files_failed_to_upload": "일부 파일을 업로드하지 못했습니다. 다시 시도해 주세요.", + "done": "완료", + "add_tag": "태그 추가", + "repeat_post": "게시물 반복", + "do_not_repeat": "반복 안 함", + "change_date_or_time": "날짜 또는 시간 변경", + "time": "시간", + "channels_settings": "채널 설정", + "delete_selected": "선택 항목 삭제", + "are_you_sure_you_want_to_delete_selected_media": "선택한 미디어를 삭제하시겠습니까?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 67dd663e1a..298656d7dd 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "Comentários em textos selecionados perdem o destaque se o texto for editado depois.", "preview_comment_text_changed": "Texto alterado", "preview_no_comments_yet": "Ainda não há comentários. Selecione um trecho do post para comentar ou adicione um comentário geral.", - "some_files_failed_to_upload": "Alguns arquivos não foram carregados, tente novamente" + "some_files_failed_to_upload": "Alguns arquivos não foram carregados, tente novamente", + "done": "Concluído", + "add_tag": "Adicionar tag", + "repeat_post": "Repetir post", + "do_not_repeat": "Não repetir", + "change_date_or_time": "Alterar data ou hora", + "time": "Hora", + "channels_settings": "Configurações do canal", + "delete_selected": "Excluir selecionados", + "are_you_sure_you_want_to_delete_selected_media": "Tem certeza de que deseja excluir as mídias selecionadas?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 36176fea64..3d5767fbd7 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "Комментарии к выделенному тексту теряют выделение, если текст позже изменяется.", "preview_comment_text_changed": "Текст изменён", "preview_no_comments_yet": "Пока нет ни одного комментария. Выделите текст в публикации, чтобы оставить к нему комментарий, или добавьте общий комментарий.", - "some_files_failed_to_upload": "Не удалось загрузить некоторые файлы, пожалуйста, попробуйте еще раз" + "some_files_failed_to_upload": "Не удалось загрузить некоторые файлы, пожалуйста, попробуйте еще раз", + "done": "Готово", + "add_tag": "Добавить тег", + "repeat_post": "Повтор поста", + "do_not_repeat": "Не повторять", + "change_date_or_time": "Изменить дату или время", + "time": "Время", + "channels_settings": "Настройки канала", + "delete_selected": "Удалить выбранные", + "are_you_sure_you_want_to_delete_selected_media": "Вы уверены, что хотите удалить выбранные медиа?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index 889a89cb8e..1506b36082 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "Seçili metindeki yorumlar, metin daha sonra değiştirilirse vurgusunu kaybeder.", "preview_comment_text_changed": "Metin değişti", "preview_no_comments_yet": "Henüz yorum yok. Yorum yapmak için gönderide bir metin seçin veya genel bir yorum ekleyin.", - "some_files_failed_to_upload": "Bazı dosyalar yüklenemedi, lütfen tekrar deneyin" + "some_files_failed_to_upload": "Bazı dosyalar yüklenemedi, lütfen tekrar deneyin", + "done": "Bitti", + "add_tag": "Etiket Ekle", + "repeat_post": "Gönderiyi Tekrarla", + "do_not_repeat": "Tekrarlama", + "change_date_or_time": "Tarihi veya Saati Değiştir", + "time": "Saat", + "channels_settings": "Kanal Ayarları", + "delete_selected": "Seçilenleri sil", + "are_you_sure_you_want_to_delete_selected_media": "Seçili medyayı silmek istediğinizden emin misiniz?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 3121edd33d..02e2152d14 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "Bình luận trên đoạn văn bản được chọn sẽ mất nổi bật nếu văn bản bị chỉnh sửa sau đó.", "preview_comment_text_changed": "Nội dung đã thay đổi", "preview_no_comments_yet": "Chưa có bình luận nào. Chọn một đoạn văn bản trong bài viết để bình luận, hoặc thêm bình luận chung.", - "some_files_failed_to_upload": "Một số tệp không tải lên được, vui lòng thử lại" + "some_files_failed_to_upload": "Một số tệp không tải lên được, vui lòng thử lại", + "done": "Xong", + "add_tag": "Thêm thẻ", + "repeat_post": "Lặp lại bài đăng", + "do_not_repeat": "Không lặp lại", + "change_date_or_time": "Thay đổi ngày hoặc giờ", + "time": "Giờ", + "channels_settings": "Cài đặt kênh", + "delete_selected": "Xóa mục đã chọn", + "are_you_sure_you_want_to_delete_selected_media": "Bạn có chắc muốn xóa phương tiện đã chọn không?" } diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 46960af9fa..b9a5813f31 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -794,5 +794,14 @@ "preview_comment_detach_hint": "如果后续编辑了所选文本,所选文本上的评论高亮将丢失。", "preview_comment_text_changed": "文本已更改", "preview_no_comments_yet": "还没有评论。选中帖子中的某些文本以进行评论,或添加一个总体评论。", - "some_files_failed_to_upload": "部分文件上传失败,请重试" + "some_files_failed_to_upload": "部分文件上传失败,请重试", + "done": "完成", + "add_tag": "添加标签", + "repeat_post": "重复发布", + "do_not_repeat": "不重复", + "change_date_or_time": "更改日期或时间", + "time": "时间", + "channels_settings": "频道设置", + "delete_selected": "删除所选", + "are_you_sure_you_want_to_delete_selected_media": "你确定要删除所选媒体吗?" } From 6f79545d4a26ffe88ec114ec437c850ebce375d3 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 6 Oct 2026 17:47:25 +0700 Subject: [PATCH 70/99] fix(calendar): open-post icon no longer lands on a wrong or missing TikTok link The open-post icon could send the user to their TikTok profile, or to a page TikTok rejects, when the post link was not usable yet. - Photo posts: both TikTok providers now build /photo/ for photo posts (publish ids starting with p_pub_) instead of /video/, in checkPostStatus and resolveReleaseId. TikTok answered 403 for the /video/ path of a freshly published photo post. - Link not released yet: resolveReleaseId returns { pending: true } while TikTok has no public id, and the icon shows a "try again in a minute" toast instead of opening the profile. The same toast covers a request that fails or takes longer than 5 seconds. - No public link: only-me and friends-only posts never get a public id, so the resolver (which now receives the post settings) returns { unavailable: true } without calling TikTok and the icon shows "This post has no public link". - Token handling: getReleaseURL no longer refreshes an expired token up front. It refreshes only after the platform rejects the token and retries once, so a post whose link is already resolved never touches the platform or the channel. When that refresh fails the icon shows a "reconnect this channel" toast. The tab is still opened in the click and closed again when there is nothing to open, so popup blockers keep allowing it. Co-Authored-By: Claude Fable 5.1 --- .../src/components/launches/calendar.tsx | 51 +++++++++++++++--- .../database/prisma/posts/posts.service.ts | 53 +++++++++++++------ .../social/social.integrations.interface.ts | 10 +++- .../social/tiktok.business.provider.ts | 25 +++++++-- .../integrations/social/tiktok.provider.ts | 23 ++++++-- 5 files changed, 131 insertions(+), 31 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 40a489d103..8d18d57bbe 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -38,6 +38,7 @@ import { useDrag, useDrop } from 'react-dnd'; import { Integration, Post, State, Tags } from '@prisma/client'; import { useAddProvider } from '@gitroom/frontend/components/launches/add.provider.component'; import { useToaster } from '@gitroom/react/toaster/toaster'; +import { timer } from '@gitroom/helpers/utils/timer'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; @@ -269,17 +270,55 @@ const usePostActions = (onMutate?: () => void) => { return; } tab.opener = null; - let releaseURL = post.releaseURL; + let release = { + releaseURL: '', + pending: true, + unavailable: false, + reconnect: false, + }; try { - releaseURL = - (await (await fetch(`/posts/${post.id}/release-url`)).json()) - .releaseURL || releaseURL; + release = await Promise.race([ + fetch(`/posts/${post.id}/release-url`).then((r) => r.json()), + timer(5000).then(() => release), + ]); } catch (e) {} + if (release.unavailable) { + tab.close(); + toaster.show( + t('post_has_no_public_link', 'This post has no public link'), + 'warning' + ); + return; + } + if (release.reconnect) { + tab.close(); + toaster.show( + t( + 'post_link_reconnect_channel', + 'Reconnect this channel to open the post link' + ), + 'warning' + ); + return; + } + // the platform has not released the post link yet, or the request + // failed or took too long + if (release.pending) { + tab.close(); + toaster.show( + t( + 'post_link_not_ready', + 'The post link is not available yet, please try again in a minute' + ), + 'warning' + ); + return; + } // multi-target posts (several subreddits / communities / channels) // join their URLs with commas: open the first one - tab.location.href = releaseURL.split(',')[0]; + tab.location.href = (release.releaseURL || post.releaseURL).split(',')[0]; }, - [fetch] + [fetch, toaster, t] ); return { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease, openPost }; diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 58c639ae15..319afb1055 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -162,6 +162,7 @@ export class PostsService { id: string; releaseId: string; releaseURL: string; + settings: string; integration: Integration; } ) { @@ -172,10 +173,20 @@ export class PostsService { const resolved = await integrationProvider.resolveReleaseId?.( post.integration.token, post.releaseId, - post.integration + post.integration, + JSON.parse(post.settings || '{}') ); - if (!resolved || resolved.postId === post.releaseId) { - return { releaseId: post.releaseId, releaseURL: post.releaseURL }; + if ( + !resolved || + !('postId' in resolved) || + resolved.postId === post.releaseId + ) { + return { + releaseId: post.releaseId, + releaseURL: post.releaseURL, + pending: !!resolved && 'pending' in resolved, + unavailable: !!resolved && 'unavailable' in resolved, + }; } await this._postRepository.updateResolvedRelease( @@ -184,14 +195,24 @@ export class PostsService { resolved.postId, resolved.releaseURL ); - return { releaseId: resolved.postId, releaseURL: resolved.releaseURL }; + return { + releaseId: resolved.postId, + releaseURL: resolved.releaseURL, + pending: false, + unavailable: false, + }; } async getReleaseURL( orgId: string, postId: string, forceRefresh = false - ): Promise<{ releaseURL: string }> { + ): Promise<{ + releaseURL: string; + pending?: boolean; + unavailable?: boolean; + reconnect?: boolean; + }> { const post = await this._postRepository.getPostById(postId, orgId); if (!post || !post.releaseURL) { return { releaseURL: '' }; @@ -207,15 +228,14 @@ export class PostsService { const getIntegration = post.integration!; - if ( - dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || - forceRefresh - ) { + // only refreshed once the platform rejects the token, so a post that is + // already resolved never reaches the platform + if (forceRefresh) { const data = await this._refreshIntegrationService.refresh( getIntegration ); if (!data) { - return { releaseURL: post.releaseURL }; + return { releaseURL: post.releaseURL, reconnect: true }; } const { accessToken } = data; @@ -228,21 +248,24 @@ export class PostsService { } } else { await this._integrationService.disconnectChannel(orgId, getIntegration); - return { releaseURL: post.releaseURL }; + return { releaseURL: post.releaseURL, reconnect: true }; } } try { - const { releaseURL } = await this.resolveRelease(orgId, post); - return { releaseURL }; + const { releaseURL, pending, unavailable } = await this.resolveRelease( + orgId, + post + ); + return { releaseURL, pending, unavailable }; } catch (e) { console.log(e); - if (e instanceof RefreshToken) { + if (e instanceof RefreshToken && !forceRefresh) { return this.getReleaseURL(orgId, postId, true); } } - return { releaseURL: post.releaseURL }; + return { releaseURL: post.releaseURL, pending: true }; } async checkPostAnalytics( diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index 52bf2fc43c..3330110161 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -37,8 +37,14 @@ export interface IAuthenticator { resolveReleaseId?( accessToken: string, releaseId: string, - integration: Integration - ): Promise<{ postId: string; releaseURL: string } | undefined>; // Final id + URL to persist when the stored releaseId is still a publish id, undefined when nothing to resolve (yet) + integration: Integration, + settings: any + ): Promise< + | { postId: string; releaseURL: string } + | { pending: true } + | { unavailable: true } + | undefined + >; // Final id + URL to persist when the stored releaseId is still a publish id, pending when the platform has no final id yet, unavailable when the post will never get one, undefined when nothing to resolve changeNickname?( id: string, accessToken: string, diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts index 875ac6a738..a57b11dc60 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts @@ -491,7 +491,7 @@ export class TiktokBusinessProvider status: 'completed', releaseURL: !publicPostId ? `https://www.tiktok.com/@${integration.profile}` - : `https://www.tiktok.com/@${integration.profile}/video/${publicPostId}`, + : this.postUrl(integration, pendingData.publishId, publicPostId), postId: !publicPostId ? pendingData.publishId : publicPostId, }; } @@ -931,6 +931,13 @@ export class TiktokBusinessProvider return videoListData?.data?.videos; } + // photo posts (p_pub_...) live under /photo/, TikTok rejects /video/ for them + private postUrl(integration: Integration, publishId: string, postId: string) { + return `https://www.tiktok.com/@${integration.profile}/${ + publishId.indexOf('p_pub_') === 0 ? 'photo' : 'video' + }/${postId}`; + } + // post_ids holds int64 ids that exceed Number.MAX_SAFE_INTEGER, so the id // must be read from the raw body instead of the parsed JSON private publicPostId(body: string) { @@ -1112,12 +1119,22 @@ export class TiktokBusinessProvider async resolveReleaseId( accessToken: string, releaseId: string, - integration: Integration + integration: Integration, + settings: any ) { if (releaseId.indexOf('_pub_') === -1) { return undefined; } + // privacy_level only applies to photo posts here, and TikTok only gives a + // post id to posts published for public viewership + if ( + releaseId.indexOf('p_pub_') === 0 && + ['SELF_ONLY', 'MUTUAL_FOLLOW_FRIENDS'].includes(settings?.privacy_level) + ) { + return { unavailable: true as const }; + } + const body = await ( await this.fetch( `${this.baseUrl}/business/publish/status/?business_id=${encodeURIComponent( @@ -1136,12 +1153,12 @@ export class TiktokBusinessProvider const publicPostId = this.publicPostId(body); if (!publicPostId) { - return undefined; + return { pending: true as const }; } return { postId: publicPostId, - releaseURL: `https://www.tiktok.com/@${integration.profile}/video/${publicPostId}`, + releaseURL: this.postUrl(integration, releaseId, publicPostId), }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index 7f2e453c57..759ed88e3c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -497,7 +497,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { status: 'completed', releaseURL: !publicPostId ? `https://www.tiktok.com/@${integration.profile}` - : `https://www.tiktok.com/@${integration.profile}/video/${publicPostId}`, + : this.postUrl(integration, pendingData.publishId, publicPostId), postId: !publicPostId ? pendingData.publishId : publicPostId, }; } @@ -515,6 +515,13 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { return { status: 'pending', pendingData }; } + // photo posts (p_pub_...) live under /photo/, TikTok rejects /video/ for them + private postUrl(integration: Integration, publishId: string, postId: string) { + return `https://www.tiktok.com/@${integration.profile}/${ + publishId.indexOf('p_pub_') === 0 ? 'photo' : 'video' + }/${postId}`; + } + // TikTok returns publicaly_available_post_id as an int64, which JSON.parse // rounds past 2^53 - pull the digits out of the raw body before parsing. private parsePublishStatus(raw: string) { @@ -1147,12 +1154,20 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { async resolveReleaseId( accessToken: string, releaseId: string, - integration: Integration + integration: Integration, + settings: any ) { if (releaseId.indexOf('_pub_') === -1) { return undefined; } + // TikTok only gives a post id to posts published for public viewership + if ( + ['SELF_ONLY', 'MUTUAL_FOLLOW_FRIENDS'].includes(settings?.privacy_level) + ) { + return { unavailable: true as const }; + } + const { publicPostId } = this.parsePublishStatus( await ( await this.fetch( @@ -1172,12 +1187,12 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { ); if (!publicPostId) { - return undefined; + return { pending: true as const }; } return { postId: publicPostId, - releaseURL: `https://www.tiktok.com/@${integration.profile}/video/${publicPostId}`, + releaseURL: this.postUrl(integration, releaseId, publicPostId), }; } From 506d9a03dba876d29c7d08e802ddb2e5909785e5 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 6 Oct 2026 17:47:25 +0700 Subject: [PATCH 71/99] chore(i18n): translations for the open-post link toasts Co-Authored-By: Claude Fable 5.1 --- i18n.lock | 3 +++ .../src/translation/locales/ar/translation.json | 3 +++ .../src/translation/locales/bn/translation.json | 3 +++ .../src/translation/locales/de/translation.json | 3 +++ .../src/translation/locales/en/translation.json | 3 +++ .../src/translation/locales/es/translation.json | 3 +++ .../src/translation/locales/fr/translation.json | 3 +++ .../src/translation/locales/he/translation.json | 3 +++ .../src/translation/locales/it/translation.json | 3 +++ .../src/translation/locales/ja/translation.json | 3 +++ .../src/translation/locales/ko/translation.json | 3 +++ .../src/translation/locales/pt/translation.json | 3 +++ .../src/translation/locales/ru/translation.json | 3 +++ .../src/translation/locales/tr/translation.json | 3 +++ .../src/translation/locales/vi/translation.json | 3 +++ .../src/translation/locales/zh/translation.json | 3 +++ 16 files changed, 48 insertions(+) diff --git a/i18n.lock b/i18n.lock index 2f4cf9bb6a..890d3da198 100644 --- a/i18n.lock +++ b/i18n.lock @@ -339,6 +339,9 @@ checksums: duplicate_post: 550d07a1efcab352409ecdbaa16acfcc preview_post: a86240c3451c3d5737b6acf358e1d056 open_post: 7973a6d7427806d086720278a9b5dba0 + post_link_not_ready: b27af79911e6028b2276a1e45b7c3dfe + post_has_no_public_link: e003c7de40911d49922d9f668bffc280 + post_link_reconnect_channel: 98822b4b34a2625a70d308938cdcb78a post_statistics: 946015ed60528cf3b173135542053d3e draft: e8a92958ad300aacfe46c2bf6644927e week_number: 94b8a343e6e9a25c021e055ad974c68e diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index a995efd815..60abfa65b8 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "منشور مكرر", "preview_post": "معاينة المنشور", "open_post": "فتح المشاركة", + "post_link_not_ready": "رابط المنشور غير متوفر بعد، يرجى المحاولة مرة أخرى بعد دقيقة", + "post_has_no_public_link": "هذا المنشور ليس له رابط عام", + "post_link_reconnect_channel": "أعد ربط هذه القناة لفتح رابط المنشور", "post_statistics": "إحصائيات المنشور", "draft": "مسودة", "week_number": "الأسبوع {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 122e71c7d0..cc18723b86 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "পোস্ট ডুপ্লিকেট করুন", "preview_post": "পোস্ট প্রিভিউ", "open_post": "পোস্ট খুলুন", + "post_link_not_ready": "পোস্টের লিংকটি এখনও উপলভ্য নয়, অনুগ্রহ করে এক মিনিট পরে আবার চেষ্টা করুন", + "post_has_no_public_link": "এই পোস্টটির কোনো পাবলিক লিংক নেই", + "post_link_reconnect_channel": "পোস্টের লিংক খুলতে এই চ্যানেলটি পুনরায় সংযুক্ত করুন", "post_statistics": "পোস্ট পরিসংখ্যান", "draft": "খসড়া", "week_number": "সপ্তাহ নম্বর", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index d688362257..76d55aabc8 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Dupliziere Beitrag", "preview_post": "Beitragsvorschau", "open_post": "Beitrag öffnen", + "post_link_not_ready": "Der Beitragslink ist noch nicht verfügbar, bitte versuche es in einer Minute erneut.", + "post_has_no_public_link": "Dieser Beitrag hat keinen öffentlichen Link.", + "post_link_reconnect_channel": "Verbinde diesen Kanal erneut, um den Beitragslink zu öffnen.", "post_statistics": "Beitragsstatistiken", "draft": "Entwurf", "week_number": "Woche {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 384a8975b0..3155a9c9b5 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Duplicate Post", "preview_post": "Preview Post", "open_post": "Open Post", + "post_link_not_ready": "The post link is not available yet, please try again in a minute", + "post_has_no_public_link": "This post has no public link", + "post_link_reconnect_channel": "Reconnect this channel to open the post link", "post_statistics": "Post Statistics", "draft": "Draft", "week_number": "Week {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 1fd60f4ccd..724a2daa40 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Publicación duplicada", "preview_post": "Vista previa de la publicación", "open_post": "Abrir publicación", + "post_link_not_ready": "El enlace de la publicación aún no está disponible, por favor intenta de nuevo en un minuto", + "post_has_no_public_link": "Esta publicación no tiene un enlace público", + "post_link_reconnect_channel": "Vuelve a conectar este canal para abrir el enlace de la publicación", "post_statistics": "Estadísticas de la publicación", "draft": "Borrador", "week_number": "Semana {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index ecb488fa4f..98af21f005 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Publication en double", "preview_post": "Aperçu de la publication", "open_post": "Ouvrir le post", + "post_link_not_ready": "Le lien vers la publication n'est pas encore disponible, veuillez réessayer dans une minute", + "post_has_no_public_link": "Cette publication n'a pas de lien public", + "post_link_reconnect_channel": "Reconnectez ce canal pour ouvrir le lien de la publication", "post_statistics": "Statistiques de la publication", "draft": "Brouillon", "week_number": "Semaine {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index f2f2677f63..a17a59990a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "פוסט כפול", "preview_post": "תצוגה מקדימה של הפוסט", "open_post": "פתח פוסט", + "post_link_not_ready": "קישור הפוסט עדיין לא זמין, אנא נסו שוב בעוד דקה", + "post_has_no_public_link": "לפוסט הזה אין קישור ציבורי", + "post_link_reconnect_channel": "חברו מחדש את הערוץ הזה כדי לפתוח את קישור הפוסט", "post_statistics": "סטטיסטיקות פוסט", "draft": "טיוטה", "week_number": "שבוע {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 0e4d3aa264..c20f43bc93 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Post duplicato", "preview_post": "Anteprima post", "open_post": "Apri post", + "post_link_not_ready": "Il link del post non è ancora disponibile, riprova tra un minuto", + "post_has_no_public_link": "Questo post non ha un link pubblico", + "post_link_reconnect_channel": "Ricollega questo canale per aprire il link del post", "post_statistics": "Statistiche del post", "draft": "Bozza", "week_number": "Settimana {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 2084092adc..f21f98bbd6 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "重複した投稿", "preview_post": "投稿をプレビュー", "open_post": "投稿を開く", + "post_link_not_ready": "投稿リンクはまだ利用できません。1分後に再度お試しください。", + "post_has_no_public_link": "この投稿には公開リンクがありません。", + "post_link_reconnect_channel": "投稿リンクを開くには、このチャンネルを再接続してください。", "post_statistics": "投稿の統計", "draft": "下書き", "week_number": "第{{number}}週", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index ddc6416bfb..8fa2fec5b4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "중복 게시물", "preview_post": "게시물 미리보기", "open_post": "게시물 열기", + "post_link_not_ready": "게시물 링크를 아직 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", + "post_has_no_public_link": "이 게시물에는 공개 링크가 없습니다.", + "post_link_reconnect_channel": "게시물 링크를 열려면 이 채널을 다시 연결하세요.", "post_statistics": "게시물 통계", "draft": "임시 저장", "week_number": "{{number}}주차", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 3316e55c7d..ae3b850ce5 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Post duplicado", "preview_post": "Visualizar post", "open_post": "Abrir postagem", + "post_link_not_ready": "O link da postagem ainda não está disponível, por favor, tente novamente em um minuto", + "post_has_no_public_link": "Esta postagem não possui um link público", + "post_link_reconnect_channel": "Reconecte este canal para abrir o link da postagem", "post_statistics": "Estatísticas do post", "draft": "Rascunho", "week_number": "Semana {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 25794505d1..b386c7e966 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Дублировать пост", "preview_post": "Предпросмотр поста", "open_post": "Открыть пост", + "post_link_not_ready": "Ссылка на пост ещё недоступна, пожалуйста, попробуйте снова через минуту", + "post_has_no_public_link": "У этого поста нет публичной ссылки", + "post_link_reconnect_channel": "Переподключите этот канал, чтобы открыть ссылку на пост", "post_statistics": "Статистика поста", "draft": "Черновик", "week_number": "Неделя {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index c5ec4eaee2..2285006fb1 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Yinelenen Gönderi", "preview_post": "Gönderiyi Önizle", "open_post": "Gönderiyi Aç", + "post_link_not_ready": "Gönderi bağlantısı henüz hazır değil, lütfen bir dakika sonra tekrar deneyin", + "post_has_no_public_link": "Bu gönderinin herkese açık bir bağlantısı yok", + "post_link_reconnect_channel": "Gönderi bağlantısını açmak için bu kanalı yeniden bağlayın", "post_statistics": "Gönderi İstatistikleri", "draft": "Taslak", "week_number": "{{number}}. Hafta", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 3c77b781c8..90c6ce6299 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "Bài viết trùng lặp", "preview_post": "Xem trước bài viết", "open_post": "Mở bài viết", + "post_link_not_ready": "Liên kết bài viết chưa sẵn sàng, vui lòng thử lại sau một phút", + "post_has_no_public_link": "Bài viết này không có liên kết công khai", + "post_link_reconnect_channel": "Kết nối lại kênh này để mở liên kết bài viết", "post_statistics": "Thống kê bài viết", "draft": "Bản nháp", "week_number": "Tuần {{number}}", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 58b059ced5..8ec45fdaf9 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -335,6 +335,9 @@ "duplicate_post": "重复帖子", "preview_post": "预览帖子", "open_post": "打开帖子", + "post_link_not_ready": "帖子链接尚未可用,请一分钟后再试", + "post_has_no_public_link": "此帖子没有公开链接", + "post_link_reconnect_channel": "重新连接此频道以打开帖子链接", "post_statistics": "帖子统计", "draft": "草稿", "week_number": "第 {{number}} 周", From bf89e2ed8b8ed478e2d7d7c36bbcf6fc98ef6e2e Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Tue, 6 Oct 2026 13:02:06 +0200 Subject: [PATCH 72/99] feat(sponsors): re-add chatbotx to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 052c80e463..1b21e0a0d8 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ |---------|:-----------------------------------------------------------------------:|-----------------| | [Hostinger](https://www.hostinger.com/vps/docker/postiz?ref=postiz) | Hostinger | Hostinger is on a mission to make online success possible for anyone – from developers to aspiring bloggers and business owners | | [Virlo](https://dev.virlo.ai/?ref=postiz) | Virlo | Virlo is the #1 social media trend spotting and all-in-one GTM tool for teams leveraging short-form video | +| [ChatbotX](https://chatbotx.io/?ref=postiz) | ChatbotX | The ManyChat alternative that you can self-host, white-label, and resell to your clients. Bring your own OpenClaw, Hermes, or Claude agents! | | [RapidProxy](https://www.rapidproxy.io/?ref=postiz) | WP-PROXY-THUMBNAIL-41 | RapidProxy provides 90M+ residential IPs for social media, browser automation, and AI workflows, with smart rotation and stable sessions. From $0.55/GB; use RAPID10 for 10% off. From 6eed47e0af665e7ea4feea64a4b8a37851be310e Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 6 Oct 2026 19:19:03 +0700 Subject: [PATCH 73/99] feat(tiktok): connect UPLOAD drafts to the published post from the open-post icon A TikTok UPLOAD post is a draft in the user's inbox, stored with releaseId "missing" and the TikTok messages page as its link. Linking it to the post the user later publishes was only possible through the manual Connect Post picker, and a connected post kept the messages page as its URL. - checkPostStatus (both TikTok providers) keeps "missing" and appends the publish id to the stored messages URL as a fragment. "missing" is a documented contract for API, CLI and agent clients, so it must not change; a fragment is never sent to TikTok and the link still opens the inbox. - resolveReleaseId now also receives the stored releaseURL, reads the publish id from the fragment and returns the real post id and URL once TikTok reports them. While TikTok reports no public post for the draft it returns nothing, so the icon keeps opening the inbox. Posts without a fragment (stored before this change) return before any request. - Connecting by hand also stores the post URL: updateReleaseId asks the provider's new optional releaseUrl (TikTok video/query share_url, query string stripped) and writes it with the id. A failed lookup never fails the connect. Connect Post, the missing-content and release-id endpoints, the "missing" guard and checkPostAnalytics behave as before. Co-Authored-By: Claude Fable 5.1 --- .../database/prisma/posts/posts.repository.ts | 8 ++- .../database/prisma/posts/posts.service.ts | 28 +++++++++- .../social/social.integrations.interface.ts | 8 ++- .../social/tiktok.business.provider.ts | 37 ++++++++---- .../integrations/social/tiktok.provider.ts | 56 ++++++++++++++++--- 5 files changed, 114 insertions(+), 23 deletions(-) diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 70eddc502c..eedb186872 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -451,7 +451,12 @@ export class PostsRepository { }); } - updateReleaseId(id: string, orgId: string, releaseId: string) { + updateReleaseId( + id: string, + orgId: string, + releaseId: string, + releaseURL?: string + ) { return this._post.model.post.update({ where: { id, @@ -460,6 +465,7 @@ export class PostsRepository { }, data: { releaseId: String(releaseId), + ...(releaseURL ? { releaseURL } : {}), }, }); } diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 319afb1055..5dc2fe559f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -153,7 +153,30 @@ export class PostsService { } async updateReleaseId(orgId: string, postId: string, releaseId: string) { - return this._postRepository.updateReleaseId(postId, orgId, releaseId); + const post = await this._postRepository.getPostById(postId, orgId); + + // the URL is a bonus: connecting must not fail when it cannot be fetched + let releaseURL: string | undefined; + if (post?.releaseId === 'missing') { + try { + releaseURL = await this._integrationManager + .getSocialIntegration(post.integration.providerIdentifier) + .releaseUrl?.( + post.integration.token, + String(releaseId), + post.integration + ); + } catch (e) { + console.log(e); + } + } + + return this._postRepository.updateReleaseId( + postId, + orgId, + releaseId, + releaseURL + ); } async resolveRelease( @@ -174,7 +197,8 @@ export class PostsService { post.integration.token, post.releaseId, post.integration, - JSON.parse(post.settings || '{}') + JSON.parse(post.settings || '{}'), + post.releaseURL ); if ( !resolved || diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index 3330110161..298d8d5e24 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -38,13 +38,19 @@ export interface IAuthenticator { accessToken: string, releaseId: string, integration: Integration, - settings: any + settings: any, + releaseURL: string ): Promise< | { postId: string; releaseURL: string } | { pending: true } | { unavailable: true } | undefined >; // Final id + URL to persist when the stored releaseId is still a publish id, pending when the platform has no final id yet, unavailable when the post will never get one, undefined when nothing to resolve + releaseUrl?( + accessToken: string, + releaseId: string, + integration: Integration + ): Promise; // URL of a post the user connected by hand changeNickname?( id: string, accessToken: string, diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts index a57b11dc60..31c672fbeb 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts @@ -474,9 +474,11 @@ export class TiktokBusinessProvider const { status, reason } = post?.data || {}; if (status === 'SEND_TO_USER_INBOX') { + // the id stays 'missing' until the user publishes the draft - the + // share id rides in the URL fragment so resolveReleaseId can ask later return { status: 'completed', - releaseURL: 'https://www.tiktok.com/messages?lang=en', + releaseURL: `https://www.tiktok.com/messages?lang=en#${pendingData.publishId}`, postId: 'missing', }; } @@ -931,10 +933,11 @@ export class TiktokBusinessProvider return videoListData?.data?.videos; } - // photo posts (p_pub_...) live under /photo/, TikTok rejects /video/ for them + // photo posts (p_pub_... / p_inbox_...) live under /photo/, TikTok rejects + // /video/ for them private postUrl(integration: Integration, publishId: string, postId: string) { return `https://www.tiktok.com/@${integration.profile}/${ - publishId.indexOf('p_pub_') === 0 ? 'photo' : 'video' + publishId.indexOf('p_') === 0 ? 'photo' : 'video' }/${postId}`; } @@ -1120,16 +1123,26 @@ export class TiktokBusinessProvider accessToken: string, releaseId: string, integration: Integration, - settings: any + settings: any, + releaseURL: string ) { - if (releaseId.indexOf('_pub_') === -1) { + // a draft sent to the inbox (UPLOAD) is stored as 'missing' with its + // share id in the URL fragment + const draft = releaseId === 'missing'; + const publishId = draft ? releaseURL?.split('#')[1] || '' : releaseId; + if ( + publishId.indexOf('_pub_') === -1 && + publishId.indexOf('_inbox_') === -1 + ) { return undefined; } - // privacy_level only applies to photo posts here, and TikTok only gives a - // post id to posts published for public viewership + // privacy_level only applies to photo posts here (and not to a draft, the + // user picks it in the app), and TikTok only gives a post id to posts + // published for public viewership if ( - releaseId.indexOf('p_pub_') === 0 && + !draft && + publishId.indexOf('p_pub_') === 0 && ['SELF_ONLY', 'MUTUAL_FOLLOW_FRIENDS'].includes(settings?.privacy_level) ) { return { unavailable: true as const }; @@ -1139,7 +1152,7 @@ export class TiktokBusinessProvider await this.fetch( `${this.baseUrl}/business/publish/status/?business_id=${encodeURIComponent( integration.internalId - )}&publish_id=${encodeURIComponent(releaseId)}`, + )}&publish_id=${encodeURIComponent(publishId)}`, { method: 'GET', headers: { @@ -1153,12 +1166,14 @@ export class TiktokBusinessProvider const publicPostId = this.publicPostId(body); if (!publicPostId) { - return { pending: true as const }; + // a draft with no public post (not published yet, or published as + // non-public) keeps opening the inbox instead of asking to retry + return draft ? undefined : { pending: true as const }; } return { postId: publicPostId, - releaseURL: this.postUrl(integration, releaseId, publicPostId), + releaseURL: this.postUrl(integration, publishId, publicPostId), }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index 759ed88e3c..bae187233c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -483,9 +483,11 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { const { status } = post?.data || {}; if (status === 'SEND_TO_USER_INBOX') { + // the id stays 'missing' until the user publishes the draft - the + // publish id rides in the URL fragment so resolveReleaseId can ask later return { status: 'completed', - releaseURL: 'https://www.tiktok.com/messages?lang=en', + releaseURL: `https://www.tiktok.com/messages?lang=en#${pendingData.publishId}`, postId: 'missing', }; } @@ -515,10 +517,11 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { return { status: 'pending', pendingData }; } - // photo posts (p_pub_...) live under /photo/, TikTok rejects /video/ for them + // photo posts (p_pub_... / p_inbox_...) live under /photo/, TikTok rejects + // /video/ for them private postUrl(integration: Integration, publishId: string, postId: string) { return `https://www.tiktok.com/@${integration.profile}/${ - publishId.indexOf('p_pub_') === 0 ? 'photo' : 'video' + publishId.indexOf('p_') === 0 ? 'photo' : 'video' }/${postId}`; } @@ -1155,14 +1158,24 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { accessToken: string, releaseId: string, integration: Integration, - settings: any + settings: any, + releaseURL: string ) { - if (releaseId.indexOf('_pub_') === -1) { + // a draft sent to the inbox (UPLOAD) is stored as 'missing' with its + // publish id in the URL fragment + const draft = releaseId === 'missing'; + const publishId = draft ? releaseURL?.split('#')[1] || '' : releaseId; + if ( + publishId.indexOf('_pub_') === -1 && + publishId.indexOf('_inbox_') === -1 + ) { return undefined; } // TikTok only gives a post id to posts published for public viewership + // (a draft ignores the privacy setting, the user picks it in the app) if ( + !draft && ['SELF_ONLY', 'MUTUAL_FOLLOW_FRIENDS'].includes(settings?.privacy_level) ) { return { unavailable: true as const }; @@ -1179,7 +1192,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ - publish_id: releaseId, + publish_id: publishId, }), } ) @@ -1187,15 +1200,42 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { ); if (!publicPostId) { - return { pending: true as const }; + // a draft with no public post (not published yet, or published as + // non-public) keeps opening the inbox instead of asking to retry + return draft ? undefined : { pending: true as const }; } return { postId: publicPostId, - releaseURL: this.postUrl(integration, releaseId, publicPostId), + releaseURL: this.postUrl(integration, publishId, publicPostId), }; } + async releaseUrl(accessToken: string, releaseId: string) { + const data = await ( + await this.fetch( + 'https://open.tiktokapis.com/v2/video/query/?fields=id,share_url', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + filters: { + video_ids: [releaseId], + }, + }), + } + ) + ).json(); + + // share_url carries tracking params (the app's client key among them) + return data?.data?.videos?.[0]?.share_url?.split('?')[0] as + | string + | undefined; + } + async postAnalytics( integrationId: string, accessToken: string, From 541c7a454c831b1e177edcc50c8192093b5f5521 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 6 Oct 2026 19:32:14 +0700 Subject: [PATCH 74/99] fix(tiktok-business): store the post URL when a post is connected by hand TiktokBusinessProvider had no releaseUrl, so a Business post connected through Connect Post kept the TikTok messages page as its link. It now returns https://www.tiktok.com/@/video/, the same form the provider builds when a post publishes. No API call is made. TikTok redirects that URL to /photo/ for photo posts. Co-Authored-By: Claude Fable 5.1 --- .../integrations/social/tiktok.business.provider.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts index 31c672fbeb..33a48c931f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts @@ -1177,6 +1177,16 @@ export class TiktokBusinessProvider }; } + // a post connected by hand: /video/ also reaches photo posts, TikTok + // redirects it to /photo/ + async releaseUrl( + accessToken: string, + releaseId: string, + integration: Integration + ) { + return `https://www.tiktok.com/@${integration.profile}/video/${releaseId}`; + } + async postAnalytics( integrationId: string, accessToken: string, From 66d210186deb51530c9d1885aa1c9fea3367df0a Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 7 Oct 2026 08:41:49 +0700 Subject: [PATCH 75/99] fix(instagram): map the Trial Reel publish limit error to a readable message Meta rejects Trial Reels with code 9 / subcode 2207078 once the account exceeds the Content Publishing API trial reel limit. The error had no mapping, so the calendar showed "Unknown Error". Map it to a curated non-retryable message. Co-Authored-By: Claude Sonnet 5.5 --- .../src/integrations/social/instagram.provider.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index dbfea721e6..098eb25a6c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -120,6 +120,13 @@ export class InstagramProvider value: "This account doesn't support Trial Reels", }; } + if (body.indexOf('2207078') > -1) { + return { + type: 'bad-body' as const, + value: + 'Instagram Trial Reel publish limit reached for this account, please try again later or publish the post as a regular Reel', + }; + } if ( body.indexOf('REVOKED_ACCESS_TOKEN') > -1 || From 0992c460588ded82eb1ea3dde4ecf9f98dae6218 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 7 Oct 2026 09:32:16 +0700 Subject: [PATCH 76/99] feat: point MCP upload tools to the CLI and public API for private files uploadFromUrlTool only accepts a public http(s) URL, and its description, its fetch error and uploadWidgetTool's fallback line never mentioned another route, so a private local file hit a dead end. The descriptions and the fetch error now point to the Postiz CLI (postiz upload) and POST /public/v1/upload, and uploadWidgetStatusTool says it lists only the files of one widget session. Strings only, no behavior or schema change. Co-Authored-By: Claude Sonnet 5.5 --- .../src/chat/tools/upload.from.url.tool.ts | 9 ++++++--- .../src/chat/tools/upload.widget.status.tool.ts | 1 + .../src/chat/tools/upload.widget.tool.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts b/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts index 9ea693e337..c6778b7fa1 100644 --- a/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts @@ -13,7 +13,8 @@ export class UploadFromUrlTool implements AgentToolInterface { run() { return createTool({ id: 'uploadFromUrlTool', - description: `Upload a remote image or video into the media library from a public URL. + description: `Upload a remote image or video into the media library from a public http(s) URL. +Local or private files cannot be fetched by this tool: upload them with the Postiz CLI (postiz upload, https://docs.postiz.com/cli/introduction) or the public API (POST /public/v1/upload with an API key, https://docs.postiz.com/public-api/introduction). Use this before scheduling a post when the user provides an external media URL (not already hosted on our domain), so the attachment passes the upload-domain validation. Returns the hosted media { id, path } to use as an attachment (or as the src of an in the content, when the channel rules say pictures go inside the content), or { error } on failure.`, mcp: { @@ -29,7 +30,9 @@ so the attachment passes the upload-domain validation. Returns the hosted media url: z .string() .url() - .describe('The public URL of the image or video to upload'), + .describe( + 'The public http(s) URL of the image or video to upload. Local files and private hosts are not supported, use the Postiz CLI (postiz upload) or POST /public/v1/upload instead' + ), }), // Mastra validates a tool's return against this schema, so it must also // allow the graceful { error } shape. Fields are optional (rather than @@ -68,7 +71,7 @@ so the attachment passes the upload-domain validation. Returns the hosted media const causeText = causes.length ? ` (${causes.join(': ')})` : ''; return { - error: `Failed to upload media from URL: ${message}${causeText}`, + error: `Failed to upload media from URL: ${message}${causeText}. Only public http(s) URLs work: upload local or private files with the Postiz CLI (postiz upload, https://docs.postiz.com/cli/introduction) or the public API (POST /public/v1/upload with an API key, https://docs.postiz.com/public-api/introduction)`, }; } }, diff --git a/libraries/nestjs-libraries/src/chat/tools/upload.widget.status.tool.ts b/libraries/nestjs-libraries/src/chat/tools/upload.widget.status.tool.ts index ce32d12597..9b7584efd8 100644 --- a/libraries/nestjs-libraries/src/chat/tools/upload.widget.status.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/upload.widget.status.tool.ts @@ -15,6 +15,7 @@ export class UploadWidgetStatusTool implements AgentToolInterface { return createTool({ id: 'uploadWidgetStatusTool', description: `List the media the user uploaded with the upload widget, using the sessionId returned by uploadWidgetTool. +It lists only the files uploaded in that widget session, not the whole media library. An empty list means the user did not upload anything yet. A media with the status "processing" is still being prepared: wait about 10 seconds and call again. A media with the status "ready" can be used as a post attachment with its { id, path }.`, mcp: { diff --git a/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts b/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts index 826b927ad2..a74a3901f7 100644 --- a/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts @@ -17,7 +17,7 @@ export class UploadWidgetTool implements AgentToolInterface { id: 'uploadWidgetTool', description: `Show the user an upload widget to add images or videos from their own device to the media library. Use this when the user wants to attach a local file to a post. When the media is already available on a public URL, use uploadFromUrlTool instead. -The widget is only displayed by apps that support MCP Apps (interactive UI); in any other app no widget appears and uploadFromUrlTool with a public URL is the way to add media. +The widget is only displayed by apps that support MCP Apps (interactive UI); in any other app no widget appears, and local files go through the Postiz CLI (postiz upload, https://docs.postiz.com/cli/introduction) or the public API (POST /public/v1/upload with an API key, https://docs.postiz.com/public-api/introduction). uploadFromUrlTool is only for public URLs. Returns a sessionId: the files the user uploads are reported in the conversation, and can also be read with uploadWidgetStatusTool.`, mcp: { annotations: { From e7b8126b878b20234b9303c97d73738b9a87acf8 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 7 Oct 2026 09:46:12 +0700 Subject: [PATCH 77/99] fix: drop type and fileSize from the media list response Both columns are never written on upload (every row keeps the schema defaults 'image' and 0, so videos report 'image' and fileSize is 0 for almost every row), which would mislead API and MCP callers. getMedia now only adds createdAt on top of the existing select. Co-Authored-By: Claude Sonnet 5.5 --- libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts | 4 ---- .../src/database/prisma/media/media.repository.ts | 2 -- 2 files changed, 6 deletions(-) diff --git a/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts index 34501cc5b9..71de61105e 100644 --- a/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts @@ -39,8 +39,6 @@ Use the returned path as an attachment URL in integrationSchedulePostTool instea name: z.string(), originalName: z.string().nullable(), path: z.string(), - type: z.string(), - fileSize: z.number(), createdAt: z.string(), thumbnail: z.string().nullable(), }) @@ -65,8 +63,6 @@ Use the returned path as an attachment URL in integrationSchedulePostTool instea name: p.name, originalName: p.originalName, path: p.path, - type: p.type, - fileSize: p.fileSize, createdAt: p.createdAt.toISOString(), thumbnail: p.thumbnail, })), diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts index 27c6292ce8..4859a06ca4 100644 --- a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts @@ -160,8 +160,6 @@ export class MediaRepository { thumbnail: true, alt: true, thumbnailTimestamp: true, - type: true, - fileSize: true, createdAt: true, }, skip: pageNum * 18, From 4be38e1a975111965714dc2b3275c6f0cfd28858 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 7 Oct 2026 10:24:03 +0700 Subject: [PATCH 78/99] fix: return only media-owned fields from the media list API and tool alt, thumbnail and thumbnailTimestamp are the values last saved from some post's media settings dialog (the post keeps its own copy), so they do not describe the file. The public route and mediaListTool now return only id, name, originalName, path and createdAt. Co-Authored-By: Claude Sonnet 5.5 --- .../v1/public.integrations.controller.ts | 19 +++++++++++++++++-- .../src/chat/tools/media.list.tool.ts | 2 -- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 916dc2a7d2..b91591c4a5 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -232,12 +232,27 @@ export class PublicIntegrationsController { } @Get('/media') - getMedia( + async getMedia( @GetOrgFromRequest() org: Organization, @Query() query: GetMediaDto ) { Sentry.metrics.count('public_api-request', 1); - return this._mediaService.getMedia(org.id, query.page, query.search); + const { pages, results } = await this._mediaService.getMedia( + org.id, + query.page, + query.search + ); + + return { + pages, + results: results.map((p) => ({ + id: p.id, + name: p.name, + originalName: p.originalName, + path: p.path, + createdAt: p.createdAt, + })), + }; } // `user` is only known for OAuth app tokens; an API key belongs to the diff --git a/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts index 71de61105e..fb4cb183f9 100644 --- a/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts @@ -40,7 +40,6 @@ Use the returned path as an attachment URL in integrationSchedulePostTool instea originalName: z.string().nullable(), path: z.string(), createdAt: z.string(), - thumbnail: z.string().nullable(), }) ), }), @@ -64,7 +63,6 @@ Use the returned path as an attachment URL in integrationSchedulePostTool instea originalName: p.originalName, path: p.path, createdAt: p.createdAt.toISOString(), - thumbnail: p.thumbnail, })), }; }, From dda966e44f864985a605e5eb7666f83e612d64c9 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 7 Oct 2026 10:33:42 +0700 Subject: [PATCH 79/99] fix: validate page input on mediaListTool A negative page passed the zod schema, survived the `|| 1` fallback and reached Prisma as a negative skip, which throws. The tool now requires an integer >= 1, like GetMediaDto does for the REST route. Co-Authored-By: Claude Sonnet 5.5 --- .../nestjs-libraries/src/chat/tools/media.list.tool.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts index fb4cb183f9..4a60361384 100644 --- a/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts @@ -20,7 +20,12 @@ Use the returned path as an attachment URL in integrationSchedulePostTool instea .string() .optional() .describe('Filter by original filename (case-insensitive contains)'), - page: z.number().optional().describe('Page number, starting at 1'), + page: z + .number() + .int() + .min(1) + .optional() + .describe('Page number, starting at 1'), }), mcp: { annotations: { From 80d527b489e046d38c3183d5003afbb051c80574 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 7 Sep 2026 12:28:18 +0700 Subject: [PATCH 80/99] fix(frontend): honor the AM/PM date metric in the post date picker time input The TimeInput inside the editor's date picker popup always rendered 24h, ignoring the Date Metrics setting that the rest of the UI respects. --- apps/frontend/src/components/launches/helpers/date.picker.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/src/components/launches/helpers/date.picker.tsx b/apps/frontend/src/components/launches/helpers/date.picker.tsx index efe1573d97..b1439118c0 100644 --- a/apps/frontend/src/components/launches/helpers/date.picker.tsx +++ b/apps/frontend/src/components/launches/helpers/date.picker.tsx @@ -253,6 +253,7 @@ export const DatePickerPanel: FC<{ /> Date: Wed, 7 Oct 2026 21:30:14 +0700 Subject: [PATCH 81/99] feat: group schedules --- .../src/api/routes/public.controller.ts | 4 +- .../v1/public.integrations.controller.ts | 18 +- .../launches/helpers/use.existing.data.tsx | 9 + .../components/new-launch/add.edit.modal.tsx | 61 ++++- .../components/new-launch/manage.modal.tsx | 244 +++++++++++++++--- .../new-launch/picks.socials.component.tsx | 5 +- .../providers/high.order.provider.tsx | 2 +- .../components/new-launch/select.current.tsx | 54 ++-- .../src/components/new-launch/store.ts | 11 + i18n.lock | 3 + .../database/prisma/posts/posts.repository.ts | 51 +++- .../database/prisma/posts/posts.service.ts | 79 ++++-- .../src/database/prisma/schema.prisma | 2 + .../src/dtos/posts/create.post.dto.ts | 5 + .../translation/locales/ar/translation.json | 5 +- .../translation/locales/bn/translation.json | 5 +- .../translation/locales/de/translation.json | 5 +- .../translation/locales/en/translation.json | 5 +- .../translation/locales/es/translation.json | 5 +- .../translation/locales/fr/translation.json | 5 +- .../translation/locales/he/translation.json | 5 +- .../translation/locales/it/translation.json | 5 +- .../translation/locales/ja/translation.json | 5 +- .../translation/locales/ko/translation.json | 5 +- .../translation/locales/pt/translation.json | 5 +- .../translation/locales/ru/translation.json | 5 +- .../translation/locales/tr/translation.json | 5 +- .../translation/locales/vi/translation.json | 5 +- .../translation/locales/zh/translation.json | 5 +- 29 files changed, 508 insertions(+), 115 deletions(-) diff --git a/apps/backend/src/api/routes/public.controller.ts b/apps/backend/src/api/routes/public.controller.ts index 40cf3542cc..235f82a776 100644 --- a/apps/backend/src/api/routes/public.controller.ts +++ b/apps/backend/src/api/routes/public.controller.ts @@ -54,8 +54,10 @@ export class PublicController { @Get(`/posts/:id`) async getPreview(@Param('id') id: string) { + // the batch only links the posts in the dashboard, it stays out of the + // public preview return (await this._postsService.getPostsRecursively(id, true)).map( - ({ childrenPost, ...p }) => ({ + ({ childrenPost, batchId, ...p }) => ({ ...p, ...(p.integration ? { diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index b91591c4a5..4f9f9d1ab1 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -14,6 +14,7 @@ import { UseInterceptors, } from '@nestjs/common'; import { streamUploadOptions } from '@gitroom/nestjs-libraries/upload/multer.stream.engine'; +import { omit } from 'lodash'; import { ApiTags } from '@nestjs/swagger'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { GetIncludeDeletedFromRequest } from '@gitroom/nestjs-libraries/user/include.deleted.from.request'; @@ -134,7 +135,14 @@ export class PublicIntegrationsController { ) { Sentry.metrics.count('public_api-request', 1); const body = await this._postsService.mapTypeToPost( - rawBody, + { + ...rawBody, + // a date per channel is a dashboard feature, the public API keeps one + // date for the whole request + posts: Array.isArray(rawBody?.posts) + ? rawBody.posts.map((post: any) => omit(post, 'date')) + : rawBody?.posts, + }, org.id, rawBody?.type === 'draft' || true ); @@ -598,7 +606,13 @@ export class PublicIntegrationsController { @Body('releaseId') releaseId: string ) { Sentry.metrics.count('public_api-request', 1); - return this._postsService.updateReleaseId(org.id, id, releaseId); + // the batch only links the posts in the dashboard + const { batchId, ...post } = await this._postsService.updateReleaseId( + org.id, + id, + releaseId + ); + return post; } @Get('/analytics/:integration') diff --git a/apps/frontend/src/components/launches/helpers/use.existing.data.tsx b/apps/frontend/src/components/launches/helpers/use.existing.data.tsx index 6b0e964483..5170cbc979 100644 --- a/apps/frontend/src/components/launches/helpers/use.existing.data.tsx +++ b/apps/frontend/src/components/launches/helpers/use.existing.data.tsx @@ -5,6 +5,15 @@ const ExistingDataContext = createContext({ group: undefined as undefined | string, posts: [] as Post[], settings: {} as any, + // the other channels the post was created with + siblings: undefined as + | undefined + | { + integration: string; + group: string; + posts: Post[]; + settings: any; + }[], }); export const ExistingDataContextProvider: FC<{ children: ReactNode; diff --git a/apps/frontend/src/components/new-launch/add.edit.modal.tsx b/apps/frontend/src/components/new-launch/add.edit.modal.tsx index 0e18d42636..aa29e6bbc1 100644 --- a/apps/frontend/src/components/new-launch/add.edit.modal.tsx +++ b/apps/frontend/src/components/new-launch/add.edit.modal.tsx @@ -12,6 +12,7 @@ import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { Post } from '@prisma/client'; export interface AddEditModalProps { dummy?: boolean; @@ -113,6 +114,16 @@ export const AddEditModalInner: FC = (props) => { return; } addOrRemoveSelectedIntegration(integration, existingData.settings); + + // the other channels the post was created with + for (const sibling of existingData.siblings || []) { + const siblingIntegration = integrations.find( + (i) => i.id === sibling.integration + ); + if (siblingIntegration) { + addOrRemoveSelectedIntegration(siblingIntegration, sibling.settings); + } + } } if (props?.selectedChannels?.length) { @@ -132,6 +143,21 @@ export const AddEditModalInner: FC = (props) => { return ; }; +// an existing post, as the values of the editor +const postValues = (posts: Post[]) => + posts.map((post) => ({ + delay: post.delay, + content: /]/i.test(post.content) + ? post.content + : post.content + .split('\n') + .map((line: string) => `

    ${line}

    `) + .join(''), + id: post.id, + // @ts-ignore + media: post.image as any[], + })); + export const AddEditModalInnerInner: FC = (props) => { const existingData = useExistingData(); const { @@ -144,6 +170,8 @@ export const AddEditModalInnerInner: FC = (props) => { setTags, setEditor, setRepeater, + selectedIntegrations, + setChannelDate, } = useLaunchStore( useShallow((state) => ({ reset: state.reset, @@ -155,6 +183,8 @@ export const AddEditModalInnerInner: FC = (props) => { setTags: state.setTags, setEditor: state.setEditor, setRepeater: state.setRepeater, + selectedIntegrations: state.selectedIntegrations, + setChannelDate: state.setChannelDate, })) ); @@ -170,23 +200,28 @@ export const AddEditModalInnerInner: FC = (props) => { value: p.tag.name, })) || [] ); + addInternalValue( 0, existingData.integration, - existingData.posts.map((post) => ({ - delay: post.delay, - content: - /]/i.test(post.content) - ? post.content - : post.content - .split('\n') - .map((line: string) => `

    ${line}

    `) - .join(''), - id: post.id, - // @ts-ignore - media: post.image as any[], - })) + postValues(existingData.posts) ); + + // the other channels the post was created with, each with its own + // content and date + for (const sibling of existingData.siblings || []) { + if ( + selectedIntegrations.some( + (p) => p.integration.id === sibling.integration + ) + ) { + addInternalValue(0, sibling.integration, postValues(sibling.posts)); + setChannelDate( + sibling.integration, + dayjs.utc(sibling.posts[0].publishDate).local() + ); + } + } setCurrent(existingData.integration); } else { setEditor('normal'); diff --git a/apps/frontend/src/components/new-launch/manage.modal.tsx b/apps/frontend/src/components/new-launch/manage.modal.tsx index 2a2d10986a..6d466e311a 100644 --- a/apps/frontend/src/components/new-launch/manage.modal.tsx +++ b/apps/frontend/src/components/new-launch/manage.modal.tsx @@ -103,12 +103,16 @@ export const ManageModal: FC = (props) => { current, activateExitButton, setHide, + channelDates, + setChannelDate, } = useLaunchStore( useShallow((state) => ({ hide: state.hide, setHide: state.setHide, date: state.date, setDate: state.setDate, + channelDates: state.channelDates, + setChannelDate: state.setChannelDate, current: state.current, repeater: state.repeater, setRepeater: state.setRepeater, @@ -121,9 +125,26 @@ export const ManageModal: FC = (props) => { activateExitButton: state.activateExitButton, })) ); + // the other channels of an existing post show and move only their own date + const channelDate = channelDates[current]; + const pickerDate = channelDate || date; + const setPickerDate = useCallback( + (newDate: dayjs.Dayjs) => { + if (channelDate) { + return setChannelDate(current, newDate); + } + + setDate(newDate); + }, + [channelDate, current] + ); + // the date sheet applies its changes only on save const [dateDraft, setDateDraft] = useState(date); + // the channels the post already has, each with its own post + const existingPosts = [existingData, ...(existingData.siblings || [])]; + useEffect(() => { if (hide) { setHide(false); @@ -203,6 +224,76 @@ export const ManageModal: FC = (props) => { const deletePost = useCallback(async () => { setLoading(true); + if (existingData.siblings?.length) { + // the channel in view, or the one that was opened + const channel = + existingPosts.find((p) => p.integration === current) || existingData; + const groups = await new Promise((resolve) => { + modal.openModal({ + id: 'delete-post-channels', + title: t('delete_post', 'Delete Post'), + onClose: () => resolve([]), + children: ( +
    +
    + {t( + 'delete_post_from_all_channels_question', + 'This post was created for more than one channel. Do you want to delete it from all of them?' + )} +
    +
    +
    + +
    +
    + +
    +
    +
    + ), + }); + }); + + if (!groups.length) { + setLoading(false); + return; + } + + await Promise.all( + groups.map((group) => + fetch(`/posts/${group}`, { + method: 'DELETE', + }) + ) + ); + mutate(); + modal.closeAll(); + return; + } + if ( !(await deleteDialog( t( @@ -221,19 +312,32 @@ export const ManageModal: FC = (props) => { mutate(); modal.closeAll(); return; - }, [existingData, mutate, modal]); + }, [existingData, mutate, modal, current, integrations]); const schedule = useCallback( (type: 'draft' | 'now' | 'schedule' | 'update') => async () => { let republish = false; - if ( - (type === 'now' || type === 'schedule') && - (existingData?.posts?.[0]?.state === 'PUBLISHED' || - (existingData?.posts?.[0]?.state === 'QUEUE' && - dayjs().isAfter(date.utc()))) - ) { - const channels = selectedIntegrations - .map((p) => p.integration.name) + // channels saved without changing their state, the rest are saved as `type` + let updateOnly: string[] = []; + // the channels that already went out, or are going out right now + const published = existingPosts.filter( + (p) => + p.posts?.[0]?.state === 'PUBLISHED' || + (p.posts?.[0]?.state === 'QUEUE' && + dayjs().isAfter((channelDates[p.integration] || date).utc())) + ); + + if ((type === 'now' || type === 'schedule') && published.length) { + const channels = published + .map( + (p) => + `${integrations.find((i) => i.id === p.integration)?.name} ${t( + 'republish_at', + 'at' + )} ${(channelDates[p.integration] || date).format( + 'DD/MM/YYYY HH:mm' + )}` + ) .join(', '); const isRecurring = !!repeater || !!existingData?.posts?.[0]?.intervalInDays; @@ -248,8 +352,7 @@ export const ManageModal: FC = (props) => { 'post_already_published_republish_warning', 'This post was already published. Republishing will publish it again to' )}{' '} - {channels} {t('republish_at', 'at')}{' '} - {date.format('DD/MM/YYYY HH:mm')}. + {channels}. {isRecurring && (
    {t( @@ -288,7 +391,7 @@ export const ManageModal: FC = (props) => { }); if (whatToDo === 'update') { - type = 'update'; + updateOnly = published.map((p) => p.integration); } if (whatToDo === 'republish') { @@ -296,6 +399,29 @@ export const ManageModal: FC = (props) => { } } + // another channel whose date already passed would go out right away, it + // only gets its details saved + if (type === 'schedule') { + updateOnly = [ + ...updateOnly, + ...existingPosts + .filter( + (p) => + p.integration !== existingData.integration && + !published.includes(p) && + dayjs().isAfter((channelDates[p.integration] || date).utc()) + ) + .map((p) => p.integration), + ]; + } + + // a draft of a post with other channels leaves the published ones as they are + if (type === 'draft' && existingData.siblings?.length) { + updateOnly = published + .filter((p) => p.posts[0].state === 'PUBLISHED') + .map((p) => p.integration); + } + setLoading(true); // Pull the local values to build the payload, but rely on the server @@ -312,7 +438,14 @@ export const ManageModal: FC = (props) => { integration: { id: post.id, }, - group, + // every channel of an existing post updates its own post + group: + existingPosts.find((p) => p.integration === post.id)?.group || group, + ...(channelDates[post.id] + ? { + date: channelDates[post.id].utc().format('YYYY-MM-DDTHH:mm:ss'), + } + : {}), settings: { ...(post.settings || {}) }, value: post.values.map((value: any) => ({ ...(value.id ? { id: value.id } : {}), @@ -473,23 +606,50 @@ export const ManageModal: FC = (props) => { if (addEditSets) { addEditSets(data); } else { - const response = await fetch('/posts', { - method: 'POST', - body: JSON.stringify(data), - }); + // the channels that only update their details are saved apart, first, + // since a draft save skips the checks their update still runs + let saved = false; + for (const request of [ + { + type: 'update', + posts: posts.filter((p: any) => + updateOnly.includes(p.integration.id) + ), + }, + { + type, + posts: posts.filter( + (p: any) => !updateOnly.includes(p.integration.id) + ), + }, + ]) { + if (!request.posts.length) { + continue; + } - if (!response.ok) { - if (response.status !== 402) { - const { message } = await response.json().catch(() => ({})); - toaster.show( - typeof message === 'string' - ? message - : t('post_save_failed', 'Could not save the post'), - 'warning' - ); + const response = await fetch('/posts', { + method: 'POST', + body: JSON.stringify({ ...data, ...request }), + }); + + if (!response.ok) { + if (response.status !== 402) { + const { message } = await response.json().catch(() => ({})); + toaster.show( + typeof message === 'string' + ? message + : t('post_save_failed', 'Could not save the post'), + 'warning' + ); + } + // the channels saved before the failure show in the calendar + if (saved) { + mutate(); + } + setLoading(false); + return; } - setLoading(false); - return; + saved = true; } } @@ -512,7 +672,17 @@ export const ManageModal: FC = (props) => { } } }, - [ref, repeater, tags, date, addEditSets, dummy, shortlinkPreferenceData] + [ + ref, + repeater, + tags, + date, + channelDates, + integrations, + addEditSets, + dummy, + shortlinkPreferenceData, + ] ); const scheduleLabel = dummy @@ -601,10 +771,10 @@ export const ManageModal: FC = (props) => { loading={loading} > { - setDateDraft(date); + setDateDraft(pickerDate); setMobileSheet('date'); }} /> @@ -645,7 +815,8 @@ export const ManageModal: FC = (props) => {
    - {!dummy && ( + {/* an existing post keeps its channels */} + {!dummy && !existingData.integration && ( = (props) => {
    - {!existingData.integration && } + {(!existingData.integration || + !!existingData.siblings?.length) && }
    {!existingData.integration && ( <> @@ -826,7 +998,7 @@ export const ManageModal: FC = (props) => {
    {t('delete_post', 'Delete Post')}
    )} - + {!addEditSets && ( +
    + {/* a published channel in view stays published on a draft */} + {!saveAs[channel.integration] && ( +
    + +
    + )} +
    +
    + ), + }); + }); + + if (!whichChannels) { + return; + } + + if (whichChannels === 'only') { + for (const p of others) { + saveAs[p.integration] = asItIs(p); + } + } + } + + // the published channels that would go out again + const republishing = published.filter((p) => !saveAs[p.integration]); + + if ((type === 'now' || type === 'schedule') && republishing.length) { + const channels = republishing .map( (p) => `${integrations.find((i) => i.id === p.integration)?.name} ${t( @@ -342,9 +471,13 @@ export const ManageModal: FC = (props) => { const isRecurring = !!repeater || !!existingData?.posts?.[0]?.intervalInDays; + // it closes on a choice, so a save that then fails its checks shows + // the channel to fix const whatToDo = await new Promise((resolve) => { modal.openModal({ + id: 'republish-post', title: t('what_do_you_want_to_do', 'What do you want to do?'), + onClose: () => resolve(undefined), children: (
    @@ -367,7 +500,10 @@ export const ManageModal: FC = (props) => { @@ -390,8 +529,14 @@ export const ManageModal: FC = (props) => { }); }); + if (!whatToDo) { + return; + } + if (whatToDo === 'update') { - updateOnly = published.map((p) => p.integration); + for (const p of republishing) { + saveAs[p.integration] = 'update'; + } } if (whatToDo === 'republish') { @@ -399,29 +544,6 @@ export const ManageModal: FC = (props) => { } } - // another channel whose date already passed would go out right away, it - // only gets its details saved - if (type === 'schedule') { - updateOnly = [ - ...updateOnly, - ...existingPosts - .filter( - (p) => - p.integration !== existingData.integration && - !published.includes(p) && - dayjs().isAfter((channelDates[p.integration] || date).utc()) - ) - .map((p) => p.integration), - ]; - } - - // a draft of a post with other channels leaves the published ones as they are - if (type === 'draft' && existingData.siblings?.length) { - updateOnly = published - .filter((p) => p.posts[0].state === 'PUBLISHED') - .map((p) => p.integration); - } - setLoading(true); // Pull the local values to build the payload, but rely on the server @@ -493,47 +615,50 @@ export const ManageModal: FC = (props) => { return; } - if (type !== 'draft') { - for (const item of checkAllValid) { - if (item.valid === false) { - toaster.show( - `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${ - item.settingsError || - t('please_fix_your_settings', 'Please fix your settings') - }`, - 'warning' - ); - focus(item.id, 'fix'); - setLoading(false); - setShowSettings(true); - return; - } + // a channel saved as a draft gets these checks once it's scheduled + const notDrafts = checkAllValid.filter( + (p: any) => saveTypeOf(p.id) !== 'draft' + ); - if (item.errors !== true) { - toaster.show( - `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${ - item.errors - }`, - 'warning' - ); - focus(item.id, 'preview'); - setLoading(false); - setShowSettings(false); - return; - } + for (const item of notDrafts) { + if (item.valid === false) { + toaster.show( + `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${ + item.settingsError || + t('please_fix_your_settings', 'Please fix your settings') + }`, + 'warning' + ); + focus(item.id, 'fix'); + setLoading(false); + setShowSettings(true); + return; + } - if (item.tooLong) { - toaster.show( - `${item.name} (${item.identifier}) ${t( - 'post_is_too_long', - 'post is too long, please fix it' - )}`, - 'warning' - ); - focus(item.id, 'preview'); - setLoading(false); - return; - } + if (item.errors !== true) { + toaster.show( + `${capitalize(item.identifier.split('-')[0])} (${item.name}): ${ + item.errors + }`, + 'warning' + ); + focus(item.id, 'preview'); + setLoading(false); + setShowSettings(false); + return; + } + + if (item.tooLong) { + toaster.show( + `${item.name} (${item.identifier}) ${t( + 'post_is_too_long', + 'post is too long, please fix it' + )}`, + 'warning' + ); + focus(item.id, 'preview'); + setLoading(false); + return; } } } @@ -606,23 +731,16 @@ export const ManageModal: FC = (props) => { if (addEditSets) { addEditSets(data); } else { - // the channels that only update their details are saved apart, first, - // since a draft save skips the checks their update still runs + // every type of save is its own request, the drafts last, since a + // draft save skips the checks the others still run let saved = false; - for (const request of [ - { - type: 'update', - posts: posts.filter((p: any) => - updateOnly.includes(p.integration.id) - ), - }, - { - type, + for (const requestType of ['update', 'schedule', 'now', 'draft']) { + const request = { + type: requestType, posts: posts.filter( - (p: any) => !updateOnly.includes(p.integration.id) + (p: any) => saveTypeOf(p.integration.id) === requestType ), - }, - ]) { + }; if (!request.posts.length) { continue; } @@ -678,7 +796,9 @@ export const ManageModal: FC = (props) => { tags, date, channelDates, + current, integrations, + selectedIntegrations, addEditSets, dummy, shortlinkPreferenceData, diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx index 71fb277862..d20e224e6c 100644 --- a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx @@ -218,14 +218,6 @@ export const withProvider = function (params: { selectedIntegration.integration.additionalSettings || '[]' ) ), - fix: () => { - setCurrent(props.id); - setHide(true); - }, - preview: () => { - setCurrent(props.id); - setHide(true); - }, }; }, getValues: () => { @@ -239,6 +231,17 @@ export const withProvider = function (params: { trigger: () => { return form.trigger(); }, + // a save that failed on this channel brings it into view, with its + // settings showing what is wrong + fix: () => { + setCurrent(props.id); + setHide(true); + form.trigger(); + }, + preview: () => { + setCurrent(props.id); + setHide(true); + }, }), [value] ); diff --git a/i18n.lock b/i18n.lock index 6e2d38374a..c0ece4a907 100644 --- a/i18n.lock +++ b/i18n.lock @@ -837,3 +837,10 @@ checksums: delete_post_from_all_channels_question: f37374b458fa43056a019397f342ea04 delete_from_all_channels: 39a7f754c6a1f730cd511307e483e13d delete_only_from: 819a30bf30916eba1d5ced11efc16fde + draft_post_in_all_channels_question: 042b07c5150e2dc8b37553301cb2aa73 + draft_all_channels: c9008358b4c607d26973eb08fb961424 + post_now_in_all_channels_question: aba83099c4258ae0d2272b16d835b2b4 + post_now_all_channels: c4a25fc5f6abc7fb2c584d22e5bea01c + schedule_post_in_all_channels_question: 8fd19c2e5e74c4a8438ba180beaed615 + schedule_all_channels: 2c761cc9d12ac53696ddfbdabdff1088 + only_channel: 5687ae99e7f5b168e35532f895efd2e8 diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 6b2bb647bb..84b8d9fa90 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "هل أنت متأكد أنك تريد حذف الوسائط المحددة؟", "delete_post_from_all_channels_question": "تم إنشاء هذا المنشور لأكثر من قناة. هل تريد حذفه منها جميعًا؟", "delete_from_all_channels": "حذف من جميع القنوات", - "delete_only_from": "فقط من" + "delete_only_from": "فقط من", + "draft_post_in_all_channels_question": "تم إنشاء هذا المنشور لأكثر من قناة. هل تريد نقلها جميعًا إلى المسودات؟", + "draft_all_channels": "نقل جميع القنوات إلى المسودات", + "post_now_in_all_channels_question": "تم إنشاء هذا المنشور لأكثر من قناة. هل تريد نشرها جميعًا الآن؟", + "post_now_all_channels": "النشر الآن على جميع القنوات", + "schedule_post_in_all_channels_question": "تم إنشاء هذا المنشور لأكثر من قناة. هل تريد جدولتها جميعًا؟", + "schedule_all_channels": "جدولة جميع القنوات", + "only_channel": "فقط" } diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 227c8d0bac..ac95c201b1 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "আপনি কি নিশ্চিত যে আপনি নির্বাচিত মিডিয়া মুছে ফেলতে চান?", "delete_post_from_all_channels_question": "এই পোস্টটি একাধিক চ্যানেলের জন্য তৈরি করা হয়েছে। আপনি কি এটি সবগুলো থেকে মুছে ফেলতে চান?", "delete_from_all_channels": "সব চ্যানেল থেকে মুছে ফেলুন", - "delete_only_from": "শুধুমাত্র চ্যানেল থেকে:" + "delete_only_from": "শুধুমাত্র চ্যানেল থেকে:", + "draft_post_in_all_channels_question": "এই পোস্টটি একাধিক চ্যানেলের জন্য তৈরি করা হয়েছে। আপনি কি সবগুলোকে খসড়ায় সরাতে চান?", + "draft_all_channels": "সব চ্যানেল খসড়ায় সরান", + "post_now_in_all_channels_question": "এই পোস্টটি একাধিক চ্যানেলের জন্য তৈরি করা হয়েছে। আপনি কি সবগুলো এখনই পোস্ট করতে চান?", + "post_now_all_channels": "সব চ্যানেলে এখনই পোস্ট করুন", + "schedule_post_in_all_channels_question": "এই পোস্টটি একাধিক চ্যানেলের জন্য তৈরি করা হয়েছে। আপনি কি সবগুলোর সময়সূচী করতে চান?", + "schedule_all_channels": "সব চ্যানেলের সময়সূচী করুন", + "only_channel": "শুধুমাত্র চ্যানেল:" } diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index ad516ffc7b..4a73e28af0 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Bist du sicher, dass du die ausgewählten Medien löschen möchtest?", "delete_post_from_all_channels_question": "Dieser Beitrag wurde für mehr als einen Kanal erstellt. Möchtest du ihn aus allen löschen?", "delete_from_all_channels": "Aus allen Kanälen löschen", - "delete_only_from": "Nur aus" + "delete_only_from": "Nur aus", + "draft_post_in_all_channels_question": "Dieser Beitrag wurde für mehr als einen Kanal erstellt. Möchtest du ihn in allen Kanälen als Entwurf speichern?", + "draft_all_channels": "Alle Kanäle als Entwurf speichern", + "post_now_in_all_channels_question": "Dieser Beitrag wurde für mehr als einen Kanal erstellt. Möchtest du ihn jetzt in allen Kanälen posten?", + "post_now_all_channels": "Jetzt in allen Kanälen posten", + "schedule_post_in_all_channels_question": "Dieser Beitrag wurde für mehr als einen Kanal erstellt. Möchtest du ihn in allen Kanälen planen?", + "schedule_all_channels": "In allen Kanälen planen", + "only_channel": "Nur" } diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index ae1c81aefc..055417ece9 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -834,5 +834,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Are you sure you want to delete the selected media?", "delete_post_from_all_channels_question": "This post was created for more than one channel. Do you want to delete it from all of them?", "delete_from_all_channels": "Delete from all channels", - "delete_only_from": "Only from" + "delete_only_from": "Only from", + "draft_post_in_all_channels_question": "This post was created for more than one channel. Do you want to move all of them to drafts?", + "draft_all_channels": "Move all channels to drafts", + "post_now_in_all_channels_question": "This post was created for more than one channel. Do you want to post all of them now?", + "post_now_all_channels": "Post all channels now", + "schedule_post_in_all_channels_question": "This post was created for more than one channel. Do you want to schedule all of them?", + "schedule_all_channels": "Schedule all channels", + "only_channel": "Only" } diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index dca0314f7f..5cbce46754 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "¿Estás seguro de que quieres eliminar los medios seleccionados?", "delete_post_from_all_channels_question": "Esta publicación se creó para más de un canal. ¿Quieres eliminarla de todos?", "delete_from_all_channels": "Eliminar de todos los canales", - "delete_only_from": "Solo de" + "delete_only_from": "Solo de", + "draft_post_in_all_channels_question": "Esta publicación se creó para más de un canal. ¿Quieres pasarlas todas a borradores?", + "draft_all_channels": "Pasar todos los canales a borradores", + "post_now_in_all_channels_question": "Esta publicación se creó para más de un canal. ¿Quieres publicarlas todas ahora?", + "post_now_all_channels": "Publicar ahora en todos los canales", + "schedule_post_in_all_channels_question": "Esta publicación se creó para más de un canal. ¿Quieres programarlas todas?", + "schedule_all_channels": "Programar todos los canales", + "only_channel": "Solo" } diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 9bb4d07f3a..21e7400b69 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Êtes-vous sûr de vouloir supprimer les médias sélectionnés ?", "delete_post_from_all_channels_question": "Ce post a été créé pour plusieurs canaux. Voulez-vous le supprimer de tous ?", "delete_from_all_channels": "Supprimer de tous les canaux", - "delete_only_from": "Uniquement sur" + "delete_only_from": "Uniquement sur", + "draft_post_in_all_channels_question": "Ce post a été créé pour plusieurs canaux. Voulez-vous tous les passer en brouillon ?", + "draft_all_channels": "Passer tous les canaux en brouillon", + "post_now_in_all_channels_question": "Ce post a été créé pour plusieurs canaux. Voulez-vous tous les publier maintenant ?", + "post_now_all_channels": "Publier maintenant sur tous les canaux", + "schedule_post_in_all_channels_question": "Ce post a été créé pour plusieurs canaux. Voulez-vous tous les planifier ?", + "schedule_all_channels": "Planifier tous les canaux", + "only_channel": "Uniquement" } diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index f597e60614..00da584059 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "האם אתה בטוח שברצונך למחוק את המדיה שנבחרה?", "delete_post_from_all_channels_question": "הפוסט הזה נוצר עבור יותר מערוץ אחד. האם ברצונך למחוק אותו מכולם?", "delete_from_all_channels": "מחק מכל הערוצים", - "delete_only_from": "רק מהערוץ" + "delete_only_from": "רק מהערוץ", + "draft_post_in_all_channels_question": "הפוסט הזה נוצר עבור יותר מערוץ אחד. האם ברצונך להעביר את כולם לטיוטות?", + "draft_all_channels": "העבר את כל הערוצים לטיוטות", + "post_now_in_all_channels_question": "הפוסט הזה נוצר עבור יותר מערוץ אחד. האם ברצונך לפרסם את כולם עכשיו?", + "post_now_all_channels": "פרסם עכשיו בכל הערוצים", + "schedule_post_in_all_channels_question": "הפוסט הזה נוצר עבור יותר מערוץ אחד. האם ברצונך לתזמן את כולם?", + "schedule_all_channels": "תזמן את כל הערוצים", + "only_channel": "רק" } diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 4a554c7031..5e0ffc4b61 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Sei sicuro di voler eliminare i media selezionati?", "delete_post_from_all_channels_question": "Questo post è stato creato per più di un canale. Vuoi eliminarlo da tutti?", "delete_from_all_channels": "Elimina da tutti i canali", - "delete_only_from": "Solo da" + "delete_only_from": "Solo da", + "draft_post_in_all_channels_question": "Questo post è stato creato per più di un canale. Vuoi spostarli tutti nelle bozze?", + "draft_all_channels": "Sposta tutti i canali nelle bozze", + "post_now_in_all_channels_question": "Questo post è stato creato per più di un canale. Vuoi pubblicarli tutti ora?", + "post_now_all_channels": "Pubblica ora su tutti i canali", + "schedule_post_in_all_channels_question": "Questo post è stato creato per più di un canale. Vuoi programmarli tutti?", + "schedule_all_channels": "Programma tutti i canali", + "only_channel": "Solo" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 57f052d4b9..cc041ad3e4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "選択したメディアを削除してもよろしいですか?", "delete_post_from_all_channels_question": "この投稿は複数のチャンネル向けに作成されました。すべてのチャンネルから削除しますか?", "delete_from_all_channels": "すべてのチャンネルから削除", - "delete_only_from": "このチャンネルのみ:" + "delete_only_from": "このチャンネルのみ:", + "draft_post_in_all_channels_question": "この投稿は複数のチャンネル向けに作成されました。すべてのチャンネルを下書きに移動しますか?", + "draft_all_channels": "すべてのチャンネルを下書きに移動", + "post_now_in_all_channels_question": "この投稿は複数のチャンネル向けに作成されました。すべてのチャンネルに今すぐ投稿しますか?", + "post_now_all_channels": "すべてのチャンネルに今すぐ投稿", + "schedule_post_in_all_channels_question": "この投稿は複数のチャンネル向けに作成されました。すべてのチャンネルでスケジュールしますか?", + "schedule_all_channels": "すべてのチャンネルでスケジュール", + "only_channel": "このチャンネルのみ:" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index 17c5648f0f..d4606aeec1 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "선택한 미디어를 삭제하시겠습니까?", "delete_post_from_all_channels_question": "이 게시글은 여러 채널용으로 만들어졌습니다. 모든 채널에서 삭제하시겠습니까?", "delete_from_all_channels": "모든 채널에서 삭제", - "delete_only_from": "다음 채널에서만:" + "delete_only_from": "다음 채널에서만:", + "draft_post_in_all_channels_question": "이 게시글은 여러 채널용으로 만들어졌습니다. 모든 채널을 임시 저장으로 옮기시겠습니까?", + "draft_all_channels": "모든 채널을 임시 저장으로 옮기기", + "post_now_in_all_channels_question": "이 게시글은 여러 채널용으로 만들어졌습니다. 모든 채널에 지금 게시하시겠습니까?", + "post_now_all_channels": "모든 채널에 지금 게시", + "schedule_post_in_all_channels_question": "이 게시글은 여러 채널용으로 만들어졌습니다. 모든 채널에 예약하시겠습니까?", + "schedule_all_channels": "모든 채널에 예약", + "only_channel": "다음 채널만:" } diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 6e4c2367c8..f8f7d8a70b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Tem certeza de que deseja excluir as mídias selecionadas?", "delete_post_from_all_channels_question": "Esta publicação foi criada para mais de um canal. Deseja excluí-la de todos?", "delete_from_all_channels": "Excluir de todos os canais", - "delete_only_from": "Apenas de" + "delete_only_from": "Apenas de", + "draft_post_in_all_channels_question": "Esta publicação foi criada para mais de um canal. Deseja mover todas para rascunhos?", + "draft_all_channels": "Mover todos os canais para rascunhos", + "post_now_in_all_channels_question": "Esta publicação foi criada para mais de um canal. Deseja publicar todas agora?", + "post_now_all_channels": "Publicar agora em todos os canais", + "schedule_post_in_all_channels_question": "Esta publicação foi criada para mais de um canal. Deseja agendar todas?", + "schedule_all_channels": "Agendar todos os canais", + "only_channel": "Apenas" } diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 580773094e..1a1c4d1388 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Вы уверены, что хотите удалить выбранные медиа?", "delete_post_from_all_channels_question": "Этот пост создан для нескольких каналов. Удалить его из всех?", "delete_from_all_channels": "Удалить из всех каналов", - "delete_only_from": "Только из канала" + "delete_only_from": "Только из канала", + "draft_post_in_all_channels_question": "Этот пост создан для нескольких каналов. Перенести его в черновики во всех?", + "draft_all_channels": "Перенести все каналы в черновики", + "post_now_in_all_channels_question": "Этот пост создан для нескольких каналов. Опубликовать его сейчас во всех?", + "post_now_all_channels": "Опубликовать сейчас во всех каналах", + "schedule_post_in_all_channels_question": "Этот пост создан для нескольких каналов. Запланировать его во всех?", + "schedule_all_channels": "Запланировать во всех каналах", + "only_channel": "Только" } diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index f94df93353..ba0b054ed4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Seçili medyayı silmek istediğinizden emin misiniz?", "delete_post_from_all_channels_question": "Bu gönderi birden fazla kanal için oluşturuldu. Hepsinden silmek istiyor musunuz?", "delete_from_all_channels": "Tüm kanallardan sil", - "delete_only_from": "Yalnızca şu kanaldan:" + "delete_only_from": "Yalnızca şu kanaldan:", + "draft_post_in_all_channels_question": "Bu gönderi birden fazla kanal için oluşturuldu. Hepsini taslaklara taşımak istiyor musunuz?", + "draft_all_channels": "Tüm kanalları taslaklara taşı", + "post_now_in_all_channels_question": "Bu gönderi birden fazla kanal için oluşturuldu. Hepsini şimdi paylaşmak istiyor musunuz?", + "post_now_all_channels": "Tüm kanallarda şimdi paylaş", + "schedule_post_in_all_channels_question": "Bu gönderi birden fazla kanal için oluşturuldu. Hepsini zamanlamak istiyor musunuz?", + "schedule_all_channels": "Tüm kanalları zamanla", + "only_channel": "Yalnızca şu kanal:" } diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 65db01a720..6510e18f6a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "Bạn có chắc muốn xóa phương tiện đã chọn không?", "delete_post_from_all_channels_question": "Bài đăng này được tạo cho nhiều kênh. Bạn có muốn xóa nó khỏi tất cả các kênh không?", "delete_from_all_channels": "Xóa khỏi tất cả các kênh", - "delete_only_from": "Chỉ từ" + "delete_only_from": "Chỉ từ", + "draft_post_in_all_channels_question": "Bài đăng này được tạo cho nhiều kênh. Bạn có muốn chuyển tất cả sang bản nháp không?", + "draft_all_channels": "Chuyển tất cả các kênh sang bản nháp", + "post_now_in_all_channels_question": "Bài đăng này được tạo cho nhiều kênh. Bạn có muốn đăng ngay trên tất cả các kênh không?", + "post_now_all_channels": "Đăng ngay trên tất cả các kênh", + "schedule_post_in_all_channels_question": "Bài đăng này được tạo cho nhiều kênh. Bạn có muốn lên lịch cho tất cả các kênh không?", + "schedule_all_channels": "Lên lịch cho tất cả các kênh", + "only_channel": "Chỉ" } diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index bc3261868f..0b3235c94e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -832,5 +832,12 @@ "are_you_sure_you_want_to_delete_selected_media": "你确定要删除所选媒体吗?", "delete_post_from_all_channels_question": "此帖子是为多个频道创建的。要从所有频道中删除它吗?", "delete_from_all_channels": "从所有频道删除", - "delete_only_from": "只删除" + "delete_only_from": "只删除", + "draft_post_in_all_channels_question": "此帖子是为多个频道创建的。要将所有频道都移至草稿吗?", + "draft_all_channels": "将所有频道移至草稿", + "post_now_in_all_channels_question": "此帖子是为多个频道创建的。要立即在所有频道发布吗?", + "post_now_all_channels": "立即在所有频道发布", + "schedule_post_in_all_channels_question": "此帖子是为多个频道创建的。要在所有频道安排发布吗?", + "schedule_all_channels": "在所有频道安排发布", + "only_channel": "仅" } From adf1a8f50048dc2d779ed092290dde1cc053e5cd Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 8 Oct 2026 15:45:43 +0700 Subject: [PATCH 86/99] fix(calendar): put Delete right-most in the post hover bar, after Preview and Duplicate Complements #2204: the hover icons now end with Preview, Duplicate, Delete, so Duplicate sits between Preview and Delete. Co-Authored-By: Claude Opus 5.5 --- .../src/components/launches/calendar.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 5935c0705c..0854697b98 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -1324,24 +1324,6 @@ const CalendarItem: FC<{
    )} -
    - -
    -
    - -
    {' '} {(state === 'PUBLISHED' || state === 'ERROR') && !post.intervalInDays && post.releaseURL?.startsWith('http') && ( @@ -1389,6 +1371,24 @@ const CalendarItem: FC<{ >
    +
    + +
    +
    + +
    Date: Thu, 8 Oct 2026 16:47:20 +0700 Subject: [PATCH 87/99] feat: move menu items --- .../src/app/(app)/(site)/plugs/page.tsx | 12 +- .../src/app/(app)/(site)/third-party/page.tsx | 13 +- .../approved-apps/approved-apps.component.tsx | 15 +- .../components/layout/settings.component.tsx | 45 ++++-- .../src/components/layout/top.menu.tsx | 44 +----- .../new-layout/layout.component.tsx | 10 +- .../oauth/self.hosted.component.tsx | 6 +- .../onboarding/onboarding.modal.tsx | 4 +- apps/frontend/src/components/plugs/plugs.tsx | 125 ++++++----------- .../public-api/mcp.client.icons.tsx | 2 +- .../public-api/public.component.tsx | 27 +--- .../components/settings/apps.component.tsx | 44 ++++++ .../third-parties/third-party.component.tsx | 129 +++++------------- i18n.lock | 5 +- .../src/chat/mcp.relay.service.ts | 2 +- .../translation/locales/ar/translation.json | 5 +- .../translation/locales/bn/translation.json | 5 +- .../translation/locales/de/translation.json | 5 +- .../translation/locales/en/translation.json | 5 +- .../translation/locales/es/translation.json | 5 +- .../translation/locales/fr/translation.json | 5 +- .../translation/locales/he/translation.json | 5 +- .../translation/locales/it/translation.json | 5 +- .../translation/locales/ja/translation.json | 5 +- .../translation/locales/ko/translation.json | 5 +- .../translation/locales/pt/translation.json | 5 +- .../translation/locales/ru/translation.json | 5 +- .../translation/locales/tr/translation.json | 5 +- .../translation/locales/vi/translation.json | 5 +- .../translation/locales/zh/translation.json | 5 +- 30 files changed, 234 insertions(+), 324 deletions(-) create mode 100644 apps/frontend/src/components/settings/apps.component.tsx diff --git a/apps/frontend/src/app/(app)/(site)/plugs/page.tsx b/apps/frontend/src/app/(app)/(site)/plugs/page.tsx index af6f39bc45..975e154f17 100644 --- a/apps/frontend/src/app/(app)/(site)/plugs/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/plugs/page.tsx @@ -1,11 +1,7 @@ -import { Plugs } from '@gitroom/frontend/components/plugs/plugs'; +import { redirect } from 'next/navigation'; + export const dynamic = 'force-dynamic'; -import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; -export const metadata: Metadata = { - title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Plugs`, - description: '', -}; + export default async function Index() { - return ; + return redirect('/settings?tab=plugs'); } diff --git a/apps/frontend/src/app/(app)/(site)/third-party/page.tsx b/apps/frontend/src/app/(app)/(site)/third-party/page.tsx index fef12c61e4..e762d73d03 100644 --- a/apps/frontend/src/app/(app)/(site)/third-party/page.tsx +++ b/apps/frontend/src/app/(app)/(site)/third-party/page.tsx @@ -1,14 +1,7 @@ -import { ThirdPartyComponent } from '@gitroom/frontend/components/third-parties/third-party.component'; +import { redirect } from 'next/navigation'; export const dynamic = 'force-dynamic'; -import { Metadata } from 'next'; -import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; -export const metadata: Metadata = { - title: `${ - isGeneralServerSide() ? 'Postiz Integrations' : 'Gitroom Integrations' - }`, - description: '', -}; + export default async function Index() { - return ; + return redirect('/settings?tab=integrations'); } diff --git a/apps/frontend/src/components/approved-apps/approved-apps.component.tsx b/apps/frontend/src/components/approved-apps/approved-apps.component.tsx index 5d4dffaf44..ff82065479 100644 --- a/apps/frontend/src/components/approved-apps/approved-apps.component.tsx +++ b/apps/frontend/src/components/approved-apps/approved-apps.component.tsx @@ -60,16 +60,11 @@ export const ApprovedAppsComponent: FC = () => { return (
    -
    -

    - {t('approved_apps', 'Approved Apps')} -

    -
    - {t( - 'apps_you_have_authorized', - 'Applications you have authorized to access your Postiz account.' - )} -
    +
    + {t( + 'apps_you_have_authorized', + 'Applications you have authorized to access your Postiz account.' + )}
    diff --git a/apps/frontend/src/components/layout/settings.component.tsx b/apps/frontend/src/components/layout/settings.component.tsx index 766b219860..9c5460473b 100644 --- a/apps/frontend/src/components/layout/settings.component.tsx +++ b/apps/frontend/src/components/layout/settings.component.tsx @@ -31,7 +31,9 @@ import { Autopost } from '@gitroom/frontend/components/autopost/autopost'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { SVGLine } from '@gitroom/frontend/components/launches/launches.component'; import { GlobalSettings } from '@gitroom/frontend/components/settings/global.settings'; -import { ApprovedAppsComponent } from '@gitroom/frontend/components/approved-apps/approved-apps.component'; +import { AppsComponent } from '@gitroom/frontend/components/settings/apps.component'; +import { ThirdPartyComponent } from '@gitroom/frontend/components/third-parties/third-party.component'; +import { Plugs } from '@gitroom/frontend/components/plugs/plugs'; export const SettingsPopup: FC<{ getRef?: Ref; }> = (props) => { @@ -81,13 +83,14 @@ export const SettingsPopup: FC<{ close(); }, []); - const [tab, setTab] = useState('global_settings'); - const t = useT(); const list = useMemo(() => { const arr = []; arr.push({ tab: 'global_settings', label: t('global_settings', 'Global Settings') }); // Populate tabs based on user permissions + if (user?.tier?.public_api && isGeneral && showLogout) { + arr.push({ tab: 'agents', label: t('agents', 'Agents') }); + } if (user?.tier?.team_members && isGeneral && user?.role !== 'USER') { arr.push({ tab: 'teams', label: t('teams', 'Teams') }); } @@ -103,14 +106,20 @@ export const SettingsPopup: FC<{ if (user?.tier.current !== 'FREE') { arr.push({ tab: 'signatures', label: t('signatures', 'Signatures') }); } - if (user?.tier?.public_api && isGeneral && showLogout) { - arr.push({ tab: 'api', label: t('developers', 'Developers') }); - } - arr.push({ tab: 'approved_apps', label: t('approved_apps', 'Approved Apps') }); + arr.push({ tab: 'integrations', label: t('integrations', 'Integrations') }); + arr.push({ tab: 'plugs', label: t('plugs', 'Plugs') }); + arr.push({ tab: 'apps', label: t('apps', 'Apps') }); return arr; }, [user, isGeneral, showLogout, t]); + const [tab, setTab] = useState(() => { + const requested = url.get('tab'); + return list.some((p) => p.tab === requested) + ? requested! + : 'global_settings'; + }); + useEffect(() => { loadProfile(); }, []); @@ -198,7 +207,19 @@ export const SettingsPopup: FC<{
    )} - {tab === 'api' && + {tab === 'integrations' && ( +
    + +
    + )} + + {tab === 'plugs' && ( +
    + +
    + )} + + {tab === 'agents' && !!user?.tier?.public_api && isGeneral && showLogout && ( @@ -207,9 +228,13 @@ export const SettingsPopup: FC<{
    )} - {tab === 'approved_apps' && ( + {tab === 'apps' && (
    - +
    )}
    diff --git a/apps/frontend/src/components/layout/top.menu.tsx b/apps/frontend/src/components/layout/top.menu.tsx index f8929c0030..ec6a3fc844 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -114,48 +114,6 @@ export const useMenuItem = () => { ), path: '/media', }, - { - name: t('plugs', 'Plugs'), - icon: ( - - - - ), - path: '/plugs', - }, - { - name: t('integrations', 'Integrations'), - icon: ( - - - - ), - path: '/third-party', - }, ] satisfies MenuItemInterface[] as MenuItemInterface[]; const secondMenu = [ @@ -385,7 +343,7 @@ export const TopMenu: FC = () => { const { isGeneral, billingEnabled } = useVariables(); return ( <> -
    +
    { // @ts-ignore user?.orgId && diff --git a/apps/frontend/src/components/new-layout/layout.component.tsx b/apps/frontend/src/components/new-layout/layout.component.tsx index 091cd9fae3..d2502da640 100644 --- a/apps/frontend/src/components/new-layout/layout.component.tsx +++ b/apps/frontend/src/components/new-layout/layout.component.tsx @@ -131,12 +131,14 @@ export const LayoutComponent = ({ children }: { children: ReactNode }) => {
    -
    - +
    +
    + +
    diff --git a/apps/frontend/src/components/oauth/self.hosted.component.tsx b/apps/frontend/src/components/oauth/self.hosted.component.tsx index 97c0f92ecb..34fbba619c 100644 --- a/apps/frontend/src/components/oauth/self.hosted.component.tsx +++ b/apps/frontend/src/components/oauth/self.hosted.component.tsx @@ -107,13 +107,13 @@ export const OAuthSelfHosted: FC<{ removeError={true} value={apiKey} onChange={(e) => setApiKey(e.target.value)} - placeholder="Settings > Developers on your instance" + placeholder="Settings > Agents (or Developers) on your instance" autoComplete="off" spellCheck={false} />
    - You can also paste the MCP URL from Settings > Developers in the - Instance URL, it includes the key. + You can also paste the MCP URL from Settings > Agents (Settings > + Developers on older versions) in the Instance URL, it includes the key.
    diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index 619e7cb95c..3101840d27 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -629,7 +629,7 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({
    {t( 'agent_access_unavailable', - 'Agent access is not available for your current plan or role. You can set it up later under Settings > Developers.' + 'Agent access is not available for your current plan or role. You can set it up later under Settings > Agents.' )}
    )} @@ -660,7 +660,7 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({
    {t( 'agent_settings_later', - 'More agents and full instructions are available under Settings > Developers' + 'More agents and full instructions are available under Settings > Agents' )}
    -
    - ); - } return ( - <> -
    -
    -
    -

    - {t('channels')} -

    -
    setCollapseMenu(collapseMenu === '1' ? '0' : '1')} - className="group-[.sidebar]:rotate-[180deg] group-[.sidebar]:mx-auto text-btnText bg-btnSimple rounded-[6px] w-[24px] h-[24px] flex items-center justify-center cursor-pointer select-none" - > - - - -
    +
    +
    +

    {t('plugs', 'Plugs')}

    +
    + {!sortedIntegrations.length ? ( +
    +
    + {t( + 'there_are_not_plugs_matching_your_channels', + 'There are not plugs matching your channels' + )} +
    + {t( + 'you_have_to_add_x_linkedin_page_threads_or_bluesky', + 'You have to add: X, LinkedIn Page, Threads or Bluesky' + )}
    -
    + +
    + ) : ( + <> +
    {sortedIntegrations.map((integration, index) => (
    { setCurrent(index); }} className={clsx( - 'flex gap-[8px] items-center justify-center group/profile hover:bg-boxHover rounded-e-[8px] mobile:shrink-0', - currentIntegration.id !== integration.id && - 'opacity-20 hover:opacity-100 cursor-pointer' + 'flex gap-[8px] items-center px-[12px] py-[8px] rounded-[8px] hover:bg-boxHover cursor-pointer', + currentIntegration.id === integration.id + ? 'bg-boxHover' + : 'opacity-40 hover:opacity-100' )} >
    {(integration.inBetweenSteps || integration.refreshNeeded) && ( -
    +
    !
    -
    +
    )} -
    - -
    { /> {
    @@ -221,13 +182,11 @@ export const Plugs = () => {
    ))}
    -
    -
    -
    - - - -
    - + + + + + )} +
    ); }; diff --git a/apps/frontend/src/components/public-api/mcp.client.icons.tsx b/apps/frontend/src/components/public-api/mcp.client.icons.tsx index 412cfa0c0d..bc44435345 100644 --- a/apps/frontend/src/components/public-api/mcp.client.icons.tsx +++ b/apps/frontend/src/components/public-api/mcp.client.icons.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; -// Logos for the MCP clients shown in Settings > Developers and in onboarding. +// Logos for the MCP clients shown in Settings > Agents and in onboarding. // Monochrome marks use currentColor so they follow the button text color, // brand marks (Claude, VS Code, Gemini) keep their colors. const icons: Record> = { diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index 43c9a27d56..038bfcefd4 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -9,7 +9,6 @@ import { useVariables } from '@gitroom/react/helpers/variable.context'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useDecisionModal } from '@gitroom/frontend/components/layout/new-modal'; -import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component'; import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; import clsx from 'clsx'; @@ -926,7 +925,6 @@ export const PublicComponent = () => { const t = useT(); const fetch = useFetch(); const user = useUser(); - const [subTab, setSubTab] = useState<'api' | 'developer'>('api'); const loadOrganizations = useCallback(async () => { return await (await fetch('/user/organizations')).json(); }, []); @@ -944,30 +942,9 @@ export const PublicComponent = () => { return (

    - {t('developers', 'Developers')} - {currentOrg?.name ? ` - ${currentOrg.name}` : ''} + {t('agents', 'Agents')}

    -
    - {(['api', 'developer'] as const).map((tab) => ( - - ))} -
    - {subTab === 'api' && } - {subTab === 'developer' && } +
    ); }; diff --git a/apps/frontend/src/components/settings/apps.component.tsx b/apps/frontend/src/components/settings/apps.component.tsx new file mode 100644 index 0000000000..9252f44a44 --- /dev/null +++ b/apps/frontend/src/components/settings/apps.component.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { FC, useState } from 'react'; +import clsx from 'clsx'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component'; +import { ApprovedAppsComponent } from '@gitroom/frontend/components/approved-apps/approved-apps.component'; + +export const AppsComponent: FC<{ canManageApps: boolean }> = ({ + canManageApps, +}) => { + const t = useT(); + const [subTab, setSubTab] = useState<'apps' | 'approved_apps'>('apps'); + const current = canManageApps ? subTab : 'approved_apps'; + + return ( +
    +

    {t('apps', 'Apps')}

    + {canManageApps && ( +
    + {(['apps', 'approved_apps'] as const).map((tab) => ( + + ))} +
    + )} + {current === 'apps' && } + {current === 'approved_apps' && } +
    + ); +}; diff --git a/apps/frontend/src/components/third-parties/third-party.component.tsx b/apps/frontend/src/components/third-parties/third-party.component.tsx index 503467a8a4..cbe979bafd 100644 --- a/apps/frontend/src/components/third-parties/third-party.component.tsx +++ b/apps/frontend/src/components/third-parties/third-party.component.tsx @@ -1,6 +1,5 @@ 'use client'; -import clsx from 'clsx'; import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { ThirdPartyListComponent } from '@gitroom/frontend/components/third-parties/third-party.list.component'; @@ -9,8 +8,6 @@ import useSWR from 'swr'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; -import useCookie from 'react-use-cookie'; -import { SVGLine } from '@gitroom/frontend/components/launches/launches.component'; export const ThirdPartyMenuComponent: FC<{ reload: () => void; @@ -64,7 +61,7 @@ export const ThirdPartyMenuComponent: FC<{ {show && (
    e.stopPropagation()} - className={`absolute top-[100%] start-0 mobile:start-auto mobile:end-0 p-[8px] px-[20px] bg-fifth flex flex-col gap-[16px] z-[100] rounded-[8px] border border-tableBorder text-nowrap`} + className={`absolute top-[100%] end-0 p-[8px] px-[20px] bg-fifth flex flex-col gap-[16px] z-[100] rounded-[8px] border border-tableBorder text-nowrap`} >
    { refreshWhenHidden: false, refreshWhenOffline: false, }); - const [collapseMenu, setCollapseMenu] = useCookie('collapseMenu', '0'); - return ( - <> -
    -
    -
    -

    - {t('integrations')} -

    -
    setCollapseMenu(collapseMenu === '1' ? '0' : '1')} - className="group-[.sidebar]:rotate-[180deg] group-[.sidebar]:mx-auto mobile:hidden text-btnText bg-btnSimple rounded-[6px] w-[24px] h-[24px] flex items-center justify-center cursor-pointer select-none" - > - - - -
    -
    -
    -
    -
    - {!isLoading && !data?.length ? ( -
    No Integrations Yet
    - ) : ( - data?.map((p: any) => ( +
    +
    +

    {t('integrations', 'Integrations')}

    +
    + {!isLoading && ( +
    + {!data?.length ? ( +
    No Integrations Yet
    + ) : ( +
    + {data?.map((p: any) => ( +
    +
    +
    -
    - -
    -
    - -
    -
    - {p.name} -
    - + {p.name}
    - )) - )} -
    +
    + +
    + ))}
    -
    + )}
    -
    -
    - -
    - + )} + +
    ); }; diff --git a/i18n.lock b/i18n.lock index c0ece4a907..55f9e38122 100644 --- a/i18n.lock +++ b/i18n.lock @@ -726,7 +726,7 @@ checksums: connect_agents: 3272e6e7b043cd9bd5626dc57892b4b2 connect_your_ai_agent: 29cfdf27911e816ade81f5dc3ba948eb connect_agent_description: 087b814876d35ea71d2f63d54cc39788 - agent_access_unavailable: 80a529742f86d7cacb9bb44d5e06be04 + agent_access_unavailable: e4bcc518fa6b95d12354c17073191312 sign_in_no_api_key: b17680d7465d120632114d25217e0e2e oauth_sign_in_hint: 92797174fbc54b56b7c81f485dc9008b add_to_cursor: da1a4472a29f82361b48e26842c05a0e @@ -744,7 +744,8 @@ checksums: connector_self_hosted_description: c966620ed2df1d6ffeaa92969aaf30a6 mcp_onboarding_description: 5b33036d174222d86ac50039a3a31724 cli_onboarding_description: b4aba065e5a8410ee2c365ee4d7aa87c - agent_settings_later: bbdd45a80cc5218809363348e0b03a32 + agent_settings_later: aade9be6157c4f95d9a6361bcbb613d2 + agents: f902c551bd769a3ae2f65b44305b1976 watch_tutorial: 7f202b82c805285109187f099d22e863 watch_tutorial_title: c3c0d814f10a3325a7fd52a7fb44ae38 watch_tutorial_description: 25dd65235d9603f6d65d62f953791722 diff --git a/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts b/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts index 1149d9bf65..8cda77141f 100644 --- a/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts +++ b/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts @@ -185,7 +185,7 @@ export class McpRelayService { } throw new HttpException( - `Could not find a Postiz MCP server at ${url.origin}. Paste the MCP URL from Settings > Developers of your instance, and make sure it runs Postiz v2.19.0 or newer`, + `Could not find a Postiz MCP server at ${url.origin}. Paste the MCP URL from Settings > Agents (Settings > Developers on older versions) of your instance, and make sure it runs Postiz v2.19.0 or newer`, HttpStatus.BAD_REQUEST ); } diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 84b8d9fa90..e18c714cb6 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -722,7 +722,7 @@ "connect_agents": "ربط الوكلاء", "connect_your_ai_agent": "اربط وكيل الذكاء الاصطناعي الخاص بك", "connect_agent_description": "اختر الوكيل الذي تستخدمه ودعه ينشئ وجدول المنشورات نيابة عنك", - "agent_access_unavailable": "الوصول إلى الوكيل غير متاح في خطتك الحالية أو دورك. يمكنك إعداده لاحقًا من الإعدادات > المطورون.", + "agent_access_unavailable": "الوصول إلى الوكيل غير متاح في خطتك الحالية أو دورك. يمكنك إعداده لاحقًا من الإعدادات > الوكلاء.", "sign_in_no_api_key": "تسجيل الدخول مع Postiz (بدون مفتاح API)", "oauth_sign_in_hint": "سيفتح وكيلك نافذة متصفح لتسجيل الدخول إلى Postiz.", "add_to_cursor": "أضف إلى المؤشر", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "يعمل الموصل الرسمي أيضًا مع Postiz المستضاف ذاتيًا. عند طلب تسجيل الدخول، اختر \"Use self-hosted\" وأدخل {{url}} مع مفتاح API الخاص بك.", "mcp_onboarding_description": "زود وكيلك بأدوات Postiz لإنشاء وجدولة وإدارة المنشورات", "cli_onboarding_description": "ثبّت Postiz CLI والمهارة التي تعلم وكيلك كيفية استخدامها", - "agent_settings_later": "المزيد من الوكلاء والتعليمات الكاملة متوفرة في الإعدادات > المطورون", + "agent_settings_later": "المزيد من الوكلاء والتعليمات الكاملة متوفرة في الإعدادات > الوكلاء", + "agents": "الوكلاء", "watch_tutorial": "مشاهدة الدليل", "watch_tutorial_title": "تعلم كيفية استخدام Postiz", "watch_tutorial_description": "شاهد هذا الفيديو القصير لتتعلم كيف تستفيد من Postiz بأفضل شكل", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index ac95c201b1..653a6eea17 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -722,7 +722,7 @@ "connect_agents": "এজেন্ট সংযুক্ত করুন", "connect_your_ai_agent": "আপনার AI এজেন্ট সংযুক্ত করুন", "connect_agent_description": "আপনার ব্যবহৃত এজেন্টটি নির্বাচন করুন এবং তাকে পোস্ট তৈরি ও নির্ধারিত করতে দিন", - "agent_access_unavailable": "আপনার বর্তমান প্ল্যান বা ভূমিকার জন্য এজেন্ট অ্যাক্সেস উপলব্ধ নয়। আপনি পরে সেটিংস > ডেভেলপারস-এ সেট আপ করতে পারেন।", + "agent_access_unavailable": "আপনার বর্তমান প্ল্যান বা ভূমিকার জন্য এজেন্ট অ্যাক্সেস উপলব্ধ নয়। আপনি পরে সেটিংস > এজেন্টস-এ সেট আপ করতে পারেন।", "sign_in_no_api_key": "Postiz-এ সাইন ইন করুন (কোনো API কী ছাড়াই)", "oauth_sign_in_hint": "আপনার এজেন্ট Postiz-এ সাইন ইন করার জন্য একটি ব্রাউজার উইন্ডো খুলবে।", "add_to_cursor": "কার্সারে যোগ করুন", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "অফিসিয়াল কানেক্টর সেলফ-হোস্টেড Postiz-এর সাথেও কাজ করে। সাইন ইন করতে বলা হলে, \"Use self-hosted\" বেছে নিন এবং আপনার API কী সহ {{url}} লিখুন।", "mcp_onboarding_description": "আপনার এজেন্টকে Postiz টুল দিন যাতে সে পোস্ট তৈরি, নির্ধারণ এবং পরিচালনা করতে পারে", "cli_onboarding_description": "Postiz CLI এবং সেই স্কিল ইনস্টল করুন যা আপনার এজেন্টকে এটি ব্যবহার করতে শেখায়", - "agent_settings_later": "আরও এজেন্ট এবং সম্পূর্ণ নির্দেশিকা সেটিংস > ডেভেলপারস-এ পাওয়া যাবে", + "agent_settings_later": "আরও এজেন্ট এবং সম্পূর্ণ নির্দেশিকা সেটিংস > এজেন্টস-এ পাওয়া যাবে", + "agents": "এজেন্টস", "watch_tutorial": "টিউটোরিয়াল দেখুন", "watch_tutorial_title": "Postiz ব্যবহারের উপায় শিখুন", "watch_tutorial_description": "Postiz সর্বোচ্চভাবে কাজে লাগাতে এই সংক্ষিপ্ত ভিডিওটি দেখুন", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 4a73e28af0..81040eb8ba 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Agenten verbinden", "connect_your_ai_agent": "Verbinde deinen KI-Agenten", "connect_agent_description": "Wähle den Agenten, den du nutzt, damit er Beiträge für dich erstellen und planen kann.", - "agent_access_unavailable": "Agentenzugriff ist für deinen aktuellen Plan oder deine Rolle nicht verfügbar. Du kannst ihn später unter Einstellungen > Entwickler einrichten.", + "agent_access_unavailable": "Agentenzugriff ist für deinen aktuellen Plan oder deine Rolle nicht verfügbar. Du kannst ihn später unter Einstellungen > Agenten einrichten.", "sign_in_no_api_key": "Mit Postiz anmelden (kein API-Schlüssel)", "oauth_sign_in_hint": "Dein Agent öffnet ein Browserfenster, um sich bei Postiz anzumelden.", "add_to_cursor": "Zum Cursor hinzufügen", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "Der offizielle Connector funktioniert auch mit selbst gehostetem Postiz. Wenn du dich anmelden sollst, wähle \"Use self-hosted\" und gib {{url}} zusammen mit deinem API-Schlüssel ein.", "mcp_onboarding_description": "Gib deinem Agenten Postiz-Tools, um Beiträge zu erstellen, zu planen und zu verwalten.", "cli_onboarding_description": "Installiere das Postiz CLI und das Skill, das deinem Agenten beibringt, wie es zu nutzen ist.", - "agent_settings_later": "Weitere Agenten und vollständige Anweisungen findest du unter Einstellungen > Entwickler.", + "agent_settings_later": "Weitere Agenten und vollständige Anweisungen findest du unter Einstellungen > Agenten.", + "agents": "Agenten", "watch_tutorial": "Tutorial ansehen", "watch_tutorial_title": "Lerne, wie du Postiz benutzt", "watch_tutorial_description": "Sieh dir dieses kurze Video an, um das Beste aus Postiz herauszuholen", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 055417ece9..2d15596e5b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -724,7 +724,7 @@ "connect_agents": "Connect Agents", "connect_your_ai_agent": "Connect Your AI Agent", "connect_agent_description": "Pick the agent you use and let it create and schedule posts for you", - "agent_access_unavailable": "Agent access is not available for your current plan or role. You can set it up later under Settings > Developers.", + "agent_access_unavailable": "Agent access is not available for your current plan or role. You can set it up later under Settings > Agents.", "sign_in_no_api_key": "Sign in with Postiz (no API key)", "oauth_sign_in_hint": "Your agent will open a browser window to sign in to Postiz.", "add_to_cursor": "Add to Cursor", @@ -742,7 +742,8 @@ "connector_self_hosted_description": "The official connector works with self-hosted Postiz too. When asked to sign in, choose \"Use self-hosted\" and enter {{url}} with your API key.", "mcp_onboarding_description": "Give your agent Postiz tools to create, schedule and manage posts", "cli_onboarding_description": "Install the Postiz CLI and the skill that teaches your agent how to use it", - "agent_settings_later": "More agents and full instructions are available under Settings > Developers", + "agent_settings_later": "More agents and full instructions are available under Settings > Agents", + "agents": "Agents", "watch_tutorial": "Watch Tutorial", "watch_tutorial_title": "Learn How to Use Postiz", "watch_tutorial_description": "Watch this short video to learn how to get the most out of Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index 5cbce46754..b05cb6faca 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Conectar agentes", "connect_your_ai_agent": "Conecta tu agente de IA", "connect_agent_description": "Elige el agente que usas y deja que cree y programe publicaciones por ti", - "agent_access_unavailable": "El acceso del agente no está disponible para tu plan o rol actual. Puedes configurarlo más tarde en Configuración > Desarrolladores.", + "agent_access_unavailable": "El acceso del agente no está disponible para tu plan o rol actual. Puedes configurarlo más tarde en Configuración > Agentes.", "sign_in_no_api_key": "Inicia sesión con Postiz (sin clave API)", "oauth_sign_in_hint": "Tu agente abrirá una ventana en el navegador para iniciar sesión en Postiz.", "add_to_cursor": "Agregar al Cursor", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "El conector oficial también funciona con Postiz autoalojado. Cuando te pida iniciar sesión, elige \"Use self-hosted\" e introduce {{url}} con tu clave de API.", "mcp_onboarding_description": "Dale a tu agente las herramientas de Postiz para crear, programar y gestionar publicaciones", "cli_onboarding_description": "Instala el CLI de Postiz y la habilidad que enseña a tu agente cómo utilizarlo", - "agent_settings_later": "Más agentes y las instrucciones completas están disponibles en Configuración > Desarrolladores", + "agent_settings_later": "Más agentes y las instrucciones completas están disponibles en Configuración > Agentes", + "agents": "Agentes", "watch_tutorial": "Ver tutorial", "watch_tutorial_title": "Aprende a usar Postiz", "watch_tutorial_description": "Mira este breve video para aprender a sacar el máximo provecho de Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 21e7400b69..7c76f97356 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Connecter des agents", "connect_your_ai_agent": "Connectez votre agent IA", "connect_agent_description": "Choisissez l’agent que vous utilisez et laissez-le créer et programmer des publications pour vous", - "agent_access_unavailable": "L’accès agent n’est pas disponible pour votre plan ou votre rôle actuel. Vous pouvez le configurer plus tard dans Paramètres > Développeurs.", + "agent_access_unavailable": "L’accès agent n’est pas disponible pour votre plan ou votre rôle actuel. Vous pouvez le configurer plus tard dans Paramètres > Agents.", "sign_in_no_api_key": "Se connecter avec Postiz (pas de clé API)", "oauth_sign_in_hint": "Votre agent ouvrira une fenêtre de navigateur pour se connecter à Postiz.", "add_to_cursor": "Ajouter au curseur", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "Le connecteur officiel fonctionne aussi avec Postiz auto-hébergé. Lorsqu'on vous demande de vous connecter, choisissez \"Use self-hosted\" et saisissez {{url}} avec votre clé API.", "mcp_onboarding_description": "Donnez à votre agent des outils Postiz pour créer, programmer et gérer des publications", "cli_onboarding_description": "Installez le CLI Postiz et la compétence qui apprend à votre agent à l’utiliser", - "agent_settings_later": "Plus d’agents et les instructions complètes sont disponibles dans Paramètres > Développeurs", + "agent_settings_later": "Plus d’agents et les instructions complètes sont disponibles dans Paramètres > Agents", + "agents": "Agents", "watch_tutorial": "Regarder le tutoriel", "watch_tutorial_title": "Apprenez à utiliser Postiz", "watch_tutorial_description": "Regardez cette courte vidéo pour apprendre à tirer le meilleur parti de Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index 00da584059..a37ab9397e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -722,7 +722,7 @@ "connect_agents": "חברו סוכנים", "connect_your_ai_agent": "חברו את סוכן ה-AI שלכם", "connect_agent_description": "בחרו את הסוכן שאתם משתמשים בו ותנו לו ליצור ולתזמן פוסטים עבורכם", - "agent_access_unavailable": "הגישה לסוכן אינה זמינה בתוכנית או בתפקיד הנוכחיים שלכם. ניתן להגדיר זאת מאוחר יותר בהגדרות > מפתחים.", + "agent_access_unavailable": "הגישה לסוכן אינה זמינה בתוכנית או בתפקיד הנוכחיים שלכם. ניתן להגדיר זאת מאוחר יותר בהגדרות > סוכנים.", "sign_in_no_api_key": "התחברו ל-Postiz (ללא מפתח API)", "oauth_sign_in_hint": "הסוכן שלכם יפתח חלון דפדפן כדי להיכנס ל-Postiz.", "add_to_cursor": "הוסיפו ל-Cursor", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "המחבר הרשמי עובד גם עם Postiz באירוח עצמי. כשתתבקש להתחבר, בחר \"Use self-hosted\" והזן את {{url}} יחד עם מפתח ה-API שלך.", "mcp_onboarding_description": "העניקו לסוכן שלכם כלים של Postiz ליצירת, תזמון וניהול פוסטים", "cli_onboarding_description": "התקינו את Postiz CLI ואת המיומנות שמלמדת את הסוכן איך להשתמש בו", - "agent_settings_later": "סוכנים נוספים והוראות מלאות זמינים תחת הגדרות > מפתחים", + "agent_settings_later": "סוכנים נוספים והוראות מלאות זמינים תחת הגדרות > סוכנים", + "agents": "סוכנים", "watch_tutorial": "צפה במדריך", "watch_tutorial_title": "למד כיצד להשתמש ב-Postiz", "watch_tutorial_description": "צפה בסרטון הקצר הזה ולמד כיצד להפיק את המרב מ-Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index 5e0ffc4b61..cbb416758d 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Collega Agenti", "connect_your_ai_agent": "Collega il tuo agente AI", "connect_agent_description": "Scegli l'agente che usi e lascia che crei e programmi post per te", - "agent_access_unavailable": "L'accesso agli agenti non è disponibile per il tuo attuale piano o ruolo. Puoi configurarlo più tardi in Impostazioni > Sviluppatori.", + "agent_access_unavailable": "L'accesso agli agenti non è disponibile per il tuo attuale piano o ruolo. Puoi configurarlo più tardi in Impostazioni > Agenti.", "sign_in_no_api_key": "Accedi con Postiz (nessuna API key)", "oauth_sign_in_hint": "Il tuo agente aprirà una finestra del browser per accedere a Postiz.", "add_to_cursor": "Aggiungi al Cursor", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "Il connettore ufficiale funziona anche con Postiz self-hosted. Quando ti viene chiesto di accedere, scegli \"Use self-hosted\" e inserisci {{url}} con la tua chiave API.", "mcp_onboarding_description": "Dai al tuo agente gli strumenti Postiz per creare, programmare e gestire post", "cli_onboarding_description": "Installa il Postiz CLI e la skill che insegna al tuo agente come usarlo", - "agent_settings_later": "Altri agenti e istruzioni complete sono disponibili in Impostazioni > Sviluppatori", + "agent_settings_later": "Altri agenti e istruzioni complete sono disponibili in Impostazioni > Agenti", + "agents": "Agenti", "watch_tutorial": "Guarda il tutorial", "watch_tutorial_title": "Scopri come usare Postiz", "watch_tutorial_description": "Guarda questo breve video per imparare a sfruttare al meglio Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index cc041ad3e4..60be7efae1 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -722,7 +722,7 @@ "connect_agents": "エージェントを接続", "connect_your_ai_agent": "AIエージェントを接続", "connect_agent_description": "利用するエージェントを選んで、投稿の作成・スケジュール管理を任せましょう", - "agent_access_unavailable": "現在のプランまたはロールではエージェントアクセスをご利用いただけません。設定 > 開発者 で後から設定することもできます。", + "agent_access_unavailable": "現在のプランまたはロールではエージェントアクセスをご利用いただけません。設定 > エージェント で後から設定することもできます。", "sign_in_no_api_key": "Postizでサインイン(APIキー不要)", "oauth_sign_in_hint": "エージェントがPostizにサインインするためのブラウザウィンドウを開きます。", "add_to_cursor": "カーソルに追加", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "公式コネクタはセルフホストの Postiz でも使えます。サインインを求められたら「Use self-hosted」を選び、{{url}} と API キーを入力してください。", "mcp_onboarding_description": "エージェントにPostizのツールを使わせて、投稿の作成やスケジュール管理をさせましょう。", "cli_onboarding_description": "Postiz CLIと、それを使い方をエージェントに教えるスキルをインストールします。", - "agent_settings_later": "その他のエージェントや詳細な手順は、設定 > 開発者 から利用できます。", + "agent_settings_later": "その他のエージェントや詳細な手順は、設定 > エージェント から利用できます。", + "agents": "エージェント", "watch_tutorial": "チュートリアルを見る", "watch_tutorial_title": "Postizの使い方を学ぶ", "watch_tutorial_description": "この短い動画を見て、Postizを最大限に活用する方法を学びましょう", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index d4606aeec1..bb43ec1dcc 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -722,7 +722,7 @@ "connect_agents": "에이전트 연결", "connect_your_ai_agent": "AI 에이전트 연결", "connect_agent_description": "사용할 에이전트를 선택하면, 게시물을 생성하고 예약할 수 있습니다.", - "agent_access_unavailable": "현재 요금제 또는 역할에서는 에이전트 접근이 불가능합니다. 설정 > 개발자에서 나중에 설정할 수 있습니다.", + "agent_access_unavailable": "현재 요금제 또는 역할에서는 에이전트 접근이 불가능합니다. 설정 > 에이전트에서 나중에 설정할 수 있습니다.", "sign_in_no_api_key": "Postiz로 로그인 (API 키 불필요)", "oauth_sign_in_hint": "에이전트가 Postiz 로그인을 위해 브라우저 창을 엽니다.", "add_to_cursor": "Cursor에 추가", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "공식 커넥터는 자체 호스팅 Postiz에서도 작동합니다. 로그인하라는 메시지가 나타나면 \"Use self-hosted\"를 선택하고 {{url}}과 API 키를 입력하세요.", "mcp_onboarding_description": "에이전트에게 Postiz 도구를 제공해 게시물을 생성, 예약, 관리할 수 있습니다.", "cli_onboarding_description": "Postiz CLI와 에이전트에게 사용법을 알려주는 스킬을 설치하세요.", - "agent_settings_later": "더 많은 에이전트와 전체 안내는 설정 > 개발자에서 볼 수 있습니다.", + "agent_settings_later": "더 많은 에이전트와 전체 안내는 설정 > 에이전트에서 볼 수 있습니다.", + "agents": "에이전트", "watch_tutorial": "튜토리얼 보기", "watch_tutorial_title": "Postiz 사용법 배우기", "watch_tutorial_description": "이 짧은 영상을 통해 Postiz를 최대한 활용하는 방법을 알아보세요", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index f8f7d8a70b..5ffa4c538e 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Conectar Agentes", "connect_your_ai_agent": "Conecte Seu Agente de IA", "connect_agent_description": "Escolha o agente que você usa e deixe-o criar e agendar posts para você", - "agent_access_unavailable": "O acesso ao agente não está disponível para seu plano ou função atual. Você pode configurá-lo mais tarde em Configurações > Desenvolvedores.", + "agent_access_unavailable": "O acesso ao agente não está disponível para seu plano ou função atual. Você pode configurá-lo mais tarde em Configurações > Agentes.", "sign_in_no_api_key": "Entrar com o Postiz (sem chave de API)", "oauth_sign_in_hint": "Seu agente abrirá uma janela do navegador para fazer login no Postiz.", "add_to_cursor": "Adicionar ao Cursor", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "O conector oficial também funciona com o Postiz auto-hospedado. Quando for solicitado o login, escolha \"Use self-hosted\" e informe {{url}} com sua chave de API.", "mcp_onboarding_description": "Dê ao seu agente as ferramentas Postiz para criar, agendar e gerenciar posts", "cli_onboarding_description": "Instale o Postiz CLI e a skill que ensina seu agente como usá-lo", - "agent_settings_later": "Mais agentes e instruções completas estão disponíveis em Configurações > Desenvolvedores", + "agent_settings_later": "Mais agentes e instruções completas estão disponíveis em Configurações > Agentes", + "agents": "Agentes", "watch_tutorial": "Assistir ao tutorial", "watch_tutorial_title": "Aprenda a usar o Postiz", "watch_tutorial_description": "Assista a este vídeo curto para aprender a tirar o máximo proveito do Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 1a1c4d1388..854c21961b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Подключить агентов", "connect_your_ai_agent": "Подключите вашего ИИ-агента", "connect_agent_description": "Выберите используемого агента, чтобы он мог создавать и планировать публикации за вас", - "agent_access_unavailable": "Доступ к агенту недоступен для вашего текущего тарифа или роли. Позже вы сможете настроить это в Настройки > Разработчики.", + "agent_access_unavailable": "Доступ к агенту недоступен для вашего текущего тарифа или роли. Позже вы сможете настроить это в Настройки > Агенты.", "sign_in_no_api_key": "Войти через Postiz (без API-ключа)", "oauth_sign_in_hint": "Ваш агент откроет окно браузера для входа в Postiz.", "add_to_cursor": "Добавить в Cursor", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "Официальный коннектор работает и с Postiz на собственном сервере. Когда вас попросят войти, выберите \"Use self-hosted\" и введите {{url}} и ваш API-ключ.", "mcp_onboarding_description": "Дайте вашему агенту инструменты Postiz для создания, планирования и управления публикациями", "cli_onboarding_description": "Установите Postiz CLI и навык, который научит вашего агента им пользоваться", - "agent_settings_later": "Больше агентов и полные инструкции доступны в разделе Настройки > Разработчики", + "agent_settings_later": "Больше агентов и полные инструкции доступны в разделе Настройки > Агенты", + "agents": "Агенты", "watch_tutorial": "Смотреть обучение", "watch_tutorial_title": "Узнайте, как пользоваться Postiz", "watch_tutorial_description": "Посмотрите это короткое видео, чтобы узнать, как получить максимум от Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index ba0b054ed4..fd845aa1fe 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Ajanları Bağla", "connect_your_ai_agent": "Yapay Zeka Ajanınızı Bağlayın", "connect_agent_description": "Kullandığınız ajanı seçin ve gönderi oluşturup zamanlamasına izin verin", - "agent_access_unavailable": "Ajan erişimi mevcut planınızda veya rolünüzde mevcut değil. Daha sonra Ayarlar > Geliştiriciler bölümünden kurabilirsiniz.", + "agent_access_unavailable": "Ajan erişimi mevcut planınızda veya rolünüzde mevcut değil. Daha sonra Ayarlar > Ajanlar bölümünden kurabilirsiniz.", "sign_in_no_api_key": "Postiz ile oturum aç (API anahtarı yok)", "oauth_sign_in_hint": "Ajanınız, Postiz'e giriş yapmanız için bir tarayıcı penceresi açacak.", "add_to_cursor": "Imlece Ekle", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "Resmi bağlayıcı, kendi sunucunuzda barındırdığınız Postiz ile de çalışır. Giriş yapmanız istendiğinde \"Use self-hosted\" seçeneğini seçin ve API anahtarınızla birlikte {{url}} adresini girin.", "mcp_onboarding_description": "Ajanınıza gönderi oluşturma, zamanlama ve yönetme araçları verin", "cli_onboarding_description": "Postiz CLI ve ajanınıza bunun nasıl kullanılacağını öğreten beceriyi kurun", - "agent_settings_later": "Daha fazla ajan ve tam talimatlar Ayarlar > Geliştiriciler bölümünde mevcuttur", + "agent_settings_later": "Daha fazla ajan ve tam talimatlar Ayarlar > Ajanlar bölümünde mevcuttur", + "agents": "Ajanlar", "watch_tutorial": "Eğitimi İzle", "watch_tutorial_title": "Postiz'i Nasıl Kullanacağınızı Öğrenin", "watch_tutorial_description": "Postiz'den en iyi şekilde yararlanmayı öğrenmek için bu kısa videoyu izleyin", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 6510e18f6a..b138b342f3 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -722,7 +722,7 @@ "connect_agents": "Kết nối Agent", "connect_your_ai_agent": "Kết nối Agent AI của bạn", "connect_agent_description": "Chọn Agent bạn sử dụng và để nó tạo, lên lịch bài đăng cho bạn", - "agent_access_unavailable": "Truy cập Agent không khả dụng với gói hoặc vai trò hiện tại của bạn. Bạn có thể thiết lập sau tại Cài đặt > Nhà phát triển.", + "agent_access_unavailable": "Truy cập Agent không khả dụng với gói hoặc vai trò hiện tại của bạn. Bạn có thể thiết lập sau tại Cài đặt > Agent.", "sign_in_no_api_key": "Đăng nhập với Postiz (không cần API key)", "oauth_sign_in_hint": "Agent của bạn sẽ mở một cửa sổ trình duyệt để đăng nhập vào Postiz.", "add_to_cursor": "Thêm vào Cursor", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "Trình kết nối chính thức cũng hoạt động với Postiz tự lưu trữ. Khi được yêu cầu đăng nhập, hãy chọn \"Use self-hosted\" và nhập {{url}} cùng khóa API của bạn.", "mcp_onboarding_description": "Cung cấp cho Agent của bạn các công cụ của Postiz để tạo, lên lịch và quản lý bài đăng", "cli_onboarding_description": "Cài đặt Postiz CLI và kỹ năng hướng dẫn Agent của bạn cách sử dụng nó", - "agent_settings_later": "Thêm nhiều Agent và hướng dẫn đầy đủ có tại Cài đặt > Nhà phát triển", + "agent_settings_later": "Thêm nhiều Agent và hướng dẫn đầy đủ có tại Cài đặt > Agent", + "agents": "Agent", "watch_tutorial": "Xem hướng dẫn", "watch_tutorial_title": "Tìm hiểu cách sử dụng Postiz", "watch_tutorial_description": "Xem video ngắn này để biết cách tận dụng tối đa Postiz", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 0b3235c94e..c72dc72ec4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -722,7 +722,7 @@ "connect_agents": "连接代理", "connect_your_ai_agent": "连接您的 AI 代理", "connect_agent_description": "选择您使用的代理,让它为您创建和安排帖子", - "agent_access_unavailable": "您的当前套餐或角色无法使用代理访问。您可以稍后在 设置 > 开发者 中进行设置。", + "agent_access_unavailable": "您的当前套餐或角色无法使用代理访问。您可以稍后在 设置 > 代理 中进行设置。", "sign_in_no_api_key": "使用 Postiz 登录(无需 API 密钥)", "oauth_sign_in_hint": "您的代理将会打开一个浏览器窗口用于登录 Postiz。", "add_to_cursor": "添加到光标", @@ -740,7 +740,8 @@ "connector_self_hosted_description": "官方连接器同样适用于自托管的 Postiz。在要求登录时,选择“Use self-hosted”,然后输入 {{url}} 和你的 API 密钥。", "mcp_onboarding_description": "为您的代理提供 Postiz 工具,以创建、安排和管理帖子", "cli_onboarding_description": "安装 Postiz CLI 以及教您的代理如何使用的技能", - "agent_settings_later": "更多代理和完整说明可在 设置 > 开发者 下查看", + "agent_settings_later": "更多代理和完整说明可在 设置 > 代理 下查看", + "agents": "代理", "watch_tutorial": "观看教程", "watch_tutorial_title": "学习如何使用Postiz", "watch_tutorial_description": "观看这个简短的视频,学习如何最大程度地利用Postiz", From 5ac6e10f5eecc56ea52e3502a1b1627c339b0756 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 8 Oct 2026 17:00:50 +0700 Subject: [PATCH 88/99] feat: fix icon --- .../src/components/agents/agent.chat.tsx | 2 +- .../components/layout/settings.component.tsx | 5 +++- .../oauth/self.hosted.component.tsx | 6 ++--- .../platform-analytics/platform.analytics.tsx | 2 +- .../public-api/public.component.tsx | 23 +++---------------- i18n.lock | 2 +- .../src/chat/mcp.relay.service.ts | 4 ++-- .../src/dtos/oauth/authorize-oauth.dto.ts | 4 ++-- .../translation/locales/ar/translation.json | 2 +- .../translation/locales/bn/translation.json | 8 +++---- .../translation/locales/de/translation.json | 2 +- .../translation/locales/en/translation.json | 2 +- .../translation/locales/es/translation.json | 2 +- .../translation/locales/fr/translation.json | 2 +- .../translation/locales/he/translation.json | 2 +- .../translation/locales/it/translation.json | 2 +- .../translation/locales/ja/translation.json | 2 +- .../translation/locales/ko/translation.json | 2 +- .../translation/locales/pt/translation.json | 2 +- .../translation/locales/ru/translation.json | 2 +- .../translation/locales/tr/translation.json | 2 +- .../translation/locales/vi/translation.json | 2 +- .../translation/locales/zh/translation.json | 2 +- 23 files changed, 35 insertions(+), 49 deletions(-) diff --git a/apps/frontend/src/components/agents/agent.chat.tsx b/apps/frontend/src/components/agents/agent.chat.tsx index 80168237a7..4f75781cdc 100644 --- a/apps/frontend/src/components/agents/agent.chat.tsx +++ b/apps/frontend/src/components/agents/agent.chat.tsx @@ -82,7 +82,7 @@ You can select the channels you want to use from the left menu. You can see your previous conversations from the right menu. -You can also use me as an MCP Server, check Settings >> Public API +You can also use me as an MCP Server, check Settings >> Agents `), }} UserMessage={Message} diff --git a/apps/frontend/src/components/layout/settings.component.tsx b/apps/frontend/src/components/layout/settings.component.tsx index 9c5460473b..bed706789e 100644 --- a/apps/frontend/src/components/layout/settings.component.tsx +++ b/apps/frontend/src/components/layout/settings.component.tsx @@ -232,7 +232,10 @@ export const SettingsPopup: FC<{
    diff --git a/apps/frontend/src/components/oauth/self.hosted.component.tsx b/apps/frontend/src/components/oauth/self.hosted.component.tsx index 34fbba619c..0430980139 100644 --- a/apps/frontend/src/components/oauth/self.hosted.component.tsx +++ b/apps/frontend/src/components/oauth/self.hosted.component.tsx @@ -107,13 +107,13 @@ export const OAuthSelfHosted: FC<{ removeError={true} value={apiKey} onChange={(e) => setApiKey(e.target.value)} - placeholder="Settings > Agents (or Developers) on your instance" + placeholder="Settings > Agents on your instance" autoComplete="off" spellCheck={false} />
    - You can also paste the MCP URL from Settings > Agents (Settings > - Developers on older versions) in the Instance URL, it includes the key. + You can also paste the MCP URL from Settings > Agents (Developers or + Public API on older versions) in the Instance URL, it includes the key.
    diff --git a/apps/frontend/src/components/platform-analytics/platform.analytics.tsx b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx index 4234c9f639..57e6c42b98 100644 --- a/apps/frontend/src/components/platform-analytics/platform.analytics.tsx +++ b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx @@ -240,7 +240,7 @@ export const PlatformAnalytics = () => { {(integration.inBetweenSteps || integration.refreshNeeded) && (
    -
    +
    !
    diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index 038bfcefd4..5d0490892e 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, useCallback, useMemo } from 'react'; -import useSWR, { useSWRConfig } from 'swr'; +import { useState, useCallback } from 'react'; +import { useSWRConfig } from 'swr'; import { useUser } from '../layout/user.context'; import copy from 'copy-to-clipboard'; import { useToaster } from '@gitroom/react/toaster/toaster'; @@ -923,27 +923,10 @@ const PublicApiContent = () => { export const PublicComponent = () => { const t = useT(); - const fetch = useFetch(); - const user = useUser(); - const loadOrganizations = useCallback(async () => { - return await (await fetch('/user/organizations')).json(); - }, []); - const { data: organizations } = useSWR('organizations', loadOrganizations, { - revalidateIfStale: false, - revalidateOnFocus: false, - refreshWhenOffline: false, - refreshWhenHidden: false, - revalidateOnReconnect: false, - }); - const currentOrg = useMemo(() => { - return organizations?.find((org: any) => org?.id === user?.orgId); - }, [organizations, user?.orgId]); return (
    -

    - {t('agents', 'Agents')} -

    +

    {t('agents', 'Agents')}

    ); diff --git a/i18n.lock b/i18n.lock index 55f9e38122..760fb34a82 100644 --- a/i18n.lock +++ b/i18n.lock @@ -575,7 +575,7 @@ checksums: select_channels: 3aab82dc90ccafe55059c32c5a33c222 start_a_new_chat: 65b9067206fa4e4929d48ce242d879a6 your_assistant: bd6422d1c2a2ba461e1da169b082bf7b - agent_welcome_message: 21c75c6cdb64a5e2d4d9ac6a9b04b7ad + agent_welcome_message: 6a9eabbe5f318e2b8047020fabd31858 last_github_trending: 893235fc69e91abe15960108dcd77d7b next_predicted_github_trending: 2e718a5c1acdad0c058440d0a70a0ea6 repository: 28c2debe822a225389ff8710934abff4 diff --git a/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts b/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts index 8cda77141f..34a089df2c 100644 --- a/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts +++ b/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts @@ -108,7 +108,7 @@ export class McpRelayService { ); } - // The MCP URL from Settings > Developers carries the API key: + // The MCP URL from Settings > Agents carries the API key: // /mcp/ const fromSettings = url.pathname.match(/^(.*?)\/mcp\/([^/]+)\/?$/); const key = @@ -185,7 +185,7 @@ export class McpRelayService { } throw new HttpException( - `Could not find a Postiz MCP server at ${url.origin}. Paste the MCP URL from Settings > Agents (Settings > Developers on older versions) of your instance, and make sure it runs Postiz v2.19.0 or newer`, + `Could not find a Postiz MCP server at ${url.origin}. Paste the MCP URL from Settings > Agents (Developers or Public API on older versions) of your instance, and make sure it runs Postiz v2.19.0 or newer`, HttpStatus.BAD_REQUEST ); } diff --git a/libraries/nestjs-libraries/src/dtos/oauth/authorize-oauth.dto.ts b/libraries/nestjs-libraries/src/dtos/oauth/authorize-oauth.dto.ts index bb80847c6e..393391f2ed 100644 --- a/libraries/nestjs-libraries/src/dtos/oauth/authorize-oauth.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/oauth/authorize-oauth.dto.ts @@ -102,13 +102,13 @@ export class AuthorizeSelfHostedDto { resource?: string; // The instance as the person knows it: its address, its backend address or - // the MCP URL from Settings > Developers (resolved in McpRelayService) + // the MCP URL from Settings > Agents (resolved in McpRelayService) @IsString() @IsDefined() @MaxLength(2048) instance_url: string; - // Optional because the MCP URL from Settings > Developers carries the key + // Optional because the MCP URL from Settings > Agents carries the key @IsString() @IsOptional() @MaxLength(512) diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index e18c714cb6..8093e3ad37 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -571,7 +571,7 @@ "select_channels": "اختر القنوات", "start_a_new_chat": "ابدأ محادثة جديدة", "your_assistant": "مساعدك", - "agent_welcome_message": "مرحبًا، أنا وكيل Postiz الخاص بك 🙌🏻.\n\nيمكنني جدولة منشور أو عدة منشورات لعدة قنوات، وتوليد الصور والفيديوهات.\n\nيمكنك اختيار القنوات التي تريد استخدامها من القائمة اليسرى.\n\nيمكنك رؤية محادثاتك السابقة من القائمة اليمنى.\n\nيمكنك أيضًا استخدامي كخادم MCP، تحقق من الإعدادات >> واجهة برمجة التطبيقات العامة (Public API)", + "agent_welcome_message": "مرحبًا، أنا وكيل Postiz الخاص بك 🙌🏻.\n\nيمكنني جدولة منشور أو عدة منشورات لعدة قنوات، وتوليد الصور والفيديوهات.\n\nيمكنك اختيار القنوات التي تريد استخدامها من القائمة اليسرى.\n\nيمكنك رؤية محادثاتك السابقة من القائمة اليمنى.\n\nيمكنك أيضًا استخدامي كخادم MCP، تحقق من الإعدادات >> الوكلاء", "last_github_trending": "آخر الترندات على Github", "next_predicted_github_trending": "الترند المتوقع القادم على Github", "repository": "المستودع", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 653a6eea17..87bf052e31 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -571,7 +571,7 @@ "select_channels": "চ্যানেল নির্বাচন করুন", "start_a_new_chat": "নতুন চ্যাট শুরু করুন", "your_assistant": "আপনার সহকারী", - "agent_welcome_message": "হ্যালো, আমি আপনার Postiz এজেন্ট 🙌🏻।\n\nআমি এক বা একাধিক চ্যানেলে পোস্ট নির্ধারণ করতে পারি এবং ছবি ও ভিডিও তৈরি করতে পারি।\n\nআপনি বাম মেনু থেকে আপনার পছন্দের চ্যানেলগুলো নির্বাচন করতে পারেন।\n\nডান মেনু থেকে আপনি আপনার পূর্ববর্তী কথোপকথন দেখতে পারবেন।\n\nআপনি আমাকে MCP সার্ভার হিসেবেও ব্যবহার করতে পারেন, সেটিংস >> পাবলিক API দেখুন।", + "agent_welcome_message": "হ্যালো, আমি আপনার Postiz এজেন্ট 🙌🏻।\n\nআমি এক বা একাধিক চ্যানেলে পোস্ট নির্ধারণ করতে পারি এবং ছবি ও ভিডিও তৈরি করতে পারি।\n\nআপনি বাম মেনু থেকে আপনার পছন্দের চ্যানেলগুলো নির্বাচন করতে পারেন।\n\nডান মেনু থেকে আপনি আপনার পূর্ববর্তী কথোপকথন দেখতে পারবেন।\n\nআপনি আমাকে MCP সার্ভার হিসেবেও ব্যবহার করতে পারেন, সেটিংস >> এজেন্ট দেখুন।", "last_github_trending": "সর্বশেষ গিটহাব ট্রেন্ডিং", "next_predicted_github_trending": "পরবর্তী অনুমানকৃত গিটহাব ট্রেন্ডিং", "repository": "রিপোজিটরি", @@ -722,7 +722,7 @@ "connect_agents": "এজেন্ট সংযুক্ত করুন", "connect_your_ai_agent": "আপনার AI এজেন্ট সংযুক্ত করুন", "connect_agent_description": "আপনার ব্যবহৃত এজেন্টটি নির্বাচন করুন এবং তাকে পোস্ট তৈরি ও নির্ধারিত করতে দিন", - "agent_access_unavailable": "আপনার বর্তমান প্ল্যান বা ভূমিকার জন্য এজেন্ট অ্যাক্সেস উপলব্ধ নয়। আপনি পরে সেটিংস > এজেন্টস-এ সেট আপ করতে পারেন।", + "agent_access_unavailable": "আপনার বর্তমান প্ল্যান বা ভূমিকার জন্য এজেন্ট অ্যাক্সেস উপলব্ধ নয়। আপনি পরে সেটিংস > এজেন্ট-এ সেট আপ করতে পারেন।", "sign_in_no_api_key": "Postiz-এ সাইন ইন করুন (কোনো API কী ছাড়াই)", "oauth_sign_in_hint": "আপনার এজেন্ট Postiz-এ সাইন ইন করার জন্য একটি ব্রাউজার উইন্ডো খুলবে।", "add_to_cursor": "কার্সারে যোগ করুন", @@ -740,8 +740,8 @@ "connector_self_hosted_description": "অফিসিয়াল কানেক্টর সেলফ-হোস্টেড Postiz-এর সাথেও কাজ করে। সাইন ইন করতে বলা হলে, \"Use self-hosted\" বেছে নিন এবং আপনার API কী সহ {{url}} লিখুন।", "mcp_onboarding_description": "আপনার এজেন্টকে Postiz টুল দিন যাতে সে পোস্ট তৈরি, নির্ধারণ এবং পরিচালনা করতে পারে", "cli_onboarding_description": "Postiz CLI এবং সেই স্কিল ইনস্টল করুন যা আপনার এজেন্টকে এটি ব্যবহার করতে শেখায়", - "agent_settings_later": "আরও এজেন্ট এবং সম্পূর্ণ নির্দেশিকা সেটিংস > এজেন্টস-এ পাওয়া যাবে", - "agents": "এজেন্টস", + "agent_settings_later": "আরও এজেন্ট এবং সম্পূর্ণ নির্দেশিকা সেটিংস > এজেন্ট-এ পাওয়া যাবে", + "agents": "এজেন্ট", "watch_tutorial": "টিউটোরিয়াল দেখুন", "watch_tutorial_title": "Postiz ব্যবহারের উপায় শিখুন", "watch_tutorial_description": "Postiz সর্বোচ্চভাবে কাজে লাগাতে এই সংক্ষিপ্ত ভিডিওটি দেখুন", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 81040eb8ba..1527494727 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -571,7 +571,7 @@ "select_channels": "Kanäle auswählen", "start_a_new_chat": "Neuen Chat starten", "your_assistant": "Ihr Assistent", - "agent_welcome_message": "Hallo, ich bin Ihr Postiz-Agent 🙌🏻.\n\nIch kann einen oder mehrere Beiträge für mehrere Kanäle planen und Bilder sowie Videos generieren.\n\nSie können die gewünschten Kanäle im linken Menü auswählen.\n\nIhre bisherigen Unterhaltungen finden Sie im rechten Menü.\n\nSie können mich auch als MCP-Server verwenden, siehe Einstellungen >> Öffentliche API.", + "agent_welcome_message": "Hallo, ich bin Ihr Postiz-Agent 🙌🏻.\n\nIch kann einen oder mehrere Beiträge für mehrere Kanäle planen und Bilder sowie Videos generieren.\n\nSie können die gewünschten Kanäle im linken Menü auswählen.\n\nIhre bisherigen Unterhaltungen finden Sie im rechten Menü.\n\nSie können mich auch als MCP-Server verwenden, siehe Einstellungen >> Agenten.", "last_github_trending": "Letzte Github-Trends", "next_predicted_github_trending": "Nächste vorhergesagte Github-Trends", "repository": "Repository", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 2d15596e5b..0348deb5e3 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -571,7 +571,7 @@ "select_channels": "Select Channels", "start_a_new_chat": "Start a new chat", "your_assistant": "Your Assistant", - "agent_welcome_message": "Hello, I am your Postiz agent 🙌🏻.\n\nI can schedule a post or multiple posts to multiple channels and generate pictures and videos.\n\nYou can select the channels you want to use from the left menu.\n\nYou can see your previous conversations from the right menu.\n\nYou can also use me as an MCP Server, check Settings >> Public API", + "agent_welcome_message": "Hello, I am your Postiz agent 🙌🏻.\n\nI can schedule a post or multiple posts to multiple channels and generate pictures and videos.\n\nYou can select the channels you want to use from the left menu.\n\nYou can see your previous conversations from the right menu.\n\nYou can also use me as an MCP Server, check Settings >> Agents", "last_github_trending": "Last Github Trending", "next_predicted_github_trending": "Next Predicted GitHub Trending", "repository": "Repository", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index b05cb6faca..ae12852a33 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -571,7 +571,7 @@ "select_channels": "Seleccionar canales", "start_a_new_chat": "Iniciar un nuevo chat", "your_assistant": "Tu Asistente", - "agent_welcome_message": "Hola, soy tu agente de Postiz 🙌🏻.\n\nPuedo programar una publicación o varias publicaciones en múltiples canales y generar imágenes y videos.\n\nPuedes seleccionar los canales que deseas usar desde el menú de la izquierda.\n\nPuedes ver tus conversaciones anteriores desde el menú de la derecha.\n\nTambién puedes usarme como un servidor MCP, revisa Configuración >> API Pública", + "agent_welcome_message": "Hola, soy tu agente de Postiz 🙌🏻.\n\nPuedo programar una publicación o varias publicaciones en múltiples canales y generar imágenes y videos.\n\nPuedes seleccionar los canales que deseas usar desde el menú de la izquierda.\n\nPuedes ver tus conversaciones anteriores desde el menú de la derecha.\n\nTambién puedes usarme como un servidor MCP, revisa Configuración >> Agentes", "last_github_trending": "Últimas tendencias de Github", "next_predicted_github_trending": "Próximas tendencias previstas de GitHub", "repository": "Repositorio", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 7c76f97356..993c81a437 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -571,7 +571,7 @@ "select_channels": "Sélectionner les canaux", "start_a_new_chat": "Démarrer une nouvelle conversation", "your_assistant": "Votre assistant", - "agent_welcome_message": "Bonjour, je suis votre agent Postiz 🙌🏻.\n\nJe peux programmer une ou plusieurs publications sur plusieurs canaux et générer des images et des vidéos.\n\nVous pouvez sélectionner les canaux que vous souhaitez utiliser dans le menu de gauche.\n\nVous pouvez voir vos conversations précédentes dans le menu de droite.\n\nVous pouvez également m'utiliser comme serveur MCP, consultez Paramètres >> API publique", + "agent_welcome_message": "Bonjour, je suis votre agent Postiz 🙌🏻.\n\nJe peux programmer une ou plusieurs publications sur plusieurs canaux et générer des images et des vidéos.\n\nVous pouvez sélectionner les canaux que vous souhaitez utiliser dans le menu de gauche.\n\nVous pouvez voir vos conversations précédentes dans le menu de droite.\n\nVous pouvez également m'utiliser comme serveur MCP, consultez Paramètres >> Agents", "last_github_trending": "Dernières tendances GitHub", "next_predicted_github_trending": "Prochaines tendances GitHub prévues", "repository": "Dépôt", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index a37ab9397e..9582b54a65 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -571,7 +571,7 @@ "select_channels": "בחר ערוצים", "start_a_new_chat": "התחל שיחה חדשה", "your_assistant": "העוזר שלך", - "agent_welcome_message": "שלום, אני הסוכן של Postiz שלך 🙌🏻.\n\nאני יכול לתזמן פוסט או מספר פוסטים למספר ערוצים וליצור תמונות וסרטונים.\n\nאתה יכול לבחור את הערוצים שברצונך להשתמש בהם מהתפריט השמאלי.\n\nאתה יכול לראות את השיחות הקודמות שלך מהתפריט הימני.\n\nאתה יכול גם להשתמש בי כשרת MCP, בדוק הגדרות >> API ציבורי", + "agent_welcome_message": "שלום, אני הסוכן של Postiz שלך 🙌🏻.\n\nאני יכול לתזמן פוסט או מספר פוסטים למספר ערוצים וליצור תמונות וסרטונים.\n\nאתה יכול לבחור את הערוצים שברצונך להשתמש בהם מהתפריט השמאלי.\n\nאתה יכול לראות את השיחות הקודמות שלך מהתפריט הימני.\n\nאתה יכול גם להשתמש בי כשרת MCP, בדוק הגדרות >> סוכנים", "last_github_trending": "הטרנדים האחרונים ב-GitHub", "next_predicted_github_trending": "הטרנד הבא הצפוי ב-GitHub", "repository": "מאגר", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index cbb416758d..b08a83cec7 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -571,7 +571,7 @@ "select_channels": "Seleziona canali", "start_a_new_chat": "Inizia una nuova chat", "your_assistant": "Il tuo assistente", - "agent_welcome_message": "Ciao, sono il tuo agente Postiz 🙌🏻.\n\nPosso programmare uno o più post su più canali e generare immagini e video.\n\nPuoi selezionare i canali che vuoi usare dal menu a sinistra.\n\nPuoi vedere le tue conversazioni precedenti dal menu a destra.\n\nPuoi anche usarmi come server MCP, vai su Impostazioni >> API pubblica", + "agent_welcome_message": "Ciao, sono il tuo agente Postiz 🙌🏻.\n\nPosso programmare uno o più post su più canali e generare immagini e video.\n\nPuoi selezionare i canali che vuoi usare dal menu a sinistra.\n\nPuoi vedere le tue conversazioni precedenti dal menu a destra.\n\nPuoi anche usarmi come server MCP, vai su Impostazioni >> Agenti", "last_github_trending": "Ultime tendenze su Github", "next_predicted_github_trending": "Prossime tendenze previste su GitHub", "repository": "Repository", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 60be7efae1..414212a0da 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -571,7 +571,7 @@ "select_channels": "チャンネルを選択", "start_a_new_chat": "新しいチャットを開始", "your_assistant": "あなたのアシスタント", - "agent_welcome_message": "こんにちは、私はあなたのPostizエージェントです🙌🏻。\n\n複数のチャンネルに投稿や複数の投稿をスケジュールしたり、画像や動画を生成したりできます。\n\n左側のメニューから使用したいチャンネルを選択できます。\n\n右側のメニューから過去の会話を見ることができます。\n\nまた、私をMCPサーバーとしても利用できます。設定 >> パブリックAPI をご確認ください。", + "agent_welcome_message": "こんにちは、私はあなたのPostizエージェントです🙌🏻。\n\n複数のチャンネルに投稿や複数の投稿をスケジュールしたり、画像や動画を生成したりできます。\n\n左側のメニューから使用したいチャンネルを選択できます。\n\n右側のメニューから過去の会話を見ることができます。\n\nまた、私をMCPサーバーとしても利用できます。設定 >> エージェント をご確認ください。", "last_github_trending": "最新のGitHubトレンド", "next_predicted_github_trending": "次に予測されるGitHubトレンド", "repository": "リポジトリ", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index bb43ec1dcc..4a34e7895b 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -571,7 +571,7 @@ "select_channels": "채널 선택", "start_a_new_chat": "새 채팅 시작", "your_assistant": "당신의 어시스턴트", - "agent_welcome_message": "안녕하세요, 저는 Postiz 에이전트입니다 🙌🏻.\n\n저는 하나 또는 여러 개의 게시물을 여러 채널에 예약 게시할 수 있고, 사진과 동영상을 생성할 수 있습니다.\n\n왼쪽 메뉴에서 사용하고 싶은 채널을 선택할 수 있습니다.\n\n오른쪽 메뉴에서 이전 대화를 볼 수 있습니다.\n\n또한 저를 MCP 서버로 사용할 수 있으며, 설정 >> 공개 API에서 확인할 수 있습니다.", + "agent_welcome_message": "안녕하세요, 저는 Postiz 에이전트입니다 🙌🏻.\n\n저는 하나 또는 여러 개의 게시물을 여러 채널에 예약 게시할 수 있고, 사진과 동영상을 생성할 수 있습니다.\n\n왼쪽 메뉴에서 사용하고 싶은 채널을 선택할 수 있습니다.\n\n오른쪽 메뉴에서 이전 대화를 볼 수 있습니다.\n\n또한 저를 MCP 서버로 사용할 수 있으며, 설정 >> 에이전트에서 확인할 수 있습니다.", "last_github_trending": "최근 깃허브 트렌딩", "next_predicted_github_trending": "다음 예측 깃허브 트렌딩", "repository": "저장소", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 5ffa4c538e..24e327e644 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -571,7 +571,7 @@ "select_channels": "Selecionar canais", "start_a_new_chat": "Iniciar um novo chat", "your_assistant": "Seu Assistente", - "agent_welcome_message": "Olá, sou seu agente Postiz 🙌🏻.\n\nPosso agendar uma publicação ou várias publicações para múltiplos canais e gerar imagens e vídeos.\n\nVocê pode selecionar os canais que deseja usar no menu à esquerda.\n\nVocê pode ver suas conversas anteriores no menu à direita.\n\nVocê também pode me usar como um Servidor MCP, confira Configurações >> API Pública", + "agent_welcome_message": "Olá, sou seu agente Postiz 🙌🏻.\n\nPosso agendar uma publicação ou várias publicações para múltiplos canais e gerar imagens e vídeos.\n\nVocê pode selecionar os canais que deseja usar no menu à esquerda.\n\nVocê pode ver suas conversas anteriores no menu à direita.\n\nVocê também pode me usar como um Servidor MCP, confira Configurações >> Agentes", "last_github_trending": "Últimas tendências do Github", "next_predicted_github_trending": "Próxima tendência prevista do GitHub", "repository": "Repositório", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 854c21961b..c150b404ce 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -571,7 +571,7 @@ "select_channels": "Выберите каналы", "start_a_new_chat": "Начать новый чат", "your_assistant": "Ваш помощник", - "agent_welcome_message": "Здравствуйте, я ваш агент Postiz 🙌🏻.\n\nЯ могу запланировать публикацию одного или нескольких постов в нескольких каналах, а также сгенерировать изображения и видео.\n\nВы можете выбрать нужные каналы в левом меню.\n\nВаши предыдущие разговоры доступны в правом меню.\n\nТакже вы можете использовать меня как MCP Server, проверьте Настройки >> Публичный API", + "agent_welcome_message": "Здравствуйте, я ваш агент Postiz 🙌🏻.\n\nЯ могу запланировать публикацию одного или нескольких постов в нескольких каналах, а также сгенерировать изображения и видео.\n\nВы можете выбрать нужные каналы в левом меню.\n\nВаши предыдущие разговоры доступны в правом меню.\n\nТакже вы можете использовать меня как MCP Server, проверьте Настройки >> Агенты", "last_github_trending": "Последние тренды на Github", "next_predicted_github_trending": "Следующий прогнозируемый тренд на GitHub", "repository": "Репозиторий", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index fd845aa1fe..a99213513c 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -571,7 +571,7 @@ "select_channels": "Kanalları Seç", "start_a_new_chat": "Yeni bir sohbet başlat", "your_assistant": "Asistanınız", - "agent_welcome_message": "Merhaba, ben Postiz ajanınızım 🙌🏻.\n\nBir veya birden fazla gönderiyi birden fazla kanala zamanlayabilir, resim ve video oluşturabilirim.\n\nKullanmak istediğiniz kanalları sol menüden seçebilirsiniz.\n\nÖnceki konuşmalarınızı sağ menüden görebilirsiniz.\n\nAyrıca beni bir MCP Sunucusu olarak da kullanabilirsiniz, Ayarlar >> Genel API bölümünü kontrol edin.", + "agent_welcome_message": "Merhaba, ben Postiz ajanınızım 🙌🏻.\n\nBir veya birden fazla gönderiyi birden fazla kanala zamanlayabilir, resim ve video oluşturabilirim.\n\nKullanmak istediğiniz kanalları sol menüden seçebilirsiniz.\n\nÖnceki konuşmalarınızı sağ menüden görebilirsiniz.\n\nAyrıca beni bir MCP Sunucusu olarak da kullanabilirsiniz, Ayarlar >> Ajanlar bölümünü kontrol edin.", "last_github_trending": "Son Github Trendleri", "next_predicted_github_trending": "Sonraki Tahmini Github Trendleri", "repository": "Depo", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index b138b342f3..972cb2a822 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -571,7 +571,7 @@ "select_channels": "Chọn kênh", "start_a_new_chat": "Bắt đầu cuộc trò chuyện mới", "your_assistant": "Trợ lý của bạn", - "agent_welcome_message": "Xin chào, tôi là trợ lý Postiz của bạn 🙌🏻.\n\nTôi có thể lên lịch đăng một hoặc nhiều bài lên nhiều kênh khác nhau và tạo hình ảnh, video.\n\nBạn có thể chọn các kênh muốn sử dụng từ menu bên trái.\n\nBạn có thể xem các cuộc trò chuyện trước đó từ menu bên phải.\n\nBạn cũng có thể sử dụng tôi như một MCP Server, kiểm tra Cài đặt >> Public API", + "agent_welcome_message": "Xin chào, tôi là trợ lý Postiz của bạn 🙌🏻.\n\nTôi có thể lên lịch đăng một hoặc nhiều bài lên nhiều kênh khác nhau và tạo hình ảnh, video.\n\nBạn có thể chọn các kênh muốn sử dụng từ menu bên trái.\n\nBạn có thể xem các cuộc trò chuyện trước đó từ menu bên phải.\n\nBạn cũng có thể sử dụng tôi như một MCP Server, kiểm tra Cài đặt >> Agent", "last_github_trending": "Xu hướng Github gần đây", "next_predicted_github_trending": "Dự đoán xu hướng Github tiếp theo", "repository": "Kho lưu trữ", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index c72dc72ec4..5740d0c7b4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -571,7 +571,7 @@ "select_channels": "选择频道", "start_a_new_chat": "开始新聊天", "your_assistant": "你的助手", - "agent_welcome_message": "你好,我是你的 Postiz 代理 🙌🏻。\n\n我可以为多个频道安排一条或多条帖子,并生成图片和视频。\n\n你可以从左侧菜单中选择你想要使用的频道。\n\n你可以从右侧菜单中查看你之前的对话。\n\n你也可以将我用作 MCP 服务器,查看设置 >> 公共 API。", + "agent_welcome_message": "你好,我是你的 Postiz 代理 🙌🏻。\n\n我可以为多个频道安排一条或多条帖子,并生成图片和视频。\n\n你可以从左侧菜单中选择你想要使用的频道。\n\n你可以从右侧菜单中查看你之前的对话。\n\n你也可以将我用作 MCP 服务器,查看设置 >> 代理。", "last_github_trending": "最新 Github 趋势", "next_predicted_github_trending": "下一个预测的 Github 趋势", "repository": "仓库", From 3bd88cee91f149ed1a7916c4365a9c1e753ecf2b Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 8 Oct 2026 18:27:50 +0700 Subject: [PATCH 89/99] feat: professional billing --- .../src/api/routes/billing.controller.ts | 11 + .../billing/billing.history.component.tsx | 428 ++++++++++++++ .../billing/first.billing.component.tsx | 13 +- .../billing/main.billing.component.tsx | 550 ++++++++++++------ i18n.lock | 31 + .../payment/payment.provider.interface.ts | 24 + .../src/services/stripe.service.ts | 45 ++ .../translation/locales/ar/translation.json | 31 + .../translation/locales/bn/translation.json | 31 + .../translation/locales/de/translation.json | 31 + .../translation/locales/en/translation.json | 31 + .../translation/locales/es/translation.json | 31 + .../translation/locales/fr/translation.json | 31 + .../translation/locales/he/translation.json | 31 + .../translation/locales/it/translation.json | 31 + .../translation/locales/ja/translation.json | 31 + .../translation/locales/ko/translation.json | 31 + .../translation/locales/pt/translation.json | 31 + .../translation/locales/ru/translation.json | 31 + .../translation/locales/tr/translation.json | 31 + .../translation/locales/vi/translation.json | 31 + .../translation/locales/zh/translation.json | 31 + 22 files changed, 1382 insertions(+), 185 deletions(-) create mode 100644 apps/frontend/src/components/billing/billing.history.component.tsx diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index e1777e50f2..c168633533 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -20,6 +20,11 @@ import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; import { BillingSyncDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.sync.dto'; +import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability'; +import { + AuthorizationActions, + Sections, +} from '@gitroom/backend/services/auth/permissions/permission.exception.class'; @ApiTags('Billing') @Controller('/billing') @@ -158,6 +163,12 @@ export class BillingController { }; } + @Get('/invoices') + @CheckPolicies([AuthorizationActions.Create, Sections.ADMIN]) + async getInvoices(@GetOrgFromRequest() org: Organization) { + return (await this.provider(org)).getInvoices(org.id); + } + @Get('/') getCurrentBilling(@GetOrgFromRequest() org: Organization) { return this._paymentService.getSubscription(org.id); diff --git a/apps/frontend/src/components/billing/billing.history.component.tsx b/apps/frontend/src/components/billing/billing.history.component.tsx new file mode 100644 index 0000000000..1077a2b69a --- /dev/null +++ b/apps/frontend/src/components/billing/billing.history.component.tsx @@ -0,0 +1,428 @@ +'use client'; + +import React, { FC, useCallback, useMemo, useState } from 'react'; +import useSWR from 'swr'; +import dayjs from 'dayjs'; +import clsx from 'clsx'; +import { capitalize } from 'lodash'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { Select } from '@gitroom/react/form/select'; + +interface Invoice { + id: string; + number: string | null; + tier: string | null; + period: string | null; + description: string | null; + amount: number; + currency: string; + created: number; + periodEnd: number; + status: 'paid' | 'pending' | 'failed' | 'void'; + downloadUrl: string | null; + viewUrl: string | null; +} + +const PAGE_SIZE = 10; + +const useInvoices = () => { + const fetch = useFetch(); + return useSWR( + '/billing/invoices', + async () => { + const response = await fetch('/billing/invoices'); + if (!response.ok) { + throw new Error('Failed to load invoices'); + } + return response.json(); + }, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); +}; + +const formatAmount = (amount: number, currency: string) => { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase(), + }); + return formatter.format( + amount / 10 ** (formatter.resolvedOptions().maximumFractionDigits ?? 2) + ); +}; + +const formatDate = (unix: number | null) => + unix + ? dayjs + .utc(unix * 1000) + .local() + .format('D MMM, YYYY') + : ''; + +const ActionLink: FC<{ + href: string; + label: string; + icon: React.ReactNode; +}> = ({ href, label, icon }) => ( + + {icon} + +); + +export const BillingHistory: FC = () => { + const t = useT(); + const { data, isLoading, error } = useInvoices(); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState('all'); + const [visible, setVisible] = useState(PAGE_SIZE); + + const statuses = useMemo( + () => + ({ + paid: { + label: t('billing_status_paid', 'Paid'), + color: 'text-[#32D583]', + }, + pending: { + label: t('billing_status_open', 'Pending'), + color: 'text-[#FFAC30]', + }, + failed: { + label: t('billing_status_failed', 'Failed'), + color: 'text-[#F97066]', + }, + void: { + label: t('billing_status_void', 'Void'), + color: 'text-textItemBlur', + }, + } as Record), + [t] + ); + + const rows = useMemo( + () => + (data || []).map((invoice) => ({ + ...invoice, + plan: invoice.tier + ? capitalize(invoice.tier) + : invoice.description || t('billing_subscription', 'Subscription'), + periodLabel: + invoice.period === 'YEARLY' + ? t('billing_yearly', 'Yearly') + : invoice.period === 'MONTHLY' + ? t('billing_monthly', 'Monthly') + : '', + amountLabel: formatAmount(invoice.amount, invoice.currency), + purchaseDate: formatDate(invoice.created), + endDate: formatDate(invoice.periodEnd), + statusInfo: statuses[invoice.status] || statuses.void, + })), + [data, statuses, t] + ); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return rows.filter( + (row) => + (status === 'all' || row.status === status) && + (!query || + [ + row.plan, + row.periodLabel, + row.number, + row.amountLabel, + row.purchaseDate, + row.endDate, + row.statusInfo.label, + ].some((value) => value?.toLowerCase().includes(query))) + ); + }, [rows, search, status]); + + const exportCsv = useCallback(() => { + const escape = (value: string | null) => + `"${(value || '').replace(/"/g, '""')}"`; + const lines = [ + [ + t('billing_plan', 'Plan'), + t('billing_amount', 'Amount'), + t('billing_purchase_date', 'Purchase Date'), + t('billing_end_date', 'End Date'), + t('billing_status', 'Status'), + t('billing_invoice', 'Invoice'), + ], + ...filtered.map((row) => [ + [row.plan, row.periodLabel].filter((p) => p).join(' - '), + row.amountLabel, + row.purchaseDate, + row.endDate, + row.statusInfo.label, + row.viewUrl || row.downloadUrl || row.number, + ]), + ]; + const url = URL.createObjectURL( + new Blob( + ['\uFEFF' + lines.map((line) => line.map(escape).join(',')).join('\n')], + { type: 'text/csv;charset=utf-8' } + ) + ); + const link = document.createElement('a'); + link.href = url; + link.download = 'billing-history.csv'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, [filtered, t]); + + const columns = + 'grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_88px] gap-x-[16px] items-center px-[16px]'; + + if (isLoading || (!error && !rows.length)) { + return null; + } + + return ( +
    +
    +
    + {t('billing_history', 'Billing History')} +
    +
    +
    + + + + + { + setSearch(e.target.value); + setVisible(PAGE_SIZE); + }} + placeholder={t('billing_search', 'Search...')} + className="w-full h-[42px] ps-[38px] pe-[12px] rounded-[8px] bg-newBgColorInner border border-newTableBorder text-[14px] outline-none focus:border-[#612BD3]" + /> +
    +
    + +
    + +
    +
    + +
    +
    +
    {t('billing_plan', 'Plan')}
    +
    {t('billing_amount', 'Amount')}
    +
    {t('billing_purchase_date', 'Purchase Date')}
    +
    {t('billing_end_date', 'End Date')}
    +
    {t('billing_status', 'Status')}
    +
    {t('billing_action', 'Action')}
    +
    + + {error || !filtered.length ? ( +
    +
    + + + + +
    +
    + {error + ? t('billing_invoices_failed', 'Could not load your invoices') + : t( + 'billing_no_matching_invoices', + 'No invoices match your search' + )} +
    +
    + ) : ( + filtered.slice(0, visible).map((row) => ( +
    +
    +
    + {row.plan} +
    + {(!!row.periodLabel || !!row.number) && ( +
    + {[row.periodLabel, row.number].filter((p) => p).join(' · ')} +
    + )} +
    +
    + {row.amountLabel} +
    +
    + {row.purchaseDate} + {!!row.endDate && ( + + {' - '} + {row.endDate} + + )} +
    +
    + {row.endDate} +
    +
    + + {row.statusInfo.label} +
    +
    + {!row.downloadUrl ? ( +
    + ) : ( + + + + + + } + /> + )} + {!!row.viewUrl && ( + + + + + } + /> + )} +
    +
    + )) + )} +
    + + {filtered.length > visible && ( + + )} +
    + ); +}; diff --git a/apps/frontend/src/components/billing/first.billing.component.tsx b/apps/frontend/src/components/billing/first.billing.component.tsx index 13cc9cc5df..7cb67048c3 100644 --- a/apps/frontend/src/components/billing/first.billing.component.tsx +++ b/apps/frontend/src/components/billing/first.billing.component.tsx @@ -334,7 +334,7 @@ type FeatureItem = { prefix?: string | number; }; -export const BillingFeatures: FC<{ tier: string }> = ({ tier }) => { +export const useBillingFeatures = (tier: string) => { const t = useT(); const features = useMemo(() => { const currentPricing = pricing[tier]; @@ -412,6 +412,15 @@ export const BillingFeatures: FC<{ tier: string }> = ({ tier }) => { return translatedText; }; + return features.map((feature) => ({ + key: feature.key, + label: renderFeature(feature), + })); +}; + +export const BillingFeatures: FC<{ tier: string }> = ({ tier }) => { + const features = useBillingFeatures(tier); + return (
    {features.map((feature) => ( @@ -430,7 +439,7 @@ export const BillingFeatures: FC<{ tier: string }> = ({ tier }) => { />
    -
    {renderFeature(feature)}
    +
    {feature.label}
    ))}
    diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 43668f9189..65a38f01e3 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Slider } from '@gitroom/react/form/slider'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@gitroom/react/form/button'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; @@ -11,8 +10,11 @@ import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; import { useToaster } from '@gitroom/react/toaster/toaster'; import dayjs from 'dayjs'; import clsx from 'clsx'; +import { capitalize } from 'lodash'; import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; import { FAQComponent } from '@gitroom/frontend/components/billing/faq.component'; +import { useBillingFeatures } from '@gitroom/frontend/components/billing/first.billing.component'; +import { BillingHistory } from '@gitroom/frontend/components/billing/billing.history.component'; import { useSWRConfig } from 'swr'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { useRouter, useSearchParams } from 'next/navigation'; @@ -65,81 +67,182 @@ export const Prorate: FC<{ }, [period, pack]); if (loading) { return ( -
    - -
    + ); } - if (price === false) { + // the price is missing when the preview request fails + if (typeof price !== 'number') { return null; } return ( -
    - ({t('pay_today', 'Pay Today')} ${(price < 0 ? 0 : price)?.toFixed(1)}) -
    + <> + {t('pay_today', 'Pay Today')} ${Math.max(price, 0).toFixed(2)} + ); }; -export const Features: FC<{ - pack: 'FREE' | 'STANDARD' | 'PRO'; + +type PlanAction = { + label: string; + onClick?: () => void; + disabled?: boolean; + variant: 'primary' | 'simple' | 'current' | 'danger'; +}; + +const planButton = { + primary: 'bg-btnPrimary text-white hover:bg-[#5023b8]', + simple: 'bg-btnSimple text-btnText hover:opacity-80', + current: 'bg-newBgLineColor text-textItemBlur', + danger: 'bg-red-500 text-white hover:bg-red-600', +}; + +const PlanCard: FC<{ + name: string; + price: number; + yearly: boolean; + current: boolean; + action: PlanAction; + loading: boolean; + disabled: boolean; + note?: React.ReactNode; }> = (props) => { - const { pack } = props; - const features = useMemo(() => { - const currentPricing = pricing[pack]; - const channelsOr = currentPricing.channel; - const list = []; - list.push(`${channelsOr} ${channelsOr === 1 ? 'channel' : 'channels'}`); - list.push( - `${ - currentPricing.posts_per_month > 10000 - ? 'Unlimited' - : currentPricing.posts_per_month - } posts per month` - ); - if (currentPricing.team_members) { - list.push(`Unlimited team members`); - } - if (currentPricing?.ai) { - list.push(`AI auto-complete`); - list.push(`AI copilots`); - list.push(`AI Autocomplete`); - } - list.push(`Advanced Picture Editor`); - if (currentPricing?.image_generator) { - list.push( - `${currentPricing?.image_generation_count} AI Images per month` - ); - } - if (currentPricing?.generate_videos) { - list.push(`${currentPricing?.generate_videos} AI Videos per month`); - } - if (currentPricing?.clipping_minutes) { - list.push( - `${currentPricing?.clipping_minutes} minutes of AI video clipping per month` - ); - } - return list; - }, [pack]); + const { name, price, yearly, current, action, loading, disabled, note } = + props; + const t = useT(); + const features = useBillingFeatures(name); + return ( -
    - {features.map((feature) => ( -
    -
    +
    + {[ + 'top-[10px] start-[10px]', + 'top-[10px] end-[10px]', + 'bottom-[10px] start-[10px]', + 'bottom-[10px] end-[10px]', + ].map((position) => ( + + ))} +
    +
    {capitalize(name)}
    + {current && ( +
    + + {t('active', 'Active')} +
    + )} +
    +
    +
    + ${price} +
    +
    + {yearly + ? t('billing_per_year', '/ year') + : t('billing_per_month', '/ month')} +
    +
    +
    + + {!!note && ( +
    + {note} +
    + )} +
    +
    +
    + {features.map((feature) => ( +
    + +
    {feature.label}
    -
    {feature}
    -
    - ))} + ))} +
    ); }; @@ -241,6 +344,8 @@ export const MainBillingComponent: FC<{ sub ); const [loading, setLoading] = useState(false); + // which button started the request, so only that one shows a spinner + const [clicked, setClicked] = useState(''); const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>( subscription?.period || 'MONTHLY' ); @@ -455,6 +560,7 @@ export const MainBillingComponent: FC<{ revalidate: false, } ); + mutate('/billing/invoices'); toast.show('Subscription updated successfully'); } setLoading(false); @@ -489,142 +595,220 @@ export const MainBillingComponent: FC<{
    ); } + const yearly = monthlyOrYearly === 'on'; + const tiers = Object.keys(pricing); + const currentTier = subscription?.subscriptionTier || 'FREE'; + const plans = tiers.filter((name) => !isGeneral || name !== 'FREE'); + + const planAction = (name: string): PlanAction => { + if (currentPackage === name && subscription?.cancelAt) { + return { + label: t('reactivate_subscription', 'Reactivate subscription'), + onClick: moveToCheckout('FREE', true), + variant: 'primary', + }; + } + if (currentPackage === name) { + return { + label: t('billing_current_plan', 'Current Plan'), + disabled: true, + variant: 'current', + }; + } + if (name === 'FREE') { + return { + label: subscription?.cancelAt + ? t('billing_downgrade_on', 'Downgrade on {{date}}', { + date: dayjs + .utc(subscription.cancelAt) + .local() + .format('D MMM, YYYY'), + }) + : t('cancel_subscription_1', 'Cancel subscription'), + onClick: moveToCheckout('FREE'), + disabled: !!subscription?.cancelAt, + variant: 'danger', + }; + } + const direction = tiers.indexOf(name) - tiers.indexOf(currentTier); + return { + label: !subscription + ? user?.tier?.current === 'FREE' && user.allowTrial + ? t('start_7_days_free_trial', 'Start 7 days free trial') + : t('billing_purchase', 'Purchase') + : direction > 0 + ? t('billing_upgrade', 'Upgrade') + : direction < 0 + ? t('billing_downgrade', 'Downgrade') + : yearly + ? t('billing_switch_to_yearly', 'Switch to yearly') + : t('billing_switch_to_monthly', 'Switch to monthly'), + onClick: moveToCheckout(name as 'STANDARD' | 'PRO'), + variant: !!subscription && direction < 0 ? 'simple' : 'primary', + }; + }; + return ( -
    -
    -
    {t('plans', 'Plans')}
    -
    -
    {t('monthly', 'MONTHLY')}
    -
    - +
    + {finishTrial && setFinishTrial(false)} />} +
    +
    +
    + {t('billing_and_subscription', 'Billing & Subscription')} +
    +
    + {t( + 'billing_and_subscription_description', + 'Keep track of your subscription, update your payment method and download your invoices' + )} +
    +
    +
    +
    setMonthlyOrYearly('off')} + className={clsx( + 'h-[34px] px-[16px] rounded-[6px] flex items-center justify-center mobile:flex-1', + !yearly ? 'bg-boxFocused text-textItemFocused' : 'cursor-pointer' + )} + > + {t('billing_monthly', 'Monthly')} +
    +
    setMonthlyOrYearly('on')} + className={clsx( + 'h-[34px] px-[16px] rounded-[6px] flex items-center justify-center gap-[8px] mobile:flex-1', + yearly ? 'bg-boxFocused text-textItemFocused' : 'cursor-pointer' + )} + > + {t('billing_yearly', 'Yearly')} +
    + {t('billing_20_percent_off', '20% Off')} +
    -
    {t('yearly', 'YEARLY')}
    - - {finishTrial && setFinishTrial(false)} />} -
    - {Object.entries(pricing) - .filter((f) => !isGeneral || f[0] !== 'FREE') - .map(([name, values]) => ( -
    +
    +
    + {t( + 'your_subscription_will_be_canceled_at', + 'Your subscription will be canceled at' + )}{' '} + + {newDayjs(subscription.cancelAt).local().format('D MMM, YYYY')} + + {'. '} + {t( + 'you_will_never_be_charged_again', + 'You will never be charged again' + )} +
    +
    + )} +
    4 ? 'grid-cols-5' : 'grid-cols-4' + )} + > + {plans.map((name) => { + const action = planAction(name); + return ( + { + setClicked(name); + action.onClick?.(); + }, + }} + loading={loading && clicked === name} + disabled={!!action.disabled || loading} + note={ + !!subscription && + currentPackage !== name && + name !== 'FREE' && ( + + ) + } + /> + ); + })} +
    + {!!subscription?.id && ( +
    +
    + -
    {name}
    -
    -
    - $ - {monthlyOrYearly === 'on' - ? values.year_price - : values.month_price} -
    -
    - {monthlyOrYearly === 'on' ? '/year' : '/month'} -
    -
    -
    - {currentPackage === name.toUpperCase() && - subscription?.cancelAt ? ( -
    -
    - -
    -
    + + + +
    +
    +
    + {t('billing_payment_method', 'Payment method')} +
    +
    + {t( + 'billing_payment_method_description', + 'Update your card, billing address and tax details in the secure billing portal' + )} +
    +
    +
    + {isGeneral && !subscription?.cancelAt && ( + + t('cancel_subscription_1', 'Cancel subscription') )} - {subscription && - currentPackage !== name.toUpperCase() && - name !== 'FREE' && - !!name && ( - - )} -
    - -
    - ))} -
    - {!!subscription?.id && ( -
    - )} - - {isGeneral && !subscription?.cancelAt && ( - - )} -
    - )} - {subscription?.cancelAt && isGeneral && ( -
    - {t( - 'your_subscription_will_be_canceled_at', - 'Your subscription will be canceled at' - )}{' '} - {newDayjs(subscription.cancelAt).local().format('D MMM, YYYY')} -
    - {t( - 'you_will_never_be_charged_again', - 'You will never be charged again' - )} + {t('billing_update_payment_method', 'Update payment method')} + +
    )} + {!!subscription?.id && }
    diff --git a/i18n.lock b/i18n.lock index 760fb34a82..822ed4677e 100644 --- a/i18n.lock +++ b/i18n.lock @@ -572,6 +572,37 @@ checksums: billing_apply: 0f03e8e3752eb120bf8ea0b8f8f5d244 billing_subscription: ba9f3675e18987d067d48533c8897343 billing_cancel_notice: bc2edd49b3c0a51f8e11bb93f56c5571 + billing_and_subscription: 54d901b131228b512d31922bfed6f49e + billing_and_subscription_description: 4458136515c80304af208b3eedc057e9 + billing_current_plan: 7497746eb3b4897ff953b1aa8c7c9f63 + billing_upgrade: 63c3b52882e0d779859307d672c178c2 + billing_downgrade: 91f6c79623592b9dc186159b3b665b65 + billing_switch_to_yearly: 4c70dcc918ed45af62c8374117aa1a16 + billing_switch_to_monthly: dc4ca8b36aa6d3b6fef9a65c9ade75cf + billing_purchase: 8047e4f1fa2c312dde88dc0f7003eb4b + billing_downgrade_on: 23e133b7c3a3098b7a27d3b304708e32 + billing_payment_method: 84bb851608b200dc843c7801218ca039 + billing_payment_method_description: 73fecbfa75b88d901a316dc3b1160c72 + billing_update_payment_method: bd941bb1afc2e2f707529042d9cc45de + billing_history: 2c2802bfaca8774325efb30773ab89a1 + billing_search: fe877a75eac472fc5b188c135c78a558 + billing_all_statuses: 041202586d00c28bcfb30bfd38105e58 + billing_export: 709afbc86c5fdca279a111ea8e49d1c9 + billing_plan: c6a08c771f97d86b02cf10d8f5f2d788 + billing_amount: a0ae9c6ff98b57475cdce56f66066aa4 + billing_purchase_date: 3f197b4ba86da213210683efc5df2326 + billing_end_date: 2d1d5e06cf23156dfc49718d9455dd48 + billing_status: 4e1fcce15854d824919b4a582c697c90 + billing_action: c92af0bdf1698b0d10cf5b28d2ad4945 + billing_invoice: eeda4ece45623519011ea5c83091f431 + billing_status_paid: 5e2fa7af50cd5d877d9c54fd87bb3bb5 + billing_status_open: 030a6f3395d5d4efddd3cc67d6009039 + billing_status_failed: 99f87615af1fffa2b8802866b096705a + billing_status_void: 244428fc68aa883114c61ab85ff8fdd8 + billing_download_invoice: 8c50fd826a446b01ef4fcdb16abef365 + billing_view_invoice: 6362bd0415ff4803ab99c2b454e07940 + billing_no_matching_invoices: d40a4a30fb9ebe36ba7203b961d00de0 + billing_invoices_failed: d1d1391d1091114af3fb946833fc46f2 select_channels: 3aab82dc90ccafe55059c32c5a33c222 start_a_new_chat: 65b9067206fa4e4929d48ce242d879a6 your_assistant: bd6422d1c2a2ba461e1da169b082bf7b diff --git a/libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts b/libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts index be4be9e784..de150efb72 100644 --- a/libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts +++ b/libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts @@ -5,6 +5,25 @@ import { AdminApplyCouponDto } from '@gitroom/nestjs-libraries/dtos/billing/admi export type PaymentPlatform = 'web' | 'mobile'; +// One row of the billing history, in a provider neutral shape +export interface PaymentInvoice { + id: string; + number: string | null; + // pricing key and MONTHLY / YEARLY the invoice was for, when the provider knows it + tier: string | null; + period: string | null; + description: string | null; + // minor units (cents) + amount: number; + currency: string; + // unix seconds + created: number; + periodEnd: number; + status: 'paid' | 'pending' | 'failed' | 'void'; + downloadUrl: string | null; + viewUrl: string | null; +} + // Every billing use-case goes through this contract. Webhooks and `platform` // are mandatory; everything else has a default that says "not supported on // this platform" so a provider only implements what its platform offers @@ -84,6 +103,11 @@ export abstract class PaymentProviderAbstract { return this.notSupported(); } + // Billing history (invoices) of the organization + async getInvoices(organizationId: string): Promise { + return this.notSupported(); + } + // Post-checkout poll: is this subscription id active for the org async checkSubscription( organizationId: string, diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index b46e6529c9..71bf25ed2b 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -12,6 +12,7 @@ import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum'; import { + PaymentInvoice, PaymentPlatform, PaymentProvider, PaymentProviderAbstract, @@ -741,6 +742,50 @@ export class StripeService extends PaymentProviderAbstract { return this.createBillingPortalLink(customer); } + async getInvoices(organizationId: string): Promise { + const customer = await this.getCustomerByOrganizationId(organizationId); + if (!customer || !customer.startsWith('cus_')) { + return []; + } + + const invoices = await stripe.invoices.list({ + customer, + limit: 100, + }); + + return invoices.data + .filter((invoice) => invoice.status !== 'draft') + .map((invoice) => { + // snapshot of the subscription metadata ({ billing, period }) when the invoice was finalized + const metadata = invoice.parent?.subscription_details?.metadata; + return { + id: invoice.id, + number: invoice.number, + tier: metadata?.billing || null, + period: metadata?.period || null, + description: invoice.lines.data[0]?.description || null, + amount: invoice.total, + currency: invoice.currency, + created: invoice.created, + periodEnd: Math.max( + invoice.period_end, + ...invoice.lines.data.map((line) => line.period.end) + ), + // an open invoice that was already attempted is a failed charge waiting for a retry + status: + invoice.status === 'paid' + ? 'paid' + : invoice.status === 'void' + ? 'void' + : invoice.status === 'uncollectible' || invoice.attempted + ? 'failed' + : 'pending', + downloadUrl: invoice.invoice_pdf || null, + viewUrl: invoice.hosted_invoice_url || null, + }; + }); + } + async finishTrial(organization: Organization) { const list = ( await stripe.subscriptions.list({ diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 8093e3ad37..79f255fe9f 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -568,6 +568,37 @@ "billing_apply": "تطبيق", "billing_subscription": "الاشتراك", "billing_cancel_notice": "يمكنك الإلغاء في أي وقت من الإعدادات دون الحاجة للتحدث مع أي شخص ولن يتم خصم أي مبلغ منك.", + "billing_and_subscription": "الفوترة والاشتراك", + "billing_and_subscription_description": "تابع اشتراكك، وحدّث طريقة الدفع، ونزّل فواتيرك", + "billing_current_plan": "الخطة الحالية", + "billing_upgrade": "ترقية", + "billing_downgrade": "تخفيض الخطة", + "billing_switch_to_yearly": "التبديل إلى السنوي", + "billing_switch_to_monthly": "التبديل إلى الشهري", + "billing_purchase": "شراء", + "billing_downgrade_on": "تخفيض الخطة في {{date}}", + "billing_payment_method": "طريقة الدفع", + "billing_payment_method_description": "حدّث بطاقتك وعنوان الفوترة وبيانات الضرائب في بوابة الفوترة الآمنة", + "billing_update_payment_method": "تحديث طريقة الدفع", + "billing_history": "سجل الفوترة", + "billing_search": "بحث...", + "billing_all_statuses": "كل الحالات", + "billing_export": "تصدير", + "billing_plan": "الخطة", + "billing_amount": "المبلغ", + "billing_purchase_date": "تاريخ الشراء", + "billing_end_date": "تاريخ الانتهاء", + "billing_status": "الحالة", + "billing_action": "الإجراء", + "billing_invoice": "الفاتورة", + "billing_status_paid": "مدفوعة", + "billing_status_open": "قيد الانتظار", + "billing_status_failed": "فشلت", + "billing_status_void": "ملغاة", + "billing_download_invoice": "تنزيل الفاتورة", + "billing_view_invoice": "عرض الفاتورة", + "billing_no_matching_invoices": "لا توجد فواتير مطابقة لبحثك", + "billing_invoices_failed": "تعذر تحميل فواتيرك", "select_channels": "اختر القنوات", "start_a_new_chat": "ابدأ محادثة جديدة", "your_assistant": "مساعدك", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 87bf052e31..2432a01c57 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -568,6 +568,37 @@ "billing_apply": "প্রয়োগ করুন", "billing_subscription": "সাবস্ক্রিপশন", "billing_cancel_notice": "সেটিংস থেকে যেকোনো সময় কাউকে না বলেই বাতিল করুন এবং কখনোই চার্জ হবে না।", + "billing_and_subscription": "বিলিং ও সাবস্ক্রিপশন", + "billing_and_subscription_description": "আপনার সাবস্ক্রিপশনের হিসাব রাখুন, পেমেন্ট পদ্ধতি আপডেট করুন এবং ইনভয়েস ডাউনলোড করুন", + "billing_current_plan": "বর্তমান প্ল্যান", + "billing_upgrade": "আপগ্রেড", + "billing_downgrade": "ডাউনগ্রেড", + "billing_switch_to_yearly": "বার্ষিকে পরিবর্তন করুন", + "billing_switch_to_monthly": "মাসিকে পরিবর্তন করুন", + "billing_purchase": "কিনুন", + "billing_downgrade_on": "{{date}} তারিখে ডাউনগ্রেড", + "billing_payment_method": "পেমেন্ট পদ্ধতি", + "billing_payment_method_description": "নিরাপদ বিলিং পোর্টালে আপনার কার্ড, বিলিং ঠিকানা এবং ট্যাক্স তথ্য আপডেট করুন", + "billing_update_payment_method": "পেমেন্ট পদ্ধতি আপডেট করুন", + "billing_history": "বিলিং ইতিহাস", + "billing_search": "অনুসন্ধান...", + "billing_all_statuses": "সব স্ট্যাটাস", + "billing_export": "এক্সপোর্ট", + "billing_plan": "প্ল্যান", + "billing_amount": "পরিমাণ", + "billing_purchase_date": "ক্রয়ের তারিখ", + "billing_end_date": "শেষের তারিখ", + "billing_status": "স্ট্যাটাস", + "billing_action": "অ্যাকশন", + "billing_invoice": "ইনভয়েস", + "billing_status_paid": "পরিশোধিত", + "billing_status_open": "অপেক্ষমাণ", + "billing_status_failed": "ব্যর্থ", + "billing_status_void": "বাতিল", + "billing_download_invoice": "ইনভয়েস ডাউনলোড করুন", + "billing_view_invoice": "ইনভয়েস দেখুন", + "billing_no_matching_invoices": "আপনার অনুসন্ধানের সাথে কোনো ইনভয়েস মেলেনি", + "billing_invoices_failed": "আপনার ইনভয়েস লোড করা যায়নি", "select_channels": "চ্যানেল নির্বাচন করুন", "start_a_new_chat": "নতুন চ্যাট শুরু করুন", "your_assistant": "আপনার সহকারী", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index 1527494727..c77e2587a9 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Anwenden", "billing_subscription": "Abonnement", "billing_cancel_notice": "Sie können jederzeit in den Einstellungen kündigen, ohne mit einer Person sprechen zu müssen, und werden nie belastet.", + "billing_and_subscription": "Abrechnung & Abonnement", + "billing_and_subscription_description": "Behalten Sie Ihr Abonnement im Blick, aktualisieren Sie Ihre Zahlungsmethode und laden Sie Ihre Rechnungen herunter", + "billing_current_plan": "Aktueller Plan", + "billing_upgrade": "Upgraden", + "billing_downgrade": "Downgraden", + "billing_switch_to_yearly": "Zu jährlich wechseln", + "billing_switch_to_monthly": "Zu monatlich wechseln", + "billing_purchase": "Kaufen", + "billing_downgrade_on": "Downgrade am {{date}}", + "billing_payment_method": "Zahlungsmethode", + "billing_payment_method_description": "Aktualisieren Sie Ihre Karte, Rechnungsadresse und Steuerdaten im sicheren Abrechnungsportal", + "billing_update_payment_method": "Zahlungsmethode aktualisieren", + "billing_history": "Rechnungsverlauf", + "billing_search": "Suchen...", + "billing_all_statuses": "Alle Status", + "billing_export": "Exportieren", + "billing_plan": "Plan", + "billing_amount": "Betrag", + "billing_purchase_date": "Kaufdatum", + "billing_end_date": "Enddatum", + "billing_status": "Status", + "billing_action": "Aktion", + "billing_invoice": "Rechnung", + "billing_status_paid": "Bezahlt", + "billing_status_open": "Ausstehend", + "billing_status_failed": "Fehlgeschlagen", + "billing_status_void": "Storniert", + "billing_download_invoice": "Rechnung herunterladen", + "billing_view_invoice": "Rechnung ansehen", + "billing_no_matching_invoices": "Keine Rechnungen entsprechen Ihrer Suche", + "billing_invoices_failed": "Ihre Rechnungen konnten nicht geladen werden", "select_channels": "Kanäle auswählen", "start_a_new_chat": "Neuen Chat starten", "your_assistant": "Ihr Assistent", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 0348deb5e3..4964860d2c 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Apply", "billing_subscription": "Subscription", "billing_cancel_notice": "Cancel anytime from settings without talking to a person and never be charged.", + "billing_and_subscription": "Billing & Subscription", + "billing_and_subscription_description": "Keep track of your subscription, update your payment method and download your invoices", + "billing_current_plan": "Current Plan", + "billing_upgrade": "Upgrade", + "billing_downgrade": "Downgrade", + "billing_switch_to_yearly": "Switch to yearly", + "billing_switch_to_monthly": "Switch to monthly", + "billing_purchase": "Purchase", + "billing_downgrade_on": "Downgrade on {{date}}", + "billing_payment_method": "Payment method", + "billing_payment_method_description": "Update your card, billing address and tax details in the secure billing portal", + "billing_update_payment_method": "Update payment method", + "billing_history": "Billing History", + "billing_search": "Search...", + "billing_all_statuses": "All statuses", + "billing_export": "Export", + "billing_plan": "Plan", + "billing_amount": "Amount", + "billing_purchase_date": "Purchase Date", + "billing_end_date": "End Date", + "billing_status": "Status", + "billing_action": "Action", + "billing_invoice": "Invoice", + "billing_status_paid": "Paid", + "billing_status_open": "Pending", + "billing_status_failed": "Failed", + "billing_status_void": "Void", + "billing_download_invoice": "Download invoice", + "billing_view_invoice": "View invoice", + "billing_no_matching_invoices": "No invoices match your search", + "billing_invoices_failed": "Could not load your invoices", "select_channels": "Select Channels", "start_a_new_chat": "Start a new chat", "your_assistant": "Your Assistant", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index ae12852a33..f15d30b9ed 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Aplicar", "billing_subscription": "Suscripción", "billing_cancel_notice": "Cancela en cualquier momento desde la configuración sin hablar con una persona y nunca se te cobrará.", + "billing_and_subscription": "Facturación y suscripción", + "billing_and_subscription_description": "Sigue tu suscripción, actualiza tu método de pago y descarga tus facturas", + "billing_current_plan": "Plan actual", + "billing_upgrade": "Mejorar", + "billing_downgrade": "Bajar de plan", + "billing_switch_to_yearly": "Cambiar a anual", + "billing_switch_to_monthly": "Cambiar a mensual", + "billing_purchase": "Comprar", + "billing_downgrade_on": "Bajada de plan el {{date}}", + "billing_payment_method": "Método de pago", + "billing_payment_method_description": "Actualiza tu tarjeta, dirección de facturación y datos fiscales en el portal de facturación seguro", + "billing_update_payment_method": "Actualizar método de pago", + "billing_history": "Historial de facturación", + "billing_search": "Buscar...", + "billing_all_statuses": "Todos los estados", + "billing_export": "Exportar", + "billing_plan": "Plan", + "billing_amount": "Importe", + "billing_purchase_date": "Fecha de compra", + "billing_end_date": "Fecha de fin", + "billing_status": "Estado", + "billing_action": "Acción", + "billing_invoice": "Factura", + "billing_status_paid": "Pagada", + "billing_status_open": "Pendiente", + "billing_status_failed": "Fallida", + "billing_status_void": "Anulada", + "billing_download_invoice": "Descargar factura", + "billing_view_invoice": "Ver factura", + "billing_no_matching_invoices": "Ninguna factura coincide con tu búsqueda", + "billing_invoices_failed": "No se pudieron cargar tus facturas", "select_channels": "Seleccionar canales", "start_a_new_chat": "Iniciar un nuevo chat", "your_assistant": "Tu Asistente", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 993c81a437..23688db2e4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Appliquer", "billing_subscription": "Abonnement", "billing_cancel_notice": "Annulez à tout moment depuis les paramètres sans avoir à parler à quelqu'un et ne soyez jamais facturé.", + "billing_and_subscription": "Facturation et abonnement", + "billing_and_subscription_description": "Suivez votre abonnement, mettez à jour votre moyen de paiement et téléchargez vos factures", + "billing_current_plan": "Forfait actuel", + "billing_upgrade": "Mettre à niveau", + "billing_downgrade": "Rétrograder", + "billing_switch_to_yearly": "Passer à l'annuel", + "billing_switch_to_monthly": "Passer au mensuel", + "billing_purchase": "Acheter", + "billing_downgrade_on": "Rétrogradation le {{date}}", + "billing_payment_method": "Moyen de paiement", + "billing_payment_method_description": "Mettez à jour votre carte, votre adresse de facturation et vos informations fiscales dans le portail de facturation sécurisé", + "billing_update_payment_method": "Mettre à jour le moyen de paiement", + "billing_history": "Historique de facturation", + "billing_search": "Rechercher...", + "billing_all_statuses": "Tous les statuts", + "billing_export": "Exporter", + "billing_plan": "Forfait", + "billing_amount": "Montant", + "billing_purchase_date": "Date d'achat", + "billing_end_date": "Date de fin", + "billing_status": "Statut", + "billing_action": "Action", + "billing_invoice": "Facture", + "billing_status_paid": "Payée", + "billing_status_open": "En attente", + "billing_status_failed": "Échouée", + "billing_status_void": "Annulée", + "billing_download_invoice": "Télécharger la facture", + "billing_view_invoice": "Voir la facture", + "billing_no_matching_invoices": "Aucune facture ne correspond à votre recherche", + "billing_invoices_failed": "Impossible de charger vos factures", "select_channels": "Sélectionner les canaux", "start_a_new_chat": "Démarrer une nouvelle conversation", "your_assistant": "Votre assistant", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index 9582b54a65..eb890f33ee 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -568,6 +568,37 @@ "billing_apply": "החל", "billing_subscription": "מנוי", "billing_cancel_notice": "ניתן לבטל בכל עת מההגדרות מבלי לדבר עם נציג ולעולם לא תחויב.", + "billing_and_subscription": "חיוב ומנוי", + "billing_and_subscription_description": "עקוב אחר המנוי שלך, עדכן את אמצעי התשלום והורד את החשבוניות שלך", + "billing_current_plan": "התוכנית הנוכחית", + "billing_upgrade": "שדרוג", + "billing_downgrade": "שנמוך", + "billing_switch_to_yearly": "מעבר לחיוב שנתי", + "billing_switch_to_monthly": "מעבר לחיוב חודשי", + "billing_purchase": "רכישה", + "billing_downgrade_on": "שנמוך ב-{{date}}", + "billing_payment_method": "אמצעי תשלום", + "billing_payment_method_description": "עדכן את הכרטיס, כתובת החיוב ופרטי המס בפורטל החיוב המאובטח", + "billing_update_payment_method": "עדכון אמצעי תשלום", + "billing_history": "היסטוריית חיובים", + "billing_search": "חיפוש...", + "billing_all_statuses": "כל הסטטוסים", + "billing_export": "ייצוא", + "billing_plan": "תוכנית", + "billing_amount": "סכום", + "billing_purchase_date": "תאריך רכישה", + "billing_end_date": "תאריך סיום", + "billing_status": "סטטוס", + "billing_action": "פעולה", + "billing_invoice": "חשבונית", + "billing_status_paid": "שולם", + "billing_status_open": "ממתין", + "billing_status_failed": "נכשל", + "billing_status_void": "בוטל", + "billing_download_invoice": "הורדת חשבונית", + "billing_view_invoice": "צפייה בחשבונית", + "billing_no_matching_invoices": "אין חשבוניות שתואמות לחיפוש", + "billing_invoices_failed": "לא ניתן היה לטעון את החשבוניות שלך", "select_channels": "בחר ערוצים", "start_a_new_chat": "התחל שיחה חדשה", "your_assistant": "העוזר שלך", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index b08a83cec7..b6a4e9b432 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Applica", "billing_subscription": "Abbonamento", "billing_cancel_notice": "Annulla in qualsiasi momento dalle impostazioni senza parlare con nessuno e non ti verrà mai addebitato nulla.", + "billing_and_subscription": "Fatturazione e abbonamento", + "billing_and_subscription_description": "Tieni traccia del tuo abbonamento, aggiorna il metodo di pagamento e scarica le tue fatture", + "billing_current_plan": "Piano attuale", + "billing_upgrade": "Effettua l'upgrade", + "billing_downgrade": "Effettua il downgrade", + "billing_switch_to_yearly": "Passa all'annuale", + "billing_switch_to_monthly": "Passa al mensile", + "billing_purchase": "Acquista", + "billing_downgrade_on": "Downgrade il {{date}}", + "billing_payment_method": "Metodo di pagamento", + "billing_payment_method_description": "Aggiorna la carta, l'indirizzo di fatturazione e i dati fiscali nel portale di fatturazione sicuro", + "billing_update_payment_method": "Aggiorna metodo di pagamento", + "billing_history": "Cronologia fatturazione", + "billing_search": "Cerca...", + "billing_all_statuses": "Tutti gli stati", + "billing_export": "Esporta", + "billing_plan": "Piano", + "billing_amount": "Importo", + "billing_purchase_date": "Data di acquisto", + "billing_end_date": "Data di fine", + "billing_status": "Stato", + "billing_action": "Azione", + "billing_invoice": "Fattura", + "billing_status_paid": "Pagata", + "billing_status_open": "In sospeso", + "billing_status_failed": "Non riuscita", + "billing_status_void": "Annullata", + "billing_download_invoice": "Scarica fattura", + "billing_view_invoice": "Visualizza fattura", + "billing_no_matching_invoices": "Nessuna fattura corrisponde alla ricerca", + "billing_invoices_failed": "Impossibile caricare le tue fatture", "select_channels": "Seleziona canali", "start_a_new_chat": "Inizia una nuova chat", "your_assistant": "Il tuo assistente", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 414212a0da..5e4a0cf086 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -568,6 +568,37 @@ "billing_apply": "適用", "billing_subscription": "サブスクリプション", "billing_cancel_notice": "設定からいつでも人と話すことなくキャンセルでき、料金は一切発生しません。", + "billing_and_subscription": "請求とサブスクリプション", + "billing_and_subscription_description": "サブスクリプションの確認、支払い方法の更新、請求書のダウンロードができます", + "billing_current_plan": "現在のプラン", + "billing_upgrade": "アップグレード", + "billing_downgrade": "ダウングレード", + "billing_switch_to_yearly": "年額に切り替え", + "billing_switch_to_monthly": "月額に切り替え", + "billing_purchase": "購入", + "billing_downgrade_on": "{{date}}にダウングレード", + "billing_payment_method": "支払い方法", + "billing_payment_method_description": "安全な請求ポータルでカード、請求先住所、税情報を更新できます", + "billing_update_payment_method": "支払い方法を更新", + "billing_history": "請求履歴", + "billing_search": "検索...", + "billing_all_statuses": "すべてのステータス", + "billing_export": "エクスポート", + "billing_plan": "プラン", + "billing_amount": "金額", + "billing_purchase_date": "購入日", + "billing_end_date": "終了日", + "billing_status": "ステータス", + "billing_action": "操作", + "billing_invoice": "請求書", + "billing_status_paid": "支払い済み", + "billing_status_open": "保留中", + "billing_status_failed": "失敗", + "billing_status_void": "無効", + "billing_download_invoice": "請求書をダウンロード", + "billing_view_invoice": "請求書を表示", + "billing_no_matching_invoices": "検索に一致する請求書はありません", + "billing_invoices_failed": "請求書を読み込めませんでした", "select_channels": "チャンネルを選択", "start_a_new_chat": "新しいチャットを開始", "your_assistant": "あなたのアシスタント", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index 4a34e7895b..78e844f9e8 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -568,6 +568,37 @@ "billing_apply": "적용", "billing_subscription": "구독", "billing_cancel_notice": "설정에서 언제든지 사람과 대화하지 않고 구독을 취소할 수 있으며, 결제가 청구되지 않습니다.", + "billing_and_subscription": "결제 및 구독", + "billing_and_subscription_description": "구독을 확인하고, 결제 수단을 업데이트하고, 인보이스를 다운로드하세요", + "billing_current_plan": "현재 요금제", + "billing_upgrade": "업그레이드", + "billing_downgrade": "다운그레이드", + "billing_switch_to_yearly": "연간 결제로 전환", + "billing_switch_to_monthly": "월간 결제로 전환", + "billing_purchase": "구매", + "billing_downgrade_on": "{{date}}에 다운그레이드", + "billing_payment_method": "결제 수단", + "billing_payment_method_description": "안전한 결제 포털에서 카드, 청구지 주소 및 세금 정보를 업데이트하세요", + "billing_update_payment_method": "결제 수단 업데이트", + "billing_history": "결제 내역", + "billing_search": "검색...", + "billing_all_statuses": "모든 상태", + "billing_export": "내보내기", + "billing_plan": "요금제", + "billing_amount": "금액", + "billing_purchase_date": "구매일", + "billing_end_date": "종료일", + "billing_status": "상태", + "billing_action": "작업", + "billing_invoice": "인보이스", + "billing_status_paid": "결제 완료", + "billing_status_open": "대기 중", + "billing_status_failed": "실패", + "billing_status_void": "무효", + "billing_download_invoice": "인보이스 다운로드", + "billing_view_invoice": "인보이스 보기", + "billing_no_matching_invoices": "검색과 일치하는 인보이스가 없습니다", + "billing_invoices_failed": "인보이스를 불러올 수 없습니다", "select_channels": "채널 선택", "start_a_new_chat": "새 채팅 시작", "your_assistant": "당신의 어시스턴트", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 24e327e644..5e6b3e2c33 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Aplicar", "billing_subscription": "Assinatura", "billing_cancel_notice": "Cancele a qualquer momento nas configurações sem falar com ninguém e nunca será cobrado.", + "billing_and_subscription": "Faturamento e assinatura", + "billing_and_subscription_description": "Acompanhe sua assinatura, atualize seu método de pagamento e baixe suas faturas", + "billing_current_plan": "Plano atual", + "billing_upgrade": "Fazer upgrade", + "billing_downgrade": "Fazer downgrade", + "billing_switch_to_yearly": "Mudar para anual", + "billing_switch_to_monthly": "Mudar para mensal", + "billing_purchase": "Comprar", + "billing_downgrade_on": "Downgrade em {{date}}", + "billing_payment_method": "Método de pagamento", + "billing_payment_method_description": "Atualize seu cartão, endereço de cobrança e dados fiscais no portal de faturamento seguro", + "billing_update_payment_method": "Atualizar método de pagamento", + "billing_history": "Histórico de faturamento", + "billing_search": "Pesquisar...", + "billing_all_statuses": "Todos os status", + "billing_export": "Exportar", + "billing_plan": "Plano", + "billing_amount": "Valor", + "billing_purchase_date": "Data da compra", + "billing_end_date": "Data de término", + "billing_status": "Status", + "billing_action": "Ação", + "billing_invoice": "Fatura", + "billing_status_paid": "Paga", + "billing_status_open": "Pendente", + "billing_status_failed": "Falhou", + "billing_status_void": "Anulada", + "billing_download_invoice": "Baixar fatura", + "billing_view_invoice": "Ver fatura", + "billing_no_matching_invoices": "Nenhuma fatura corresponde à sua pesquisa", + "billing_invoices_failed": "Não foi possível carregar suas faturas", "select_channels": "Selecionar canais", "start_a_new_chat": "Iniciar um novo chat", "your_assistant": "Seu Assistente", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index c150b404ce..0f268c7333 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Применить", "billing_subscription": "Подписка", "billing_cancel_notice": "Вы можете отменить подписку в любое время через настройки без необходимости разговаривать с оператором и больше не будете платить.", + "billing_and_subscription": "Оплата и подписка", + "billing_and_subscription_description": "Следите за подпиской, обновляйте способ оплаты и скачивайте счета", + "billing_current_plan": "Текущий тариф", + "billing_upgrade": "Повысить тариф", + "billing_downgrade": "Понизить тариф", + "billing_switch_to_yearly": "Перейти на годовой", + "billing_switch_to_monthly": "Перейти на ежемесячный", + "billing_purchase": "Купить", + "billing_downgrade_on": "Понижение тарифа {{date}}", + "billing_payment_method": "Способ оплаты", + "billing_payment_method_description": "Обновите карту, платёжный адрес и налоговые данные на защищённом платёжном портале", + "billing_update_payment_method": "Обновить способ оплаты", + "billing_history": "История платежей", + "billing_search": "Поиск...", + "billing_all_statuses": "Все статусы", + "billing_export": "Экспорт", + "billing_plan": "Тариф", + "billing_amount": "Сумма", + "billing_purchase_date": "Дата покупки", + "billing_end_date": "Дата окончания", + "billing_status": "Статус", + "billing_action": "Действие", + "billing_invoice": "Счёт", + "billing_status_paid": "Оплачен", + "billing_status_open": "В ожидании", + "billing_status_failed": "Ошибка", + "billing_status_void": "Аннулирован", + "billing_download_invoice": "Скачать счёт", + "billing_view_invoice": "Просмотреть счёт", + "billing_no_matching_invoices": "Нет счетов, соответствующих поиску", + "billing_invoices_failed": "Не удалось загрузить счета", "select_channels": "Выберите каналы", "start_a_new_chat": "Начать новый чат", "your_assistant": "Ваш помощник", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index a99213513c..a6014b81db 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Uygula", "billing_subscription": "Abonelik", "billing_cancel_notice": "Ayarlar kısmından herhangi bir kişiyle konuşmadan istediğiniz zaman iptal edebilir ve asla ücretlendirilmezsiniz.", + "billing_and_subscription": "Faturalandırma ve Abonelik", + "billing_and_subscription_description": "Aboneliğinizi takip edin, ödeme yönteminizi güncelleyin ve faturalarınızı indirin", + "billing_current_plan": "Mevcut Plan", + "billing_upgrade": "Yükselt", + "billing_downgrade": "Düşür", + "billing_switch_to_yearly": "Yıllığa geç", + "billing_switch_to_monthly": "Aylığa geç", + "billing_purchase": "Satın al", + "billing_downgrade_on": "{{date}} tarihinde düşürülecek", + "billing_payment_method": "Ödeme yöntemi", + "billing_payment_method_description": "Kartınızı, fatura adresinizi ve vergi bilgilerinizi güvenli faturalandırma portalında güncelleyin", + "billing_update_payment_method": "Ödeme yöntemini güncelle", + "billing_history": "Fatura Geçmişi", + "billing_search": "Ara...", + "billing_all_statuses": "Tüm durumlar", + "billing_export": "Dışa aktar", + "billing_plan": "Plan", + "billing_amount": "Tutar", + "billing_purchase_date": "Satın Alma Tarihi", + "billing_end_date": "Bitiş Tarihi", + "billing_status": "Durum", + "billing_action": "İşlem", + "billing_invoice": "Fatura", + "billing_status_paid": "Ödendi", + "billing_status_open": "Beklemede", + "billing_status_failed": "Başarısız", + "billing_status_void": "Geçersiz", + "billing_download_invoice": "Faturayı indir", + "billing_view_invoice": "Faturayı görüntüle", + "billing_no_matching_invoices": "Aramanızla eşleşen fatura yok", + "billing_invoices_failed": "Faturalarınız yüklenemedi", "select_channels": "Kanalları Seç", "start_a_new_chat": "Yeni bir sohbet başlat", "your_assistant": "Asistanınız", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index 972cb2a822..bff88b9e49 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -568,6 +568,37 @@ "billing_apply": "Áp dụng", "billing_subscription": "Đăng ký", "billing_cancel_notice": "Bạn có thể hủy bất cứ lúc nào trong phần cài đặt mà không cần nói chuyện với ai và sẽ không bị tính phí.", + "billing_and_subscription": "Thanh toán & Gói đăng ký", + "billing_and_subscription_description": "Theo dõi gói đăng ký, cập nhật phương thức thanh toán và tải xuống hóa đơn của bạn", + "billing_current_plan": "Gói hiện tại", + "billing_upgrade": "Nâng cấp", + "billing_downgrade": "Hạ cấp", + "billing_switch_to_yearly": "Chuyển sang gói năm", + "billing_switch_to_monthly": "Chuyển sang gói tháng", + "billing_purchase": "Mua", + "billing_downgrade_on": "Hạ cấp vào {{date}}", + "billing_payment_method": "Phương thức thanh toán", + "billing_payment_method_description": "Cập nhật thẻ, địa chỉ thanh toán và thông tin thuế trong cổng thanh toán bảo mật", + "billing_update_payment_method": "Cập nhật phương thức thanh toán", + "billing_history": "Lịch sử thanh toán", + "billing_search": "Tìm kiếm...", + "billing_all_statuses": "Tất cả trạng thái", + "billing_export": "Xuất", + "billing_plan": "Gói", + "billing_amount": "Số tiền", + "billing_purchase_date": "Ngày mua", + "billing_end_date": "Ngày kết thúc", + "billing_status": "Trạng thái", + "billing_action": "Hành động", + "billing_invoice": "Hóa đơn", + "billing_status_paid": "Đã thanh toán", + "billing_status_open": "Đang chờ", + "billing_status_failed": "Thất bại", + "billing_status_void": "Đã hủy", + "billing_download_invoice": "Tải xuống hóa đơn", + "billing_view_invoice": "Xem hóa đơn", + "billing_no_matching_invoices": "Không có hóa đơn nào khớp với tìm kiếm", + "billing_invoices_failed": "Không thể tải hóa đơn của bạn", "select_channels": "Chọn kênh", "start_a_new_chat": "Bắt đầu cuộc trò chuyện mới", "your_assistant": "Trợ lý của bạn", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 5740d0c7b4..241eea7773 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -568,6 +568,37 @@ "billing_apply": "应用", "billing_subscription": "订阅", "billing_cancel_notice": "您可以随时在设置中取消订阅,无需与任何人交流,且不会被收费。", + "billing_and_subscription": "账单与订阅", + "billing_and_subscription_description": "跟踪您的订阅、更新付款方式并下载发票", + "billing_current_plan": "当前方案", + "billing_upgrade": "升级", + "billing_downgrade": "降级", + "billing_switch_to_yearly": "切换为年付", + "billing_switch_to_monthly": "切换为月付", + "billing_purchase": "购买", + "billing_downgrade_on": "将于 {{date}} 降级", + "billing_payment_method": "付款方式", + "billing_payment_method_description": "在安全的账单门户中更新您的银行卡、账单地址和税务信息", + "billing_update_payment_method": "更新付款方式", + "billing_history": "账单历史", + "billing_search": "搜索...", + "billing_all_statuses": "所有状态", + "billing_export": "导出", + "billing_plan": "方案", + "billing_amount": "金额", + "billing_purchase_date": "购买日期", + "billing_end_date": "结束日期", + "billing_status": "状态", + "billing_action": "操作", + "billing_invoice": "发票", + "billing_status_paid": "已支付", + "billing_status_open": "待处理", + "billing_status_failed": "失败", + "billing_status_void": "已作废", + "billing_download_invoice": "下载发票", + "billing_view_invoice": "查看发票", + "billing_no_matching_invoices": "没有与搜索匹配的发票", + "billing_invoices_failed": "无法加载您的发票", "select_channels": "选择频道", "start_a_new_chat": "开始新聊天", "your_assistant": "你的助手", From 8f3c38ab1536d2961560f6045bb07801ef194c88 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 8 Oct 2026 19:03:07 +0700 Subject: [PATCH 90/99] feat: fix invoices --- .../billing/billing.history.component.tsx | 50 ++++++++++--------- .../billing/main.billing.component.tsx | 16 ++---- i18n.lock | 1 - .../translation/locales/ar/translation.json | 1 - .../translation/locales/bn/translation.json | 1 - .../translation/locales/de/translation.json | 1 - .../translation/locales/en/translation.json | 1 - .../translation/locales/es/translation.json | 1 - .../translation/locales/fr/translation.json | 1 - .../translation/locales/he/translation.json | 1 - .../translation/locales/it/translation.json | 1 - .../translation/locales/ja/translation.json | 1 - .../translation/locales/ko/translation.json | 1 - .../translation/locales/pt/translation.json | 1 - .../translation/locales/ru/translation.json | 1 - .../translation/locales/tr/translation.json | 1 - .../translation/locales/vi/translation.json | 1 - .../translation/locales/zh/translation.json | 1 - 18 files changed, 30 insertions(+), 52 deletions(-) diff --git a/apps/frontend/src/components/billing/billing.history.component.tsx b/apps/frontend/src/components/billing/billing.history.component.tsx index 1077a2b69a..50c232090a 100644 --- a/apps/frontend/src/components/billing/billing.history.component.tsx +++ b/apps/frontend/src/components/billing/billing.history.component.tsx @@ -25,6 +25,8 @@ interface Invoice { } const PAGE_SIZE = 10; +// invoices under $10 (trial starts, proration leftovers) are left out of the history +const MIN_AMOUNT = 1000; const useInvoices = () => { const fetch = useFetch(); @@ -112,22 +114,24 @@ export const BillingHistory: FC = () => { const rows = useMemo( () => - (data || []).map((invoice) => ({ - ...invoice, - plan: invoice.tier - ? capitalize(invoice.tier) - : invoice.description || t('billing_subscription', 'Subscription'), - periodLabel: - invoice.period === 'YEARLY' - ? t('billing_yearly', 'Yearly') - : invoice.period === 'MONTHLY' - ? t('billing_monthly', 'Monthly') - : '', - amountLabel: formatAmount(invoice.amount, invoice.currency), - purchaseDate: formatDate(invoice.created), - endDate: formatDate(invoice.periodEnd), - statusInfo: statuses[invoice.status] || statuses.void, - })), + (data || []) + .filter((invoice) => invoice.amount >= MIN_AMOUNT) + .map((invoice) => ({ + ...invoice, + plan: invoice.tier + ? capitalize(invoice.tier) + : invoice.description || t('billing_subscription', 'Subscription'), + periodLabel: + invoice.period === 'YEARLY' + ? t('billing_yearly', 'Yearly') + : invoice.period === 'MONTHLY' + ? t('billing_monthly', 'Monthly') + : '', + amountLabel: formatAmount(invoice.amount, invoice.currency), + purchaseDate: formatDate(invoice.created), + endDate: formatDate(invoice.periodEnd), + statusInfo: statuses[invoice.status] || statuses.void, + })), [data, statuses, t] ); @@ -188,7 +192,7 @@ export const BillingHistory: FC = () => { const columns = 'grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_88px] gap-x-[16px] items-center px-[16px]'; - if (isLoading || (!error && !rows.length)) { + if (isLoading || error || !rows.length) { return null; } @@ -290,7 +294,7 @@ export const BillingHistory: FC = () => {
    {t('billing_action', 'Action')}
    - {error || !filtered.length ? ( + {!filtered.length ? (
    {
    - {error - ? t('billing_invoices_failed', 'Could not load your invoices') - : t( - 'billing_no_matching_invoices', - 'No invoices match your search' - )} + {t( + 'billing_no_matching_invoices', + 'No invoices match your search' + )}
    ) : ( diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 65a38f01e3..bb8d067a2f 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -42,10 +42,8 @@ export const Prorate: FC<{ const { period, pack } = props; const t = useT(); const fetch = useFetch(); - const [price, setPrice] = useState(0); - const [loading, setLoading] = useState(false); + const [price, setPrice] = useState(false); const calculatePrice = useDebouncedCallback(async () => { - setLoading(true); setPrice( ( await ( @@ -59,25 +57,19 @@ export const Prorate: FC<{ ).json() ).price ); - setLoading(false); }, 500); useEffect(() => { setPrice(false); calculatePrice(); }, [period, pack]); - if (loading) { - return ( - - ); - } - // the price is missing when the preview request fails + // nothing is shown while the preview loads, the price is missing when it fails if (typeof price !== 'number') { return null; } return ( - <> +
    {t('pay_today', 'Pay Today')} ${Math.max(price, 0).toFixed(2)} - +
    ); }; diff --git a/i18n.lock b/i18n.lock index 822ed4677e..40ccfe4d6e 100644 --- a/i18n.lock +++ b/i18n.lock @@ -602,7 +602,6 @@ checksums: billing_download_invoice: 8c50fd826a446b01ef4fcdb16abef365 billing_view_invoice: 6362bd0415ff4803ab99c2b454e07940 billing_no_matching_invoices: d40a4a30fb9ebe36ba7203b961d00de0 - billing_invoices_failed: d1d1391d1091114af3fb946833fc46f2 select_channels: 3aab82dc90ccafe55059c32c5a33c222 start_a_new_chat: 65b9067206fa4e4929d48ce242d879a6 your_assistant: bd6422d1c2a2ba461e1da169b082bf7b diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json index 79f255fe9f..4b0b91d8e1 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "تنزيل الفاتورة", "billing_view_invoice": "عرض الفاتورة", "billing_no_matching_invoices": "لا توجد فواتير مطابقة لبحثك", - "billing_invoices_failed": "تعذر تحميل فواتيرك", "select_channels": "اختر القنوات", "start_a_new_chat": "ابدأ محادثة جديدة", "your_assistant": "مساعدك", diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json index 2432a01c57..3fbfc2ac76 100644 --- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "ইনভয়েস ডাউনলোড করুন", "billing_view_invoice": "ইনভয়েস দেখুন", "billing_no_matching_invoices": "আপনার অনুসন্ধানের সাথে কোনো ইনভয়েস মেলেনি", - "billing_invoices_failed": "আপনার ইনভয়েস লোড করা যায়নি", "select_channels": "চ্যানেল নির্বাচন করুন", "start_a_new_chat": "নতুন চ্যাট শুরু করুন", "your_assistant": "আপনার সহকারী", diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json index c77e2587a9..818b2e0ab4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Rechnung herunterladen", "billing_view_invoice": "Rechnung ansehen", "billing_no_matching_invoices": "Keine Rechnungen entsprechen Ihrer Suche", - "billing_invoices_failed": "Ihre Rechnungen konnten nicht geladen werden", "select_channels": "Kanäle auswählen", "start_a_new_chat": "Neuen Chat starten", "your_assistant": "Ihr Assistent", diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 4964860d2c..60c776b9c5 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Download invoice", "billing_view_invoice": "View invoice", "billing_no_matching_invoices": "No invoices match your search", - "billing_invoices_failed": "Could not load your invoices", "select_channels": "Select Channels", "start_a_new_chat": "Start a new chat", "your_assistant": "Your Assistant", diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json index f15d30b9ed..dc443358a7 100644 --- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Descargar factura", "billing_view_invoice": "Ver factura", "billing_no_matching_invoices": "Ninguna factura coincide con tu búsqueda", - "billing_invoices_failed": "No se pudieron cargar tus facturas", "select_channels": "Seleccionar canales", "start_a_new_chat": "Iniciar un nuevo chat", "your_assistant": "Tu Asistente", diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json index 23688db2e4..01fcd42226 100644 --- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Télécharger la facture", "billing_view_invoice": "Voir la facture", "billing_no_matching_invoices": "Aucune facture ne correspond à votre recherche", - "billing_invoices_failed": "Impossible de charger vos factures", "select_channels": "Sélectionner les canaux", "start_a_new_chat": "Démarrer une nouvelle conversation", "your_assistant": "Votre assistant", diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json index eb890f33ee..928c321eb4 100644 --- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "הורדת חשבונית", "billing_view_invoice": "צפייה בחשבונית", "billing_no_matching_invoices": "אין חשבוניות שתואמות לחיפוש", - "billing_invoices_failed": "לא ניתן היה לטעון את החשבוניות שלך", "select_channels": "בחר ערוצים", "start_a_new_chat": "התחל שיחה חדשה", "your_assistant": "העוזר שלך", diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json index b6a4e9b432..8f19e49d94 100644 --- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Scarica fattura", "billing_view_invoice": "Visualizza fattura", "billing_no_matching_invoices": "Nessuna fattura corrisponde alla ricerca", - "billing_invoices_failed": "Impossibile caricare le tue fatture", "select_channels": "Seleziona canali", "start_a_new_chat": "Inizia una nuova chat", "your_assistant": "Il tuo assistente", diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json index 5e4a0cf086..8cfa6020f2 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "請求書をダウンロード", "billing_view_invoice": "請求書を表示", "billing_no_matching_invoices": "検索に一致する請求書はありません", - "billing_invoices_failed": "請求書を読み込めませんでした", "select_channels": "チャンネルを選択", "start_a_new_chat": "新しいチャットを開始", "your_assistant": "あなたのアシスタント", diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json index 78e844f9e8..00014a62b5 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "인보이스 다운로드", "billing_view_invoice": "인보이스 보기", "billing_no_matching_invoices": "검색과 일치하는 인보이스가 없습니다", - "billing_invoices_failed": "인보이스를 불러올 수 없습니다", "select_channels": "채널 선택", "start_a_new_chat": "새 채팅 시작", "your_assistant": "당신의 어시스턴트", diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json index 5e6b3e2c33..1aecfb4998 100644 --- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Baixar fatura", "billing_view_invoice": "Ver fatura", "billing_no_matching_invoices": "Nenhuma fatura corresponde à sua pesquisa", - "billing_invoices_failed": "Não foi possível carregar suas faturas", "select_channels": "Selecionar canais", "start_a_new_chat": "Iniciar um novo chat", "your_assistant": "Seu Assistente", diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json index 0f268c7333..885e7bdce5 100644 --- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Скачать счёт", "billing_view_invoice": "Просмотреть счёт", "billing_no_matching_invoices": "Нет счетов, соответствующих поиску", - "billing_invoices_failed": "Не удалось загрузить счета", "select_channels": "Выберите каналы", "start_a_new_chat": "Начать новый чат", "your_assistant": "Ваш помощник", diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json index a6014b81db..8ffb99c2e2 100644 --- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Faturayı indir", "billing_view_invoice": "Faturayı görüntüle", "billing_no_matching_invoices": "Aramanızla eşleşen fatura yok", - "billing_invoices_failed": "Faturalarınız yüklenemedi", "select_channels": "Kanalları Seç", "start_a_new_chat": "Yeni bir sohbet başlat", "your_assistant": "Asistanınız", diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json index bff88b9e49..f160a0cc43 100644 --- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "Tải xuống hóa đơn", "billing_view_invoice": "Xem hóa đơn", "billing_no_matching_invoices": "Không có hóa đơn nào khớp với tìm kiếm", - "billing_invoices_failed": "Không thể tải hóa đơn của bạn", "select_channels": "Chọn kênh", "start_a_new_chat": "Bắt đầu cuộc trò chuyện mới", "your_assistant": "Trợ lý của bạn", diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json index 241eea7773..c07735167a 100644 --- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json @@ -598,7 +598,6 @@ "billing_download_invoice": "下载发票", "billing_view_invoice": "查看发票", "billing_no_matching_invoices": "没有与搜索匹配的发票", - "billing_invoices_failed": "无法加载您的发票", "select_channels": "选择频道", "start_a_new_chat": "开始新聊天", "your_assistant": "你的助手", From 4c5d42a92f05db3fc59e73154bc7c91d4a4bc03d Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Thu, 8 Oct 2026 22:41:53 +0200 Subject: [PATCH 91/99] feat: superadmin public api endpoint to reset an org's ai credits --- .../v1/public.integrations.controller.ts | 24 +++++++++++- .../subscriptions/subscription.repository.ts | 12 ++++++ .../subscriptions/subscription.service.ts | 38 +++++++++++++++---- .../src/dtos/billing/reset.credits.dto.ts | 7 ++++ 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 libraries/nestjs-libraries/src/dtos/billing/reset.credits.dto.ts diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 4f9f9d1ab1..dfb597282b 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -56,6 +56,8 @@ import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { AdminStatsService } from '@gitroom/nestjs-libraries/database/prisma/admin-stats/admin-stats.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; import { GetOrgActivityDto } from '@gitroom/nestjs-libraries/dtos/analytics/get.org.activity.dto'; +import { ResetCreditsDto } from '@gitroom/nestjs-libraries/dtos/billing/reset.credits.dto'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import dayjs from 'dayjs'; @ApiTags('Public API') @@ -71,7 +73,8 @@ export class PublicIntegrationsController { private _usersService: UsersService, private _adminStatsService: AdminStatsService, private _organizationService: OrganizationService, - private _clippingService: ClippingService + private _clippingService: ClippingService, + private _subscriptionService: SubscriptionService ) {} @Post('/upload') @@ -444,6 +447,25 @@ export class PublicIntegrationsController { }); } + @Post('/credits/reset') + @UseGuards(SuperAdminGuard) + async resetCredits( + @GetOrgFromRequest() org: Organization, + @Body() body: ResetCreditsDto + ) { + Sentry.metrics.count('public_api-request', 1); + const reset = await this._subscriptionService.resetCredits( + org.id, + body.type + ); + + if (!reset) { + throw new HttpException({ msg: 'No subscription found' }, 404); + } + + return reset; + } + @Get('/notifications') async getNotifications( @GetOrgFromRequest() org: Organization, diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts index 632971ccee..cf4b413231 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -331,6 +331,18 @@ export class SubscriptionRepository { }); } + deleteCreditsFrom(organizationId: string, from: dayjs.Dayjs, type: string) { + return this._credits.model.credits.deleteMany({ + where: { + organizationId, + type, + createdAt: { + gte: from.toDate(), + }, + }, + }); + } + setCustomerId(orgId: string, customerId: string) { return this._organization.model.organization.update({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index 58f32662aa..89ec8104b0 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -370,6 +370,33 @@ export class SubscriptionService { return this._subscriptionRepository.getSubscription(organizationId); } + private currentPeriodStart(subscriptionCreatedAt: Date) { + let date = dayjs(subscriptionCreatedAt); + while (date.isBefore(dayjs())) { + date = date.add(1, 'month'); + } + + return date.subtract(1, 'month'); + } + + async resetCredits(organizationId: string, type: string) { + const subscription = await this._subscriptionRepository.getSubscription( + organizationId + ); + + if (!subscription) { + return null; + } + + const { count } = await this._subscriptionRepository.deleteCreditsFrom( + organizationId, + this.currentPeriodStart(subscription.createdAt), + type + ); + + return { deleted: count }; + } + async checkCredits(organization: Organization, checkType = 'ai_images') { // @ts-ignore const type = organization?.subscription?.subscriptionTier || 'FREE'; @@ -378,13 +405,10 @@ export class SubscriptionService { return { credits: 0 }; } - // @ts-ignore - let date = dayjs(organization.subscription.createdAt); - while (date.isBefore(dayjs())) { - date = date.add(1, 'month'); - } - - const checkFromMonth = date.subtract(1, 'month'); + const checkFromMonth = this.currentPeriodStart( + // @ts-ignore + organization.subscription.createdAt + ); const imageGenerationCount = checkType === 'ai_images' ? pricing[type].image_generation_count diff --git a/libraries/nestjs-libraries/src/dtos/billing/reset.credits.dto.ts b/libraries/nestjs-libraries/src/dtos/billing/reset.credits.dto.ts new file mode 100644 index 0000000000..22ef50c82a --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/billing/reset.credits.dto.ts @@ -0,0 +1,7 @@ +import { IsIn, IsString } from 'class-validator'; + +export class ResetCreditsDto { + @IsString() + @IsIn(['ai_images', 'ai_videos']) + type: string; +} From 2e2d8ed00b90723488984e07e9817d199e6423e0 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 9 Oct 2026 09:09:28 +0700 Subject: [PATCH 92/99] fix(integrations): log the error behind "Authentication failed" on social connect Co-Authored-By: Claude Sonnet 5.5 --- apps/backend/src/api/routes/no.auth.integrations.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index 70025aa8ca..f66b7ac863 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -168,6 +168,7 @@ export class NoAuthIntegrationsController { }); } + console.log(`Authentication failed for ${integration}:`, err); return res({ error: 'Authentication failed', accessToken: '', From d6f881a89a2eb1dcf02e11e1756c2210d251f66d Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 9 Oct 2026 09:49:05 +0700 Subject: [PATCH 93/99] fix(account): ignore disabled members when checking account deletion 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 --- .../prisma/organizations/organization.repository.ts | 1 + .../src/database/prisma/users/users.service.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts index f70f71c9d2..a27f257130 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -563,6 +563,7 @@ export class OrganizationRepository { users: { select: { role: true, + disabled: true, user: { select: { email: true, diff --git a/libraries/nestjs-libraries/src/database/prisma/users/users.service.ts b/libraries/nestjs-libraries/src/database/prisma/users/users.service.ts index decb2c6a3d..c11276d82a 100644 --- a/libraries/nestjs-libraries/src/database/prisma/users/users.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/users/users.service.ts @@ -92,7 +92,11 @@ export class UsersService { for (const org of ownedOrgs) { const team = await this._organizationRepository.getTeam(org.id); - if (team?.users?.some((member) => member.user.id !== userId)) { + if ( + team?.users?.some( + (member) => member.user.id !== userId && !member.disabled + ) + ) { throw new HttpException( 'Please remove your team members before deleting your account', 400 From 23a37cde8e6a736aa0b9a9e16851768721354c69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:22:03 +0000 Subject: [PATCH 94/99] fix(sync): brand the v2.25.1 upload tool hints and YouTube Workspace 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 Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ --- docs/fork-delta.md | 5 +++++ .../src/chat/tools/upload.from.url.tool.ts | 16 +++++++++++++--- .../src/chat/tools/upload.widget.tool.ts | 12 +++++++++++- .../src/integrations/social/youtube.provider.ts | 3 ++- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/fork-delta.md b/docs/fork-delta.md index 2e9195f8bf..86deaf403c 100644 --- a/docs/fork-delta.md +++ b/docs/fork-delta.md @@ -30,6 +30,11 @@ Last verified: 2026-09-21 (fork `dev` vs `upstream/main`). | `libraries/nestjs-libraries/src/openai/openai.service.ts` | `pickClips` (sync 2026-09-21) uses the fork `getOpenAIClient()`/`getModel()` pattern so OPENAI_BASE_URL / OPENAI_MODEL_NAME keep working; upstream hardcodes a module-level client and model | Low (file gains upstream methods over time) | | `libraries/nestjs-libraries/src/upload/local.storage.ts` | `removeFile` containment guard (path.relative check refusing to unlink outside the upload directory) - CodeQL js/path-injection hardening | Low | | `libraries/nestjs-libraries/src/database/prisma/clipping/clipping.service.ts` | Fixed-format failure logging (data passed as arguments, not interpolated) - CodeQL js/tainted-format-string hardening | Low | +| `apps/backend/src/api/routes/billing.controller.ts` | DOS shared billing branches (subscribe, portal, current billing, cancel, prorate). Since the v2.25.1 sync, `/cancel` in DOS mode emails the cancel feedback after dos.me accepts the cancel; the Stripe path keeps upstream's "email only when `cancel_at` is set" | Medium (upstream reworks billing regularly) | +| `apps/frontend/src/components/billing/main.billing.component.tsx` | DOS mode on top of upstream's v2.25.1 billing redesign: monthly-only Plus ($9) / Pro ($19) cards (`PlanCard` `label` prop, yearly toggle hidden, only FREE/STANDARD/PRO), portal/cancel box gated by `showPortalAndCancel`, `BillingHistory` hidden (invoices live in the dos.me portal, so the portal button keeps the "/ Invoices History" label), upstream's cancel-toggle toasts skipped (a dos.me cancel never toggles), non-OK responses toasted | Medium | +| `libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts`, `upload.widget.tool.ts` | MCP descriptions point agents at this deployment's public API and `brand.docsUrl` instead of the upstream CLI (which defaults to the upstream cloud) and upstream docs | Low | +| `libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts` | `refreshErrorMessage` names the deployment brand, so the Workspace admin trusts the right OAuth app | Low | +| `package.json` `prisma-db-push` | Fork keeps `pnpm exec prisma` (no `prisma-reset` / `docker-*` scripts); took upstream's removal of `--accept-data-loss` in the v2.25.1 sync | Low | ## 3. Planned divergence (accepted, not yet done) diff --git a/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts b/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts index c6778b7fa1..a61e1ac52a 100644 --- a/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts @@ -4,6 +4,10 @@ import { z } from 'zod'; import { Injectable } from '@nestjs/common'; import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; +import { + getBrandConfig, + joinBrandUrl, +} from '@gitroom/helpers/utils/brand.config'; @Injectable() export class UploadFromUrlTool implements AgentToolInterface { @@ -11,10 +15,16 @@ export class UploadFromUrlTool implements AgentToolInterface { name = 'uploadFromUrlTool'; run() { + // The upstream CLI defaults to the upstream cloud, so private files are + // pointed at this deployment's public API instead + const publicApiDocs = joinBrandUrl( + getBrandConfig().docsUrl, + 'public-api/introduction' + ); return createTool({ id: 'uploadFromUrlTool', description: `Upload a remote image or video into the media library from a public http(s) URL. -Local or private files cannot be fetched by this tool: upload them with the Postiz CLI (postiz upload, https://docs.postiz.com/cli/introduction) or the public API (POST /public/v1/upload with an API key, https://docs.postiz.com/public-api/introduction). +Local or private files cannot be fetched by this tool: upload them with the public API (POST /public/v1/upload with an API key, ${publicApiDocs}). Use this before scheduling a post when the user provides an external media URL (not already hosted on our domain), so the attachment passes the upload-domain validation. Returns the hosted media { id, path } to use as an attachment (or as the src of an in the content, when the channel rules say pictures go inside the content), or { error } on failure.`, mcp: { @@ -31,7 +41,7 @@ so the attachment passes the upload-domain validation. Returns the hosted media .string() .url() .describe( - 'The public http(s) URL of the image or video to upload. Local files and private hosts are not supported, use the Postiz CLI (postiz upload) or POST /public/v1/upload instead' + 'The public http(s) URL of the image or video to upload. Local files and private hosts are not supported, use POST /public/v1/upload instead' ), }), // Mastra validates a tool's return against this schema, so it must also @@ -71,7 +81,7 @@ so the attachment passes the upload-domain validation. Returns the hosted media const causeText = causes.length ? ` (${causes.join(': ')})` : ''; return { - error: `Failed to upload media from URL: ${message}${causeText}. Only public http(s) URLs work: upload local or private files with the Postiz CLI (postiz upload, https://docs.postiz.com/cli/introduction) or the public API (POST /public/v1/upload with an API key, https://docs.postiz.com/public-api/introduction)`, + error: `Failed to upload media from URL: ${message}${causeText}. Only public http(s) URLs work: upload local or private files with the public API (POST /public/v1/upload with an API key, ${publicApiDocs})`, }; } }, diff --git a/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts b/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts index a74a3901f7..53cfc4e8ca 100644 --- a/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/upload.widget.tool.ts @@ -5,6 +5,10 @@ import { Injectable } from '@nestjs/common'; import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; import { UPLOAD_WIDGET_URI } from '@gitroom/nestjs-libraries/chat/ui/upload.widget'; +import { + getBrandConfig, + joinBrandUrl, +} from '@gitroom/helpers/utils/brand.config'; @Injectable() export class UploadWidgetTool implements AgentToolInterface { @@ -13,11 +17,17 @@ export class UploadWidgetTool implements AgentToolInterface { mcpOnly = true; run() { + // The upstream CLI defaults to the upstream cloud, so private files are + // pointed at this deployment's public API instead + const publicApiDocs = joinBrandUrl( + getBrandConfig().docsUrl, + 'public-api/introduction' + ); return createTool({ id: 'uploadWidgetTool', description: `Show the user an upload widget to add images or videos from their own device to the media library. Use this when the user wants to attach a local file to a post. When the media is already available on a public URL, use uploadFromUrlTool instead. -The widget is only displayed by apps that support MCP Apps (interactive UI); in any other app no widget appears, and local files go through the Postiz CLI (postiz upload, https://docs.postiz.com/cli/introduction) or the public API (POST /public/v1/upload with an API key, https://docs.postiz.com/public-api/introduction). uploadFromUrlTool is only for public URLs. +The widget is only displayed by apps that support MCP Apps (interactive UI); in any other app no widget appears, and local files go through the public API (POST /public/v1/upload with an API key, ${publicApiDocs}). uploadFromUrlTool is only for public URLs. Returns a sessionId: the files the user uploads are reported in the conversation, and can also be read with uploadWidgetStatusTool.`, mcp: { annotations: { diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 1b135048f2..1e83241117 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -24,6 +24,7 @@ import { createReadStream, statSync } from 'fs'; import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; import { setHeartbeatDetails } from '@gitroom/nestjs-libraries/temporal/temporal.heartbeat'; import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; +import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; const clientAndYoutube = () => { const client = new google.auth.OAuth2({ @@ -266,7 +267,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { }`; if (text.includes('invalid_rapt')) { - return `because your organization's Google Workspace session policy invalidated its access. Ask your Google Workspace admin to mark Postiz as a trusted app and turn on "Exempt trusted apps" (https://support.google.com/a/answer/9368756) to prevent this from happening again`; + return `because your organization's Google Workspace session policy invalidated its access. Ask your Google Workspace admin to mark ${getBrandConfig().name} as a trusted app and turn on "Exempt trusted apps" (https://support.google.com/a/answer/9368756) to prevent this from happening again`; } return undefined; From a445c39955a99134dd1e7f2efdaad887d94ed0fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:05:47 +0000 Subject: [PATCH 95/99] fix(sync): clear the React Compiler lint errors in the v2.25.1 lines 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 Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ --- .../components/launches/calendar.context.tsx | 8 +++- .../launches/helpers/date.picker.tsx | 2 +- .../src/components/launches/menu/menu.tsx | 2 +- .../components/launches/select.channels.tsx | 6 +-- .../components/launches/select.customer.tsx | 2 +- .../src/components/new-launch/editor.tsx | 44 +++++++++++++------ .../components/new-launch/manage.modal.tsx | 2 +- .../components/settings/teams.component.tsx | 5 +-- docs/fork-delta.md | 1 + 9 files changed, 47 insertions(+), 25 deletions(-) diff --git a/apps/frontend/src/components/launches/calendar.context.tsx b/apps/frontend/src/components/launches/calendar.context.tsx index e28bb2a190..c30967b92b 100644 --- a/apps/frontend/src/components/launches/calendar.context.tsx +++ b/apps/frontend/src/components/launches/calendar.context.tsx @@ -211,9 +211,13 @@ export const CalendarWeekProvider: FC<{ display, }); - useEffect(() => { + // a customer switch clears the channel filter, adjusted while rendering + // instead of in an effect so the stale filter is never rendered + const [channelsCustomer, setChannelsCustomer] = useState(filters.customer); + if (channelsCustomer !== filters.customer) { + setChannelsCustomer(filters.customer); setSelectedChannels(null); - }, [filters.customer]); + } const params = useMemo(() => { return new URLSearchParams({ diff --git a/apps/frontend/src/components/launches/helpers/date.picker.tsx b/apps/frontend/src/components/launches/helpers/date.picker.tsx index b1439118c0..ac90c949eb 100644 --- a/apps/frontend/src/components/launches/helpers/date.picker.tsx +++ b/apps/frontend/src/components/launches/helpers/date.picker.tsx @@ -91,7 +91,7 @@ export const DatePickerPanel: FC<{ ) ); }, - [date] + [date, onChange] ); if (sheet) { diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index d479252f0c..55fd803a0b 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -51,7 +51,7 @@ const RenameChannelModal: FC<{ }); onSave(); close(); - }, [channelName, id]); + }, [channelName, id, fetch, onSave, close]); return (
    { setSelectedChannels(allSelected ? [] : null); - }, [allSelected]); + }, [allSelected, setSelectedChannels]); const toggle = useCallback( (id: string) => () => { @@ -56,7 +56,7 @@ export const SelectChannels: FC<{ : [...selectedIds, id]; setSelectedChannels(next.length === channels.length ? null : next); }, - [selectedIds, channels] + [selectedIds, channels, setSelectedChannels] ); if (channels.length <= 1) { diff --git a/apps/frontend/src/components/launches/select.customer.tsx b/apps/frontend/src/components/launches/select.customer.tsx index 617823d2e8..3fcc0a0b16 100644 --- a/apps/frontend/src/components/launches/select.customer.tsx +++ b/apps/frontend/src/components/launches/select.customer.tsx @@ -60,7 +60,7 @@ export const SelectCustomer: FC<{ setOpen(false); setCurrent('global'); }, - [onChange] + [onChange, setCurrent, t, toaster] ); if (totalCustomers <= 1) { diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx index 25385f1f41..75b3e1959f 100644 --- a/apps/frontend/src/components/new-launch/editor.tsx +++ b/apps/frontend/src/components/new-launch/editor.tsx @@ -655,10 +655,14 @@ export const Editor: FC<{ const t = useT(); const toaster = useToaster(); const editorRef = useRef(undefined); + // the toolbar gets the editor from state, a ref can't be read while rendering + const [editorInstance, setEditorInstance] = useState(undefined); const [loading, setLoading] = useState(false); // the uploader is created once, so it reads the latest value from here const inlineImagesRef = useRef(inlineImages); - inlineImagesRef.current = inlineImages; + useEffect(() => { + inlineImagesRef.current = inlineImages; + }, [inlineImages]); const uppy = useUppyUploader({ onUploadSuccess: (result: any) => { @@ -858,13 +862,12 @@ export const Editor: FC<{ const editorKey = `${editorType}-${inlineImages}`; const [loadedEditor, setLoadedEditor] = useState(editorKey); const [showEditor, setShowEditor] = useState(true); - useEffect(() => { - if (editorKey === loadedEditor) { - return; - } + // adjusted while rendering instead of in an effect, so the editor with the + // old extensions is never rendered + if (editorKey !== loadedEditor) { setLoadedEditor(editorKey); setShowEditor(false); - }, [editorKey]); + } useEffect(() => { if (showEditor) { @@ -962,6 +965,7 @@ export const Editor: FC<{ onChange={props.onChange} paste={paste} autoFocus={autoFocus} + onEditor={setEditorInstance} ref={editorRef} />
    @@ -1034,15 +1038,15 @@ export const Editor: FC<{ } toolBar={
    - + {editorType !== 'none' && ( <> @@ -1051,15 +1055,15 @@ export const Editor: FC<{ identifier !== 'telegram' && ( <> @@ -1156,8 +1160,18 @@ export const OnlyEditor = forwardRef< onChange: (value: string) => void; paste?: (event: ClipboardEvent | File[], slice?: Slice) => boolean; autoFocus?: boolean; + onEditor?: (editor: any) => void; } ->(({ editorType, inlineImages, value, onChange, paste, autoFocus }, ref) => { +>((props, ref) => { + const { + editorType, + inlineImages, + value, + onChange, + paste, + autoFocus, + onEditor, + } = props; const t = useT(); const fetch = useFetch(); @@ -1360,5 +1374,9 @@ export const OnlyEditor = forwardRef< editor, })); + useEffect(() => { + onEditor?.(editor); + }, [editor, onEditor]); + return ; }); diff --git a/apps/frontend/src/components/new-launch/manage.modal.tsx b/apps/frontend/src/components/new-launch/manage.modal.tsx index a361be7df7..6988d4df1b 100644 --- a/apps/frontend/src/components/new-launch/manage.modal.tsx +++ b/apps/frontend/src/components/new-launch/manage.modal.tsx @@ -136,7 +136,7 @@ export const ManageModal: FC = (props) => { setDate(newDate); }, - [channelDate, current] + [channelDate, current, setChannelDate, setDate] ); // the date sheet applies its changes only on save diff --git a/apps/frontend/src/components/settings/teams.component.tsx b/apps/frontend/src/components/settings/teams.component.tsx index a96f2aaec5..3bd48a26b9 100644 --- a/apps/frontend/src/components/settings/teams.component.tsx +++ b/apps/frontend/src/components/settings/teams.component.tsx @@ -48,6 +48,7 @@ export const AddMember = () => { control: form.control, name: 'sendEmail', }); + const t = useT(); const submit = useCallback( async (values: { email: string; role: string; sendEmail: boolean }) => { const response = await fetch('/settings/team', { @@ -73,11 +74,9 @@ export const AddMember = () => { modals.closeAll(); toast.show(t('link_copied_to_clipboard', 'Link copied to clipboard')); }, - [] + [fetch, modals, toast, t] ); - const t = useT(); - return (
    diff --git a/docs/fork-delta.md b/docs/fork-delta.md index 86deaf403c..51e6ff7c89 100644 --- a/docs/fork-delta.md +++ b/docs/fork-delta.md @@ -34,6 +34,7 @@ Last verified: 2026-09-21 (fork `dev` vs `upstream/main`). | `apps/frontend/src/components/billing/main.billing.component.tsx` | DOS mode on top of upstream's v2.25.1 billing redesign: monthly-only Plus ($9) / Pro ($19) cards (`PlanCard` `label` prop, yearly toggle hidden, only FREE/STANDARD/PRO), portal/cancel box gated by `showPortalAndCancel`, `BillingHistory` hidden (invoices live in the dos.me portal, so the portal button keeps the "/ Invoices History" label), upstream's cancel-toggle toasts skipped (a dos.me cancel never toggles), non-OK responses toasted | Medium | | `libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts`, `upload.widget.tool.ts` | MCP descriptions point agents at this deployment's public API and `brand.docsUrl` instead of the upstream CLI (which defaults to the upstream cloud) and upstream docs | Low | | `libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts` | `refreshErrorMessage` names the deployment brand, so the Workspace admin trusts the right OAuth app | Low | +| v2.25.1 React Compiler lint fixes: `new-launch/editor.tsx`, `new-launch/manage.modal.tsx`, `launches/calendar.context.tsx`, `launches/select.channels.tsx`, `launches/select.customer.tsx`, `launches/menu/menu.tsx`, `launches/helpers/date.picker.tsx`, `settings/teams.component.tsx` | The ESLint code-scanning gate fails on `react-hooks` errors in lines the sync brings in. Fixed without behaviour change: missing `useCallback` deps added, the toolbar gets the TipTap editor from state (`OnlyEditor` `onEditor` prop) instead of reading `editorRef` while rendering, the editor-rebuild and customer-switch resets adjust state while rendering instead of in an effect, `t` declared before use. Worth sending upstream so the delta disappears | High (hottest upstream files - re-check on every sync) | | `package.json` `prisma-db-push` | Fork keeps `pnpm exec prisma` (no `prisma-reset` / `docker-*` scripts); took upstream's removal of `--accept-data-loss` in the v2.25.1 sync | Low | ## 3. Planned divergence (accepted, not yet done) From a0be876a06216fa4c558f5e4a093dc39415dac77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:06:50 +0000 Subject: [PATCH 96/99] docs(fork-delta): full paths and accurate wording for the v2.25.1 lint fixes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ --- docs/fork-delta.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fork-delta.md b/docs/fork-delta.md index 51e6ff7c89..50cf0dfa10 100644 --- a/docs/fork-delta.md +++ b/docs/fork-delta.md @@ -34,7 +34,7 @@ Last verified: 2026-09-21 (fork `dev` vs `upstream/main`). | `apps/frontend/src/components/billing/main.billing.component.tsx` | DOS mode on top of upstream's v2.25.1 billing redesign: monthly-only Plus ($9) / Pro ($19) cards (`PlanCard` `label` prop, yearly toggle hidden, only FREE/STANDARD/PRO), portal/cancel box gated by `showPortalAndCancel`, `BillingHistory` hidden (invoices live in the dos.me portal, so the portal button keeps the "/ Invoices History" label), upstream's cancel-toggle toasts skipped (a dos.me cancel never toggles), non-OK responses toasted | Medium | | `libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts`, `upload.widget.tool.ts` | MCP descriptions point agents at this deployment's public API and `brand.docsUrl` instead of the upstream CLI (which defaults to the upstream cloud) and upstream docs | Low | | `libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts` | `refreshErrorMessage` names the deployment brand, so the Workspace admin trusts the right OAuth app | Low | -| v2.25.1 React Compiler lint fixes: `new-launch/editor.tsx`, `new-launch/manage.modal.tsx`, `launches/calendar.context.tsx`, `launches/select.channels.tsx`, `launches/select.customer.tsx`, `launches/menu/menu.tsx`, `launches/helpers/date.picker.tsx`, `settings/teams.component.tsx` | The ESLint code-scanning gate fails on `react-hooks` errors in lines the sync brings in. Fixed without behaviour change: missing `useCallback` deps added, the toolbar gets the TipTap editor from state (`OnlyEditor` `onEditor` prop) instead of reading `editorRef` while rendering, the editor-rebuild and customer-switch resets adjust state while rendering instead of in an effect, `t` declared before use. Worth sending upstream so the delta disappears | High (hottest upstream files - re-check on every sync) | +| `apps/frontend/src/components/new-launch/editor.tsx`, `apps/frontend/src/components/new-launch/manage.modal.tsx`, `apps/frontend/src/components/launches/calendar.context.tsx`, `apps/frontend/src/components/launches/select.channels.tsx`, `apps/frontend/src/components/launches/select.customer.tsx`, `apps/frontend/src/components/launches/menu/menu.tsx`, `apps/frontend/src/components/launches/helpers/date.picker.tsx`, `apps/frontend/src/components/settings/teams.component.tsx` | The ESLint code-scanning gate fails on React Compiler `react-hooks` errors in lines the v2.25.1 sync brought in. Fixes: missing `useCallback` deps added, `t` declared before use, the toolbar gets the TipTap editor from state (`OnlyEditor` `onEditor` prop) instead of reading `editorRef` while rendering (so it now has the editor as soon as it is created), and the editor-rebuild and customer-switch resets adjust state while rendering instead of in an effect (no render with the stale value). Worth sending upstream so the delta disappears | High (hottest upstream files - re-check on every sync) | | `package.json` `prisma-db-push` | Fork keeps `pnpm exec prisma` (no `prisma-reset` / `docker-*` scripts); took upstream's removal of `--accept-data-loss` in the v2.25.1 sync | Low | ## 3. Planned divergence (accepted, not yet done) From 813f4d542f934e54d922fc1d98df17a881c75470 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:09:40 +0000 Subject: [PATCH 97/99] fix(sync): keep the connect-failure log format string constant 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 Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ --- apps/backend/src/api/routes/no.auth.integrations.controller.ts | 2 +- docs/fork-delta.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index d380017858..634709a010 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -176,7 +176,7 @@ export class NoAuthIntegrationsController { }); } - console.log(`Authentication failed for ${integration}:`, err); + console.log('Authentication failed:', integration, err); return res({ error: 'Authentication failed', accessToken: '', diff --git a/docs/fork-delta.md b/docs/fork-delta.md index 50cf0dfa10..6647eca996 100644 --- a/docs/fork-delta.md +++ b/docs/fork-delta.md @@ -30,6 +30,7 @@ Last verified: 2026-09-21 (fork `dev` vs `upstream/main`). | `libraries/nestjs-libraries/src/openai/openai.service.ts` | `pickClips` (sync 2026-09-21) uses the fork `getOpenAIClient()`/`getModel()` pattern so OPENAI_BASE_URL / OPENAI_MODEL_NAME keep working; upstream hardcodes a module-level client and model | Low (file gains upstream methods over time) | | `libraries/nestjs-libraries/src/upload/local.storage.ts` | `removeFile` containment guard (path.relative check refusing to unlink outside the upload directory) - CodeQL js/path-injection hardening | Low | | `libraries/nestjs-libraries/src/database/prisma/clipping/clipping.service.ts` | Fixed-format failure logging (data passed as arguments, not interpolated) - CodeQL js/tainted-format-string hardening | Low | +| `apps/backend/src/api/routes/no.auth.integrations.controller.ts` | "Authentication failed" log (added upstream in v2.25.1) passes the `integration` route param as an argument instead of interpolating it into the format string - CodeQL js/tainted-format-string hardening | Low | | `apps/backend/src/api/routes/billing.controller.ts` | DOS shared billing branches (subscribe, portal, current billing, cancel, prorate). Since the v2.25.1 sync, `/cancel` in DOS mode emails the cancel feedback after dos.me accepts the cancel; the Stripe path keeps upstream's "email only when `cancel_at` is set" | Medium (upstream reworks billing regularly) | | `apps/frontend/src/components/billing/main.billing.component.tsx` | DOS mode on top of upstream's v2.25.1 billing redesign: monthly-only Plus ($9) / Pro ($19) cards (`PlanCard` `label` prop, yearly toggle hidden, only FREE/STANDARD/PRO), portal/cancel box gated by `showPortalAndCancel`, `BillingHistory` hidden (invoices live in the dos.me portal, so the portal button keeps the "/ Invoices History" label), upstream's cancel-toggle toasts skipped (a dos.me cancel never toggles), non-OK responses toasted | Medium | | `libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts`, `upload.widget.tool.ts` | MCP descriptions point agents at this deployment's public API and `brand.docsUrl` instead of the upstream CLI (which defaults to the upstream cloud) and upstream docs | Low | From 540a9727ee4048f9230aa5c3da7ec9ef0f87b809 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:14:12 +0000 Subject: [PATCH 98/99] fix(sync): clear the ESLint warnings on the v2.25.1 lines 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 (the badge also gets its alt text) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ --- .../src/components/launches/menu/menu.tsx | 4 +-- .../components/launches/select.channels.tsx | 7 ++-- .../src/components/layout/check.payment.tsx | 2 +- .../src/components/media/media.component.tsx | 6 ++-- .../components/new-launch/manage.modal.tsx | 32 ++++++++++++++++--- .../new-launch/picks.socials.component.tsx | 3 +- .../providers/high.order.provider.tsx | 2 +- docs/fork-delta.md | 2 +- 8 files changed, 42 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index 55fd803a0b..3518082486 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -380,7 +380,7 @@ export const Menu: FC<{ ), }); setShow(false); - }, [integrations, t]); + }, [integrations, id, modal, mutate, toast, t]); const resetChannelName = useCallback(async () => { await fetch(`/integrations/${id}/custom-name`, { method: 'PUT', @@ -389,7 +389,7 @@ export const Menu: FC<{ mutate(); toast.show(t('channel_name_reset', 'Channel name reset'), 'success'); setShow(false); - }, [id, t]); + }, [id, fetch, mutate, toast, t]); const updateCredentials = useCallback(() => { modal.openModal({ title: t('custom_url', 'Custom URL'), diff --git a/apps/frontend/src/components/launches/select.channels.tsx b/apps/frontend/src/components/launches/select.channels.tsx index 513139ca95..4a7540f500 100644 --- a/apps/frontend/src/components/launches/select.channels.tsx +++ b/apps/frontend/src/components/launches/select.channels.tsx @@ -10,6 +10,7 @@ import { Checkbox } from '@gitroom/react/form/checkbox'; import { FilterIcon } from '@gitroom/frontend/components/ui/icons'; import { Check } from '@gitroom/frontend/components/launches/tags.component'; import { IntegrationListRow } from '@gitroom/frontend/components/new-launch/picks.socials.component'; +import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback'; export const SelectChannels: FC<{ list?: boolean; @@ -131,10 +132,12 @@ export const SelectChannels: FC<{ checked={selectedIds.includes(p.id)} onChange={toggle(p.id)} /> -
    {p.name}
    diff --git a/apps/frontend/src/components/layout/check.payment.tsx b/apps/frontend/src/components/layout/check.payment.tsx index 97c813083f..bfb32fa1a3 100644 --- a/apps/frontend/src/components/layout/check.payment.tsx +++ b/apps/frontend/src/components/layout/check.payment.tsx @@ -120,7 +120,7 @@ export const ChannelsAfterPayment: FC<{ ), }); })(); - }, [check, integrations]); + }, [check, integrations, fetch, modal, t]); return null; }; diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index dbd5bf9b83..0116085bb2 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -448,7 +448,7 @@ export const MediaBox: FC<{ setPage(Math.max(0, (result?.pages || 1) - 1)); } } - }, [selected, mutate, page, t]); + }, [selected, fetch, mutate, page, t]); const btn = useMemo(() => { return ( @@ -822,7 +822,7 @@ export const MultiMediaComponent: FC<{ }, }); }, - [currentMedia, insertInContent] + [currentMedia, insertInContent, name, onChange] ); const showModal = useCallback(() => { modals.openModal({ @@ -840,7 +840,7 @@ export const MultiMediaComponent: FC<{ /> ), }); - }, [changeMedia, insertInContent, t]); + }, [changeMedia, insertInContent, modals, t]); const clearMedia = useCallback( (topIndex: number) => () => { diff --git a/apps/frontend/src/components/new-launch/manage.modal.tsx b/apps/frontend/src/components/new-launch/manage.modal.tsx index 6988d4df1b..0294190652 100644 --- a/apps/frontend/src/components/new-launch/manage.modal.tsx +++ b/apps/frontend/src/components/new-launch/manage.modal.tsx @@ -143,10 +143,15 @@ export const ManageModal: FC = (props) => { const [dateDraft, setDateDraft] = useState(date); // the channels the post already has, each with its own post - const existingPosts = [existingData, ...(existingData.siblings || [])]; + const existingPosts = useMemo( + () => [existingData, ...(existingData.siblings || [])], + [existingData] + ); // the channel in view, or the one that was opened - const channel = - existingPosts.find((p) => p.integration === current) || existingData; + const channel = useMemo( + () => existingPosts.find((p) => p.integration === current) || existingData, + [existingPosts, current, existingData] + ); useEffect(() => { if (hide) { @@ -312,7 +317,16 @@ export const ManageModal: FC = (props) => { mutate(); modal.closeAll(); return; - }, [existingData, mutate, modal, current, integrations]); + }, [ + existingData, + existingPosts, + channel, + fetch, + mutate, + modal, + integrations, + t, + ]); const schedule = useCallback( (type: 'draft' | 'now' | 'schedule' | 'update') => async () => { @@ -796,12 +810,20 @@ export const ManageModal: FC = (props) => { tags, date, channelDates, - current, integrations, selectedIntegrations, addEditSets, dummy, shortlinkPreferenceData, + existingData, + existingPosts, + channel, + customClose, + fetch, + modal, + mutate, + t, + toaster, ] ); diff --git a/apps/frontend/src/components/new-launch/picks.socials.component.tsx b/apps/frontend/src/components/new-launch/picks.socials.component.tsx index 6ffe4e5ff2..a8404d5799 100644 --- a/apps/frontend/src/components/new-launch/picks.socials.component.tsx +++ b/apps/frontend/src/components/new-launch/picks.socials.component.tsx @@ -12,9 +12,10 @@ import { Integrations } from '@gitroom/frontend/components/launches/calendar.con const PlatformBadge: FC<{ integration: Integrations }> = ({ integration }) => { return integration.identifier === 'youtube' ? ( - {integration.identifier} ) : ( diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx index d20e224e6c..180dbcd8bc 100644 --- a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx @@ -195,7 +195,7 @@ export const withProvider = function (params: { if (current) { setInlineImages(inlineImages); } - }, [current, inlineImages]); + }, [current, inlineImages, setInlineImages]); useImperativeHandle( ref, diff --git a/docs/fork-delta.md b/docs/fork-delta.md index 6647eca996..ab5f821626 100644 --- a/docs/fork-delta.md +++ b/docs/fork-delta.md @@ -35,7 +35,7 @@ Last verified: 2026-09-21 (fork `dev` vs `upstream/main`). | `apps/frontend/src/components/billing/main.billing.component.tsx` | DOS mode on top of upstream's v2.25.1 billing redesign: monthly-only Plus ($9) / Pro ($19) cards (`PlanCard` `label` prop, yearly toggle hidden, only FREE/STANDARD/PRO), portal/cancel box gated by `showPortalAndCancel`, `BillingHistory` hidden (invoices live in the dos.me portal, so the portal button keeps the "/ Invoices History" label), upstream's cancel-toggle toasts skipped (a dos.me cancel never toggles), non-OK responses toasted | Medium | | `libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts`, `upload.widget.tool.ts` | MCP descriptions point agents at this deployment's public API and `brand.docsUrl` instead of the upstream CLI (which defaults to the upstream cloud) and upstream docs | Low | | `libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts` | `refreshErrorMessage` names the deployment brand, so the Workspace admin trusts the right OAuth app | Low | -| `apps/frontend/src/components/new-launch/editor.tsx`, `apps/frontend/src/components/new-launch/manage.modal.tsx`, `apps/frontend/src/components/launches/calendar.context.tsx`, `apps/frontend/src/components/launches/select.channels.tsx`, `apps/frontend/src/components/launches/select.customer.tsx`, `apps/frontend/src/components/launches/menu/menu.tsx`, `apps/frontend/src/components/launches/helpers/date.picker.tsx`, `apps/frontend/src/components/settings/teams.component.tsx` | The ESLint code-scanning gate fails on React Compiler `react-hooks` errors in lines the v2.25.1 sync brought in. Fixes: missing `useCallback` deps added, `t` declared before use, the toolbar gets the TipTap editor from state (`OnlyEditor` `onEditor` prop) instead of reading `editorRef` while rendering (so it now has the editor as soon as it is created), and the editor-rebuild and customer-switch resets adjust state while rendering instead of in an effect (no render with the stale value). Worth sending upstream so the delta disappears | High (hottest upstream files - re-check on every sync) | +| `apps/frontend/src/components/new-launch/editor.tsx`, `apps/frontend/src/components/new-launch/manage.modal.tsx`, `apps/frontend/src/components/launches/calendar.context.tsx`, `apps/frontend/src/components/launches/select.channels.tsx`, `apps/frontend/src/components/launches/select.customer.tsx`, `apps/frontend/src/components/launches/menu/menu.tsx`, `apps/frontend/src/components/launches/helpers/date.picker.tsx`, `apps/frontend/src/components/settings/teams.component.tsx`, `apps/frontend/src/components/media/media.component.tsx`, `apps/frontend/src/components/layout/check.payment.tsx`, `apps/frontend/src/components/new-launch/picks.socials.component.tsx`, `apps/frontend/src/components/new-launch/providers/high.order.provider.tsx` | The ESLint code-scanning gate fails on React Compiler `react-hooks` errors in lines the v2.25.1 sync brought in. Fixes: missing `useCallback` deps added, `t` declared before use, the toolbar gets the TipTap editor from state (`OnlyEditor` `onEditor` prop) instead of reading `editorRef` while rendering (so it now has the editor as soon as it is created), and the editor-rebuild and customer-switch resets adjust state while rendering instead of in an effect (no render with the stale value). The ESLint warnings on those lines are cleared too: hook deps completed (`existingPosts` / `channel` memoised in `manage.modal.tsx` so the composer's save and delete callbacks see the current post), and two `` swapped for the repo's `SafeImage` / `ImageWithFallback`. Worth sending upstream so the delta disappears | High (hottest upstream files - re-check on every sync) | | `package.json` `prisma-db-push` | Fork keeps `pnpm exec prisma` (no `prisma-reset` / `docker-*` scripts); took upstream's removal of `--accept-data-loss` in the v2.25.1 sync | Low | ## 3. Planned divergence (accepted, not yet done) From 6114dc54f552dedddeaf1fc392bd9ccce0055a7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:26:00 +0000 Subject: [PATCH 99/99] fix(sync): decode & last in the html branch of stripHtmlValidation 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 "& decoded before " /   / '" order count as new code. A post containing the literal text " (stored as &quot;) came out as a bare quote. & 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 813f4d54 is reverted to upstream's line - it was never flagged and only added fork delta. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XFwPKMsHCF5VarWHQAsHtJ --- .../src/api/routes/no.auth.integrations.controller.ts | 2 +- docs/fork-delta.md | 2 +- libraries/helpers/src/utils/strip.html.validation.ts | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index 634709a010..d380017858 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -176,7 +176,7 @@ export class NoAuthIntegrationsController { }); } - console.log('Authentication failed:', integration, err); + console.log(`Authentication failed for ${integration}:`, err); return res({ error: 'Authentication failed', accessToken: '', diff --git a/docs/fork-delta.md b/docs/fork-delta.md index ab5f821626..7fbaf7061c 100644 --- a/docs/fork-delta.md +++ b/docs/fork-delta.md @@ -30,7 +30,7 @@ Last verified: 2026-09-21 (fork `dev` vs `upstream/main`). | `libraries/nestjs-libraries/src/openai/openai.service.ts` | `pickClips` (sync 2026-09-21) uses the fork `getOpenAIClient()`/`getModel()` pattern so OPENAI_BASE_URL / OPENAI_MODEL_NAME keep working; upstream hardcodes a module-level client and model | Low (file gains upstream methods over time) | | `libraries/nestjs-libraries/src/upload/local.storage.ts` | `removeFile` containment guard (path.relative check refusing to unlink outside the upload directory) - CodeQL js/path-injection hardening | Low | | `libraries/nestjs-libraries/src/database/prisma/clipping/clipping.service.ts` | Fixed-format failure logging (data passed as arguments, not interpolated) - CodeQL js/tainted-format-string hardening | Low | -| `apps/backend/src/api/routes/no.auth.integrations.controller.ts` | "Authentication failed" log (added upstream in v2.25.1) passes the `integration` route param as an argument instead of interpolating it into the format string - CodeQL js/tainted-format-string hardening | Low | +| `libraries/helpers/src/utils/strip.html.validation.ts` | `html` branch decodes `&` last so an escaped entity (`&quot;`) is not decoded twice - CodeQL js/double-escaping (high), flagged once upstream v2.25.1 reformatted the block. The same order issue in the other branches is pre-existing and left as upstream has it | Low | | `apps/backend/src/api/routes/billing.controller.ts` | DOS shared billing branches (subscribe, portal, current billing, cancel, prorate). Since the v2.25.1 sync, `/cancel` in DOS mode emails the cancel feedback after dos.me accepts the cancel; the Stripe path keeps upstream's "email only when `cancel_at` is set" | Medium (upstream reworks billing regularly) | | `apps/frontend/src/components/billing/main.billing.component.tsx` | DOS mode on top of upstream's v2.25.1 billing redesign: monthly-only Plus ($9) / Pro ($19) cards (`PlanCard` `label` prop, yearly toggle hidden, only FREE/STANDARD/PRO), portal/cancel box gated by `showPortalAndCancel`, `BillingHistory` hidden (invoices live in the dos.me portal, so the portal button keeps the "/ Invoices History" label), upstream's cancel-toggle toasts skipped (a dos.me cancel never toggles), non-OK responses toasted | Medium | | `libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts`, `upload.widget.tool.ts` | MCP descriptions point agents at this deployment's public API and `brand.docsUrl` instead of the upstream CLI (which defaults to the upstream cloud) and upstream docs | Low | diff --git a/libraries/helpers/src/utils/strip.html.validation.ts b/libraries/helpers/src/utils/strip.html.validation.ts index 719aa6eb85..243098ef0c 100644 --- a/libraries/helpers/src/utils/strip.html.validation.ts +++ b/libraries/helpers/src/utils/strip.html.validation.ts @@ -177,10 +177,11 @@ export const stripHtmlValidation = ( ) .replace(/>/gi, '>') .replace(/</gi, '<') - .replace(/&/gi, '&') .replace(/ /gi, ' ') .replace(/"/gi, '"') - .replace(/'/gi, "'"); + .replace(/'/gi, "'") + // last, so an escaped entity like &quot; isn't decoded twice + .replace(/&/gi, '&'); } if (type === 'markdown') {