diff --git a/CLAUDE.md b/CLAUDE.md index ee5b8ee247..e57048018a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,3 +95,4 @@ The backend app is mostly used to write controllers and import from the librarie - 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. +- 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. diff --git a/SECURITY.md b/SECURITY.md index 911a28459e..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. -- @egelhaus ([E-Mail](mailto:egelhaus@ennogelhaus.de)) +- Gelhaus Solutions (@egelhaus): [contact@gplatform.org](mailto:contact@gplatform.org) ### AI Reports diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index 595cd31a18..79b3997092 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -21,6 +21,11 @@ import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/us import { DosSharedBillingService } from '@gitroom/nestjs-libraries/dos-billing/dos-shared-billing.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') @@ -178,6 +183,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('/') async getCurrentBilling( @GetOrgFromRequest() org: Organization, @@ -198,15 +209,14 @@ 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 - ); - if (this._dosBilling.enabled()) { await this._dosBilling.cancel(user); + await this._notificationService.sendEmail( + process.env.EMAIL_FROM_ADDRESS, + 'Subscription Cancelled', + `Organization ${org.name} has cancelled their subscription because: ${body.feedback}`, + user.email + ); try { await this._dosBilling.syncOrg(user, org.id); } catch {} @@ -215,7 +225,18 @@ export class BillingController { return { cancel_at: sub?.cancelAt || null }; } - return (await this.provider(org)).setToCancel(org.id); + 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 result; } @Post('/prorate') diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 46acab6c4b..c9efef907c 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 { CustomerNameDto } from '@gitroom/nestjs-libraries/dtos/integrations/customer.name.dto'; import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto'; import { @@ -98,6 +99,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 { @@ -109,7 +119,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/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts index 7d8a6a5172..d380017858 100644 --- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts +++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts @@ -63,7 +63,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}`); @@ -137,7 +141,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, @@ -165,6 +176,7 @@ export class NoAuthIntegrationsController { }); } + console.log(`Authentication failed for ${integration}:`, err); return res({ error: 'Authentication failed', accessToken: '', 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/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/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 3ad250b294..9a5a5be9a3 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -97,7 +97,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') @@ -319,9 +319,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('/organizations') 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..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 @@ -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'; @@ -34,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 { ClippingDto } from '@gitroom/nestjs-libraries/dtos/clipping/clipping.dto'; import { ClippingService } from '@gitroom/nestjs-libraries/database/prisma/clipping/clipping.service'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; @@ -54,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') @@ -69,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') @@ -133,7 +138,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 ); @@ -230,6 +242,30 @@ export class PublicIntegrationsController { return { connected: true }; } + @Get('/media') + async getMedia( + @GetOrgFromRequest() org: Organization, + @Query() query: GetMediaDto + ) { + Sentry.metrics.count('public_api-request', 1); + 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 // whole organization @Get('/me') @@ -269,7 +305,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, @@ -411,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, @@ -573,7 +628,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/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index 432768784a..1fb7bbfc37 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'; @@ -18,7 +17,6 @@ export class AuthService { constructor( private _userService: UsersService, private _organizationService: OrganizationService, - private _notificationService: NotificationService, private _emailService: EmailService, private _providerManager: AuthProviderManager ) {} @@ -74,11 +72,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; } @@ -301,10 +298,10 @@ 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` + `You have requested to reset your password.
Click here to reset your password
The link will expire in 20 minutes` ); } @@ -354,11 +351,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/backend/src/services/auth/permissions/permissions.service.ts b/apps/backend/src/services/auth/permissions/permissions.service.ts index c8bc18b16b..8a1a7881a5 100644 --- a/apps/backend/src/services/auth/permissions/permissions.service.ts +++ b/apps/backend/src/services/auth/permissions/permissions.service.ts @@ -71,7 +71,7 @@ export class PermissionsService { // but only if the channel actually belongs to this org if (refreshChannelId) { const existingIntegration = - await this._integrationService.getIntegrationById( + await this._integrationService.getIntegrationByInternalId( orgId, refreshChannelId ); 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/apps/frontend/src/app/(app)/(site)/plugs/page.tsx b/apps/frontend/src/app/(app)/(site)/plugs/page.tsx index 119f3c9b63..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 { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; -export const metadata: Metadata = { - title: `${getBrandNameServerSide()} 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 32176265da..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,12 +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 { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server.side'; -export const metadata: Metadata = { - title: `${getBrandNameServerSide()} Integrations`, - description: '', -}; + export default async function Index() { - return ; + return redirect('/settings?tab=integrations'); } diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index 0c6d5cc031..75fc912b95 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -526,7 +526,10 @@ div div .set-font-family { .hideChatbase #chatbase-bubble-button, .hideChatbase #chatbase-bubble-window, -.hideChatbase #chatbase-message-bubbles { +.hideChatbase #chatbase-message-bubbles, +.bottomSheetOpen #chatbase-bubble-button, +.bottomSheetOpen #chatbase-bubble-window, +.bottomSheetOpen #chatbase-message-bubbles { display: none !important; } @@ -599,11 +602,35 @@ html[dir='rtl'] [dir='ltr'] { outline: none; } +// the composer on phones follows the app typography +@media (max-width: 1025px) { + .flat-editor .ProseMirror { + font-family: inherit; + font-size: 14px; + line-height: 20px; + } + + .flat-editor .tiptap p.is-editor-empty:first-child::before { + color: #656464; + } +} + .ProseMirror .mention { font-weight: bold; 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 +664,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/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/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/billing/billing.history.component.tsx b/apps/frontend/src/components/billing/billing.history.component.tsx new file mode 100644 index 0000000000..50c232090a --- /dev/null +++ b/apps/frontend/src/components/billing/billing.history.component.tsx @@ -0,0 +1,430 @@ +'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; +// invoices under $10 (trial starts, proration leftovers) are left out of the history +const MIN_AMOUNT = 1000; + +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 || []) + .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] + ); + + 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')}
+
+ + {!filtered.length ? ( +
+
+ + + + +
+
+ {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 91e46dd095..6ba9edb138 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'; @@ -57,6 +57,7 @@ export const FirstBillingComponent = () => { const [period, setPeriod] = useState('MONTHLY'); const [dosCheckoutLoading, setDosCheckoutLoading] = useState(false); const fetch = useFetch(); + const { mutate } = useSWRConfig(); const modals = useModals(); const t = useT(); const toaster = useToaster(); @@ -145,6 +146,12 @@ export const FirstBillingComponent = () => { } ); + useEffect(() => { + if (data?.blocked) { + mutate('/user/self'); + } + }, [data?.blocked, mutate]); + const price = useMemo( () => Object.entries(pricing).filter(([key]) => @@ -395,7 +402,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]; @@ -473,6 +480,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) => ( @@ -491,7 +507,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 6cd10bd347..3f33e7cdca 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'; @@ -40,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 ( @@ -57,89 +57,196 @@ export const Prorate: FC<{ ).json() ).price ); - setLoading(false); }, 500); useEffect(() => { setPrice(false); calculatePrice(); }, [period, pack]); - if (loading) { - return ( -
- -
- ); - } - if (price === false) { + // 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')} ${(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; + label?: 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, + label, + 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) => ( + + ))} +
+
+ {label || 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}
-
- ))} + ))} +
); }; @@ -242,6 +349,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' ); @@ -322,7 +431,12 @@ export const MainBillingComponent: FC<{ cancelAt: cancel_at, })); - toast.show('Subscription reactivated successfully'); + // only the Stripe cancel toggles, dos.me never answers "already active" + toast.show( + cancel_at && !sharedDosBilling + ? '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; } @@ -406,8 +520,15 @@ export const MainBillingComponent: FC<{ ...subs!, cancelAt: cancel_at, })); - if (cancel_at) - toast.show('Subscription set to canceled successfully'); + // a dos.me cancel never reactivates, a missing cancel_at there + // only means the plan sync did not report it yet + if (cancel_at || !sharedDosBilling) { + 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; @@ -488,6 +609,7 @@ export const MainBillingComponent: FC<{ revalidate: false, } ); + mutate('/billing/invoices'); toast.show('Subscription updated successfully'); } setLoading(false); @@ -522,159 +644,250 @@ export const MainBillingComponent: FC<{
); } + // dos.me plans are monthly-only Plus ($9) / Pro ($19) mapped onto the + // STANDARD / PRO tiers, so the yearly toggle and other tiers are hidden. + const yearly = !sharedDosBilling && monthlyOrYearly === 'on'; + const tiers = Object.keys(pricing); + const currentTier = subscription?.subscriptionTier || 'FREE'; + const plans = tiers.filter((name) => { + if (sharedDosBilling) { + return ['FREE', 'STANDARD', 'PRO'].includes(name); + } + return !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')}
+
+ {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' + )} +
+
{!sharedDosBilling && ( -
-
{t('monthly', 'MONTHLY')}
-
- +
+
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) => { - if (sharedDosBilling) { - return ['FREE', 'STANDARD', 'PRO'].includes(f[0]); - } - return !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' + : plans.length < 4 + ? 'grid-cols-3' + : '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 && showPortalAndCancel && ( +
+
+ -
- {sharedDosBilling && name === 'STANDARD' ? 'Plus' : name} -
-
-
- $ - {sharedDosBilling - ? name === 'PRO' - ? 19 - : name === 'STANDARD' - ? 9 - : 0 - : monthlyOrYearly === 'on' - ? values.year_price - : values.month_price} -
-
- {sharedDosBilling || monthlyOrYearly !== 'on' - ? '/month' - : '/year'} -
-
-
- {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 && showPortalAndCancel && ( -
- )} - - {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' - )} + {sharedDosBilling + ? t( + 'update_payment_method_invoices_history', + 'Update Payment Method / Invoices History' + ) + : t('billing_update_payment_method', 'Update payment method')} + +
)} + {/* dos.me invoices live in its own billing portal, not this org's Stripe customer */} + {!!subscription?.id && !sharedDosBilling && }
diff --git a/apps/frontend/src/components/developer/developer.component.tsx b/apps/frontend/src/components/developer/developer.component.tsx index 3c4fe7dcea..3d589edbc2 100644 --- a/apps/frontend/src/components/developer/developer.component.tsx +++ b/apps/frontend/src/components/developer/developer.component.tsx @@ -5,7 +5,10 @@ import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import useSWR from 'swr'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { useDecisionModal, useModals } from '@gitroom/frontend/components/layout/new-modal'; -import { MediaBox } from '@gitroom/frontend/components/media/media.component'; +import { + MediaBox, + mediaLibrarySize, +} from '@gitroom/frontend/components/media/media.component'; import copy from 'copy-to-clipboard'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useVariables } from '@gitroom/react/helpers/variable.context'; @@ -105,8 +108,8 @@ export const DeveloperComponent: FC = () => { askClose: false, closeOnEscape: true, fullScreen: true, - size: 'calc(100% - 80px)', - height: 'calc(100% - 80px)', + size: mediaLibrarySize(), + height: mediaLibrarySize(), children: (close: () => void) => ( {loading && ( @@ -166,6 +166,7 @@ export const AiImage: FC<{ xmlns="http://www.w3.org/2000/svg" width="16" height="16" + className="mobile:w-[20px] mobile:h-[20px]" viewBox="0 0 16 16" fill="none" > diff --git a/apps/frontend/src/components/launches/ai.video.tsx b/apps/frontend/src/components/launches/ai.video.tsx index ca54a0e56a..76f508fdd7 100644 --- a/apps/frontend/src/components/launches/ai.video.tsx +++ b/apps/frontend/src/components/launches/ai.video.tsx @@ -90,7 +90,7 @@ export const Modal: FC<{
-
+
-
+
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/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/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) && ( { return ( + {channelsOpen && (
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, fetch, onSave, close]); + return ( +
+ setChannelName(e.target.value)} + /> + +
+ ); +}; export const Menu: FC<{ canEnable: boolean; @@ -325,6 +361,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, id, modal, mutate, toast, 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, fetch, mutate, toast, t]); const updateCredentials = useCallback(() => { modal.openModal({ title: t('custom_url', 'Custom URL'), @@ -527,6 +592,52 @@ export const Menu: FC<{
)} +
+
+ + + +
+
+ {t('rename_channel', 'Rename channel')} +
+
+ {findIntegration?.name !== findIntegration?.originalName && ( +
+
+ + + +
+
+ {t('reset_channel_name', 'Reset to original name')} +
+
+ )}
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 (
= ({ list }) => { + const { integrations, customer, selectedChannels, setSelectedChannels } = + useCalendar(); + const t = useT(); + const [pos, setPos] = useState({}); + const [open, setOpen] = useState(false); + const ref = useClickOutside(() => { + if (open) { + setOpen(false); + } + }); + + const channels = useMemo( + () => + integrations.filter((i) => !customer || i.customer?.id === customer), + [integrations, customer] + ); + + const selectedIds = selectedChannels ?? channels.map((c) => c.id); + const allSelected = channels.every((c) => selectedIds.includes(c.id)); + + const openClose = useCallback(() => { + if (open) { + setOpen(false); + return; + } + + const { x, y, height } = ref.current?.getBoundingClientRect(); + setPos({ top: y + height, left: Math.min(x, window.innerWidth - 270) }); + setOpen(true); + }, [open, ref]); + + const toggleAll = useCallback(() => { + setSelectedChannels(allSelected ? [] : null); + }, [allSelected, setSelectedChannels]); + + const toggle = useCallback( + (id: string) => () => { + const next = selectedIds.includes(id) + ? selectedIds.filter((s) => s !== id) + : [...selectedIds, id]; + setSelectedChannels(next.length === channels.length ? null : next); + }, + [selectedIds, channels, setSelectedChannels] + ); + + if (channels.length <= 1) { + return null; + } + + if (list) { + // the customers are listed above the channels when there are any + const hasCustomers = + uniqBy(integrations, (i) => i?.customer?.id).length > 1; + return ( +
+ {hasCustomers && ( +
+ {t('channels', 'Channels')} +
+ )} +
+
+ {t('select_all', 'Select all')} +
+ {}} value={allSelected} /> +
+ {channels.map((p) => ( + + ))} +
+ ); + } + + return ( +
+
+ +
+ {open && ( +
+
+ +
+ {channels.map((p) => ( +
+ + +
{p.name}
+
+ ))} +
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/launches/select.customer.tsx b/apps/frontend/src/components/launches/select.customer.tsx index 2b42acc3fc..3fcc0a0b16 100644 --- a/apps/frontend/src/components/launches/select.customer.tsx +++ b/apps/frontend/src/components/launches/select.customer.tsx @@ -15,8 +15,9 @@ export const SelectCustomer: FC<{ onChange: (value: string) => void; integrations: Integrations[]; customer?: string; + list?: boolean; }> = (props) => { - const { onChange, integrations, customer: currentCustomer } = props; + const { onChange, integrations, customer: currentCustomer, list } = props; const { setCurrent } = useLaunchStore( useShallow((state) => ({ setCurrent: state.setCurrent, @@ -47,10 +48,63 @@ export const SelectCustomer: FC<{ const totalCustomers = useMemo(() => { return uniqBy(integrations, (i) => i?.customer?.id).length; }, [integrations]); + + const selectCustomer = useCallback( + (id: string) => () => { + toaster.show( + t('customer_socials_selected', 'Customer socials selected'), + 'success' + ); + setCustomer(id); + onChange(id); + setOpen(false); + setCurrent('global'); + }, + [onChange, setCurrent, t, toaster] + ); + if (totalCustomers <= 1) { return null; } + const customers = uniqBy(integrations, (u) => u?.customer?.name).filter( + (f) => f.customer?.name + ); + + if (list) { + return ( +
+
+ {t('customers', 'Customers')} +
+ {customers.map((p) => { + const selected = customer === p.customer?.id; + return ( +
+
+ {p.customer?.name} +
+
+ {selected && ( +
+ )} +
+
+ ); + })} +
+ ); + } + return (
{t('customers', 'Customers')}
- {uniqBy(integrations, (u) => u?.customer?.name) - .filter((f) => f.customer?.name) - .map((p) => ( -
{ - toaster.show( - t('customer_socials_selected', 'Customer socials selected'), - 'success' - ); - setCustomer(p.customer?.id); - onChange(p.customer?.id); - setOpen(false); - setCurrent('global') - }} - key={p.customer?.id} - className="p-[12px] hover:bg-newBgColor text-[14px] font-[500] h-[32px] flex items-center" - > - {p.customer?.name} -
- ))} + {customers.map((p) => ( +
+ {p.customer?.name} +
+ ))}
)}
diff --git a/apps/frontend/src/components/launches/tags.component.tsx b/apps/frontend/src/components/launches/tags.component.tsx index 3d08f00365..871ceb0345 100644 --- a/apps/frontend/src/components/launches/tags.component.tsx +++ b/apps/frontend/src/components/launches/tags.component.tsx @@ -17,12 +17,14 @@ import { DropdownArrowIcon, PlusIcon, CheckmarkIcon, + PencilIcon, } from '@gitroom/frontend/components/ui/icons'; export const TagsComponent: FC<{ name: string; label: string; initial: any[]; + list?: boolean; onChange: (event: { target: { value: any[]; @@ -50,6 +52,7 @@ export const TagsComponentInner: FC<{ label: string; initial: any[]; allTags: any; + list?: boolean; mutate: () => Promise; onChange: (event: { target: { @@ -57,7 +60,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,60 +106,168 @@ export const TagsComponentInner: FC<{ }, }); } - }, []); + }, [tagValue, name, onChange, mutate, modals, t]); - const deleteTag = useCallback( + 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 editTag = useCallback( 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 val: string | undefined = await new Promise((resolve) => { + modals.openModal({ + title: t('edit_tag', 'Edit Tag'), + onClose: () => resolve(undefined), + children: (close) => ( + + ), + }); }); - }); - if (!confirmed) { + 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, + }, + }); + } + } + } finally { setTimeout(() => { setAllowClose(true); }, 500); - return; } + }, + [tagValue, name, onChange, mutate, modals, t] + ); - await fetch(`/posts/tags/${tag.id}`, { - method: 'DELETE', - }); + const deleteTag = useCallback( + async (tag: any, e: React.MouseEvent) => { + setAllowClose(false); + e.stopPropagation(); + try { + const confirmed: boolean = await new Promise((resolve) => { + modals.openModal({ + title: t('delete_tag', 'Delete Tag'), + children: (close) => ( + + ), + }); + }); + + 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] ); + 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" > @@ -231,10 +324,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]" > ×
@@ -258,10 +357,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/check.payment.tsx b/apps/frontend/src/components/layout/check.payment.tsx index 35dd866d52..bfb32fa1a3 100644 --- a/apps/frontend/src/components/layout/check.payment.tsx +++ b/apps/frontend/src/components/layout/check.payment.tsx @@ -1,9 +1,11 @@ -import { FC, ReactNode, useCallback, useEffect, useState } from 'react'; +import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import Loading from '@gitroom/frontend/components/layout/loading'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { timer } from '@gitroom/helpers/utils/timer'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { useDecisionModal } from '@gitroom/frontend/components/layout/new-modal'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; export const CheckPayment: FC<{ check: string; mutate: () => 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, fetch, modal, t]); + + return null; +}; diff --git a/apps/frontend/src/components/layout/new-modal.tsx b/apps/frontend/src/components/layout/new-modal.tsx index 53a210f3a4..73e19df0e4 100644 --- a/apps/frontend/src/components/layout/new-modal.tsx +++ b/apps/frontend/src/components/layout/new-modal.tsx @@ -24,6 +24,7 @@ interface OpenModalInterface { top?: string | number; closeOnEscape?: boolean; withCloseButton?: boolean; + destructive?: boolean; askClose?: boolean; onClose?: () => void; children: ReactNode | ((close: () => void) => ReactNode); @@ -195,7 +196,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' @@ -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}
)} - {tab === 'teams' && !!user?.tier?.team_members && isGeneral && ( -
- -
- )} + {tab === 'teams' && + !!user?.tier?.team_members && + isGeneral && + user?.role !== 'USER' && ( +
+ +
+ )} {tab === 'webhooks' && !!user?.tier?.webhooks && (
@@ -195,7 +207,19 @@ export const SettingsPopup: FC<{
)} - {tab === 'api' && + {tab === 'integrations' && ( +
+ +
+ )} + + {tab === 'plugs' && ( +
+ +
+ )} + + {tab === 'agents' && !!user?.tier?.public_api && isGeneral && showLogout && ( @@ -204,9 +228,16 @@ 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 435fb385d9..d9b127a914 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -114,77 +114,103 @@ 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 = [ { - name: t('UGC', 'UGC'), + name: t('make_ugc', 'Make UGC'), icon: ( - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), path: '#', @@ -320,7 +346,7 @@ export const TopMenu: FC = () => { const { isGeneral, billingEnabled } = useVariables(); return ( <> -
+
{ // @ts-ignore user?.orgId && diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index 4eadb0e84c..0116085bb2 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, fetch, mutate, page, t]); + const btn = useMemo(() => { return ( @@ -620,7 +715,7 @@ export const MediaBox: FC<{ @@ -654,7 +749,12 @@ 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: { target: { name: string; @@ -677,8 +777,12 @@ export const MultiMediaComponent: FC<{ allData, dummy, toolBar, + toolBarEnd, + toolsClassName, + toolsStyle, information, mediaNotAvailable, + insertInContent, } = props; const user = useUser(); const modals = useModals(); @@ -704,6 +808,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 +822,7 @@ export const MultiMediaComponent: FC<{ }, }); }, - [currentMedia] + [currentMedia, insertInContent, name, onChange] ); const showModal = useCallback(() => { modals.openModal({ @@ -721,13 +830,17 @@ export const MultiMediaComponent: FC<{ askClose: false, closeOnEscape: true, fullScreen: true, - size: 'calc(100% - 80px)', - height: 'calc(100% - 80px)', + size: mediaLibrarySize(), + height: mediaLibrarySize(), children: (close) => ( - + ), }); - }, [changeMedia, t]); + }, [changeMedia, insertInContent, modals, t]); const clearMedia = useCallback( (topIndex: number) => () => { @@ -758,9 +871,9 @@ export const MultiMediaComponent: FC<{ return ( <> -
    -
    - {!!currentMedia && ( +
    +
    + {!!currentMedia?.length && ( @@ -772,7 +885,7 @@ export const MultiMediaComponent: FC<{ handle=".dragging" > {currentMedia.map((media, index) => ( -
    +
    @@ -812,7 +925,7 @@ export const MultiMediaComponent: FC<{ ) : ( )} @@ -820,65 +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')} -
    -
    +
    - - - - {!!user?.tier?.ai && ( - <> - - - - )} -
    - )} - {!mediaNotAvailable && ( -
    - -
    - )} - {!!toolBar && ( -
    - {toolBar} -
    - )} + )} + {!!toolBar && ( +
    + {toolBar} +
    + )} + {!!toolBar && ( +
    + )} +
    + {toolBarEnd} {information && ( -
    +
    {information}
    )} @@ -958,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) => ( ), @@ -989,7 +1127,10 @@ export const MediaComponent: FC<{ )}
    - +
    +
    + +
    +
    +
    + ), + }); + }); + + 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( @@ -194,26 +317,181 @@ export const ManageModal: FC = (props) => { mutate(); modal.closeAll(); return; - }, [existingData, mutate, modal]); + }, [ + existingData, + existingPosts, + channel, + fetch, + mutate, + modal, + integrations, + t, + ]); const schedule = useCallback( (type: 'draft' | 'now' | 'schedule' | 'update') => async () => { let republish = false; + // the channels saved as another type, the rest are saved as `type` + const saveAs: Record = {}; + const saveTypeOf = (id: string) => saveAs[id] || type; + // 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())) + ); + const others = existingPosts.filter((p) => p !== channel); + + // another channel whose date already passed would go out right away, it + // only gets its details saved + if (type === 'schedule') { + for (const p of others) { + if ( + !published.includes(p) && + dayjs().isAfter((channelDates[p.integration] || date).utc()) + ) { + saveAs[p.integration] = 'update'; + } + } + } + + // a draft of a post with other channels leaves the published ones as they are + if (type === 'draft' && existingData.siblings?.length) { + for (const p of published) { + if (p.posts[0].state === 'PUBLISHED') { + saveAs[p.integration] = 'update'; + } + } + } + + // the save that leaves a channel in its state + const asItIs = (p: (typeof existingPosts)[number]) => + p.posts?.[0]?.state === 'DRAFT' + ? 'draft' + : p.posts?.[0]?.state === 'QUEUE' && !published.includes(p) + ? 'schedule' + : 'update'; + + // like the delete, a save that changes the state of the other channels + // (scheduling their drafts, moving them to drafts) can do it only for + // the channel in view, the others keep their state if ( - (type === 'now' || type === 'schedule') && - (existingData?.posts?.[0]?.state === 'PUBLISHED' || - (existingData?.posts?.[0]?.state === 'QUEUE' && - dayjs().isAfter(date.utc()))) + type !== 'update' && + others.some( + (p) => + !saveAs[p.integration] && + // the republish question covers the published ones + (type === 'draft' || !published.includes(p)) && + asItIs(p) !== type + ) ) { - const channels = selectedIntegrations - .map((p) => p.integration.name) + const question = + type === 'draft' + ? t( + '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?' + ) + : type === 'now' + ? t( + 'post_now_in_all_channels_question', + 'This post was created for more than one channel. Do you want to post all of them now?' + ) + : t( + 'schedule_post_in_all_channels_question', + 'This post was created for more than one channel. Do you want to schedule all of them?' + ); + + const allChannels = + type === 'draft' + ? t('draft_all_channels', 'Move all channels to drafts') + : type === 'now' + ? t('post_now_all_channels', 'Post all channels now') + : t('schedule_all_channels', 'Schedule all channels'); + + const whichChannels = await new Promise((resolve) => { + modal.openModal({ + id: 'change-post-channels', + title: t('what_do_you_want_to_do', 'What do you want to do?'), + onClose: () => resolve(undefined), + children: ( +
    +
    {question}
    +
    +
    + +
    + {/* 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( + 'republish_at', + 'at' + )} ${(channelDates[p.integration] || date).format( + 'DD/MM/YYYY HH:mm' + )}` + ) .join(', '); 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: (
    @@ -221,8 +499,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( @@ -237,7 +514,10 @@ export const ManageModal: FC = (props) => { @@ -260,8 +543,14 @@ export const ManageModal: FC = (props) => { }); }); + if (!whatToDo) { + return; + } + if (whatToDo === 'update') { - type = 'update'; + for (const p of republishing) { + saveAs[p.integration] = 'update'; + } } if (whatToDo === 'republish') { @@ -285,7 +574,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 } : {}), @@ -333,47 +629,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; } } } @@ -446,23 +745,43 @@ export const ManageModal: FC = (props) => { if (addEditSets) { addEditSets(data); } else { - const response = await fetch('/posts', { - method: 'POST', - body: JSON.stringify(data), - }); + // 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 requestType of ['update', 'schedule', 'now', 'draft']) { + const request = { + type: requestType, + posts: posts.filter( + (p: any) => saveTypeOf(p.integration.id) === requestType + ), + }; + 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; } } @@ -485,130 +804,293 @@ export const ManageModal: FC = (props) => { } } }, - [ref, repeater, tags, date, addEditSets, dummy, shortlinkPreferenceData] + [ + ref, + repeater, + tags, + date, + channelDates, + integrations, + selectedIntegrations, + addEditSets, + dummy, + shortlinkPreferenceData, + existingData, + existingPosts, + channel, + customClose, + fetch, + modal, + mutate, + t, + toaster, + ] + ); + + const scheduleLabel = dummy + ? t('create_output', 'Create output') + : !existingData?.integration + ? t('add_to_calendar', 'Add to calendar') + : existingData?.posts?.[0]?.state === 'DRAFT' + ? t('schedule', 'Schedule') + : t('update', 'Update'); + + const mobileActions: MobileTopBarAction[] = addEditSets + ? [ + { + label: t('save_set', 'Save Set'), + variant: 'primary', + onClick: schedule('draft'), + }, + ] + : [ + { + label: scheduleLabel, + variant: 'primary', + onClick: schedule('schedule'), + }, + ...(!dummy + ? [ + { + label: t('post_now', 'Post Now'), + variant: 'secondary' as const, + onClick: schedule('now'), + }, + ] + : []), + { + label: t('save_as_draft', 'Save as Draft'), + variant: 'tertiary', + onClick: schedule('draft'), + }, + ]; + + // the settings sheet links to the channel settings only when there are any + const hasChannelSettings = useMemo(() => { + const identifiers = + current === 'global' + ? selectedIntegrations.map((p) => p.integration.identifier) + : [integrations.find((p) => p.id === current)?.identifier]; + + return identifiers.some( + (identifier) => + !!getProviderSettingsMeta( + Providers.find((p) => p.identifier === identifier)?.component + )?.SettingsComponent + ); + }, [current, selectedIntegrations, integrations]); + + // on phones, the settings, tags, repeat and delete live in a settings sheet + const settingsButton = !dummy && ( +
    setMobileSheet('settings')} + className="hidden mobile:flex shrink-0 w-[32px] h-[44px] justify-end items-center cursor-pointer text-[#A3A3A3]" + > + +
    + ); + + // phones have no room for the preview column, it replaces the editor instead + const previewButton = ( +
    setMobileTab(mobileTab === 'preview' ? 'edit' : 'preview')} + className={clsx( + 'hidden mobile:flex shrink-0 w-[32px] h-[44px] justify-end items-center cursor-pointer', + mobileTab === 'preview' ? 'text-[#FC69FF]' : 'text-[#A3A3A3]' + )} + > + +
    ); return (
    + + { + setDateDraft(pickerDate); + setMobileSheet('date'); + }} + /> +
    -
    -
    +
    +
    {t('create_post_title', 'Create Post')} -
    - -
    -
    -
    - {(['edit', 'preview'] as const).map((tab) => ( -
    setMobileTab(tab)} - className={clsx( - 'flex-1 pt-[6px] pb-[5px] cursor-pointer text-center rounded-[6px]', - mobileTab === tab && 'text-textItemFocused bg-boxFocused' - )} - > - {tab === 'edit' - ? t('edit', 'Edit') - : t('post_preview', 'Post Preview')} -
    - ))}
    -
    +
    -
    +
    -
    - {!dummy && ( +
    + {/* an existing post keeps its channels */} + {!dummy && !existingData.integration && ( )}
    + {!!existingData.integration && ( + <> +
    + +
    + {previewButton} + {settingsButton} + + )}
    -
    -
    {!existingData.integration && }
    -
    +
    +
    +
    + {(!existingData.integration || + !!existingData.siblings?.length) && } +
    + {!existingData.integration && ( + <> +
    setMobileSheet('channels')} + className="hidden mobile:flex shrink-0 w-[44px] h-[44px] rounded-[8px] bg-btnSimple justify-center items-center cursor-pointer" + > + +
    +
    + {previewButton} + {settingsButton} + + )} +
    +
    {!hide && }
    + {/* on phones the settings open as a bottom sheet */}
    -
    + {showSettings && (
    setShowSettings(!showSettings)} - className={clsx( - 'bg-[#612BD3] rounded-[12px] flex items-center gap-[8px] cursor-pointer p-[12px]', - showSettings ? '!rounded-b-none' : '' - )} - > -
    - {currentIntegrationText} -
    -
    - setShowSettings(false)} + className="hidden mobile:block fixed inset-0 z-[599] bg-popup backdrop-blur-[8px] animate-fadeIn touch-none" + /> + )} +
    +
    +
    + setShowSettings(false)} />
    -
    -
    -
    -
    setShowSettings(!showSettings)} + className={clsx( + 'bg-[#612BD3] rounded-[12px] flex items-center gap-[8px] cursor-pointer p-[12px] mobile:hidden', + showSettings ? '!rounded-b-none' : '' + )} + > +
    + {currentIntegrationText} +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + setShowSettings(false)} />
    +
    -
    @@ -621,21 +1103,17 @@ export const ManageModal: FC = (props) => {
    -
    -
    - {!dummy && ( +
    +
    + {/* keep a single tags component mounted, the tags sheet has its own */} + {!dummy && mobileSheet !== 'tags' && ( = (props) => { )}
    -
    +
    {existingData?.integration && ( )} - + {!addEditSets && ( )} {!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: () => { + setPickerDate(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..a8404d5799 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,63 @@ 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' ? ( + + ) : ( + + ); +}; + +// a channel row with a checkbox, for the phone sheets +export const IntegrationListRow: FC<{ + integration: Integrations; + selected: boolean; + onClick: () => void; +}> = ({ integration, selected, onClick }) => { + return ( +
    +
    + + +
    +
    + {integration.name} +
    + {}} value={selected} /> +
    + ); +}; + +export const PicksSocialsComponent: FC<{ + toolTip?: boolean; + list?: boolean; +}> = ({ toolTip, list }) => { const exising = useExistingData(); const { @@ -28,76 +79,89 @@ export const PicksSocialsComponent: FC<{ toolTip?: boolean }> = ({ })) ); + const availableIntegrations = integrations.filter((f) => { + if (exising.integration) { + return ( + f.id === exising.integration || + !!exising.siblings?.some((sibling) => sibling.integration === f.id) + ); + } + return !f.inBetweenSteps && !f.disabled; + }); + + const toggleIntegration = (integration: Integrations) => () => { + if (exising.integration) { + return; + } + addOrRemoveSelectedIntegration(integration, {}); + }; + + if (list) { + return ( +
    + {availableIntegrations.map((integration) => ( + p.integration.id === integration.id + )} + onClick={toggleIntegration(integration)} + /> + ))} +
    + ); + } + 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/facebook/facebook.preview.tsx b/apps/frontend/src/components/new-launch/providers/facebook/facebook.preview.tsx index bdd8a59a95..65e7fd6f86 100644 --- a/apps/frontend/src/components/new-launch/providers/facebook/facebook.preview.tsx +++ b/apps/frontend/src/components/new-launch/providers/facebook/facebook.preview.tsx @@ -153,6 +153,7 @@ export const FacebookPreview: FC<{
    {background ? (
    ) : (
    (params: { }>; dto?: any; maximumCharacters?: number | ((settings: any) => number); + inlineImages?: boolean | ((values: any) => boolean); }) { const { postComment, @@ -75,13 +76,14 @@ export const withProvider = function (params: { allIntegrations, setPostComment, setEditor, + setInlineImages, dummy, setChars, setComments, setHide, } = useLaunchStore( useShallow((state) => ({ - date: state.date, + date: state.channelDates[props.id] || state.date, tab: state.tab, global: state.global, dummy: state.dummy, @@ -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, setInlineImages]); + useImperativeHandle( ref, () => ({ @@ -199,14 +218,6 @@ export const withProvider = function (params: { selectedIntegration.integration.additionalSettings || '[]' ) ), - fix: () => { - setCurrent(props.id); - setHide(true); - }, - preview: () => { - setCurrent(props.id); - setHide(true); - }, }; }, getValues: () => { @@ -220,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] ); @@ -288,7 +310,7 @@ export const withProvider = function (params: { ))} {(SettingsComponent || !!data?.internalPlugs?.length) && createPortal( -
    +
    {isGlobal && ( )} diff --git a/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx b/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx index b14cc199de..68e0d48ece 100644 --- a/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx +++ b/apps/frontend/src/components/new-launch/providers/instagram/instagram.preview.tsx @@ -34,6 +34,7 @@ export const InstagramPreview: FC<{ const finalValue = `${integration?.name} ` + + `` + newContent .slice(start, end) .replace(/\[\[\[([.\s\S]*?)]]]/, (match, match1) => { @@ -43,7 +44,7 @@ export const InstagramPreview: FC<{ newContent.slice(end).replace(/\[\[\[([.\s\S]*?)]]]/, (match, match1) => { return `${match1}`; }) + - ``; + ``; return { text: finalValue, images: p.image }; }); @@ -82,6 +83,7 @@ export const InstagramPreview: FC<{ /> )}
    diff --git a/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx b/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx index e09cae29d5..946fd78376 100644 --- a/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx @@ -37,6 +37,7 @@ const RenderRedditComponent: FC<{ case 'self': return (
    { {integration?.name}
    @{integration?.name}
    -
    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/providers/youtube/youtube.preview.tsx b/apps/frontend/src/components/new-launch/providers/youtube/youtube.preview.tsx index c942b46821..40e093df9c 100644 --- a/apps/frontend/src/components/new-launch/providers/youtube/youtube.preview.tsx +++ b/apps/frontend/src/components/new-launch/providers/youtube/youtube.preview.tsx @@ -141,6 +141,7 @@ export const YoutubePreview: FC<{
    diff --git a/apps/frontend/src/components/new-launch/select.current.tsx b/apps/frontend/src/components/new-launch/select.current.tsx index e94b5f6209..de1b857dca 100644 --- a/apps/frontend/src/components/new-launch/select.current.tsx +++ b/apps/frontend/src/components/new-launch/select.current.tsx @@ -11,6 +11,7 @@ import { useShallow } from 'zustand/react/shallow'; import { GlobalIcon } from '@gitroom/frontend/components/ui/icons'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; +import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; import { useDecisionModal, useModals, @@ -52,6 +53,7 @@ export function useHasScroll(ref: RefObject): boolean { export const SelectCurrent: FC = () => { const modals = useDecisionModal(); + const existingData = useExistingData(); const { selectedIntegrations, current, @@ -94,30 +96,33 @@ export const SelectCurrent: FC = () => { return ( <> -
    +
    -
    { - setHide(true); - setCurrent('global'); - }} - className={clsx( - 'cursor-pointer flex gap-[8px] rounded-[8px] w-[40px] h-[40px] justify-center items-center bg-newBgLineColor', - current !== 'global' - ? 'text-[#A3A3A3]' - : 'border border-[#FC69FF] text-[#FC69FF]' - )} - > -
    - + {/* an existing post opens on its channels, without the global mode */} + {!existingData.integration && ( +
    { + setHide(true); + setCurrent('global'); + }} + className={clsx( + '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 mobile:border-[1.5px] border-[#FC69FF] text-[#FC69FF]' + )} + > +
    + +
    -
    + )} {selectedIntegrations.map(({ integration }) => (
    { @@ -126,19 +131,24 @@ 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' )} > -
    - X -
    - + {/* an existing post keeps its channels, each with its own content */} + {!existingData.integration && ( + <> +
    + X +
    + + + )}
    { ))}
    -
    +
    ); }; diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts index 2ea620b826..7063507193 100644 --- a/apps/frontend/src/components/new-launch/store.ts +++ b/apps/frontend/src/components/new-launch/store.ts @@ -27,8 +27,11 @@ export interface SelectedIntegrations { interface StoreState { editor: undefined | 'none' | 'normal' | 'markdown' | 'html'; + inlineImages: boolean; loaded: boolean; date: dayjs.Dayjs; + // the other channels of an existing post, each with its own date + channelDates: Record; postComment: PostComment; dummy: boolean; repeater?: number; @@ -112,6 +115,7 @@ interface StoreState { setTab: (tab: 0 | 1) => void; setHide: (hide: boolean) => void; setDate: (date: dayjs.Dayjs) => void; + setChannelDate: (integrationId: string, date: dayjs.Dayjs) => void; setRepeater: (repeater: number) => void; setTags: (tags: { label: string; value: string }[]) => void; setIsCreateSet: (isCreateSet: boolean) => void; @@ -129,6 +133,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,11 +142,13 @@ interface StoreState { const initialState = { editor: undefined as undefined, + inlineImages: false, loaded: true, dummy: false, comments: true, activateExitButton: true, date: newDayjs(), + channelDates: {} as Record, postComment: PostComment.ALL, tags: [] as { label: string; value: string }[], totalChars: 0, @@ -187,6 +194,7 @@ export const useLaunchStore = create()((set) => ({ ? { current: 'global', editor: 'normal', + inlineImages: false, } : {}), }; @@ -533,6 +541,13 @@ export const useLaunchStore = create()((set) => ({ set((state) => ({ date, })), + setChannelDate: (integrationId: string, date: dayjs.Dayjs) => + set((state) => ({ + channelDates: { + ...state.channelDates, + [integrationId]: date, + }, + })), setRepeater: (repeater: number) => set((state) => ({ repeater, @@ -617,6 +632,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/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/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..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 > Developers on your instance" + placeholder="Settings > Agents 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 (Developers or + Public API 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 ae20e4836a..4bc1084a93 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -641,7 +641,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.' )}
    )} @@ -672,7 +672,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 f9009ce23f..262c864f1b 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'; @@ -10,7 +10,6 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { joinBrandUrl } from '@gitroom/helpers/utils/brand.config'; 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'; @@ -837,50 +836,11 @@ const PublicApiContent = () => { 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(); - }, []); - 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('developers', 'Developers')} - {currentOrg?.name ? ` - ${currentOrg.name}` : ''} -

    -
    - {(['api', 'developer'] as const).map((tab) => ( - - ))} -
    - {subTab === 'api' && } - {subTab === 'developer' && } +

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

    +
    ); }; 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/settings/authentication.component.tsx b/apps/frontend/src/components/settings/authentication.component.tsx new file mode 100644 index 0000000000..7fba24da1b --- /dev/null +++ b/apps/frontend/src/components/settings/authentication.component.tsx @@ -0,0 +1,49 @@ +'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 }, + APPLE: { label: t('auth_method_apple', 'Apple'), 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/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/settings/teams.component.tsx b/apps/frontend/src/components/settings/teams.component.tsx index 070ed9b298..3bd48a26b9 100644 --- a/apps/frontend/src/components/settings/teams.component.tsx +++ b/apps/frontend/src/components/settings/teams.component.tsx @@ -48,14 +48,23 @@ export const AddMember = () => { control: form.control, name: 'sendEmail', }); + const t = useT(); 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) { + 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(); if (values.sendEmail) { modals.closeAll(); toast.show(t('invitation_link_sent', 'Invitation link sent')); @@ -65,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/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.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/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 }) => { + // the support chat bubble would cover the sheet button + useEffect(() => { + document.querySelector('body')?.classList.add('bottomSheetOpen'); + return () => { + document.querySelector('body')?.classList.remove('bottomSheetOpen'); + }; + }, []); + + 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..88f97917e5 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, @@ -391,6 +443,27 @@ export const UserIcon: FC = ({ size = 20, className, ...props }) => ( ); +// Filter Icon +export const FilterIcon: FC = ({ size = 20, className, ...props }) => ( + + + +); + // Expand Icon export const ExpandIcon: FC = ({ size = 24, @@ -717,6 +790,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/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 5c18c34759..185020cd87 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( @@ -394,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, @@ -572,7 +574,10 @@ export class PostActivity { return refresh; } catch (err) { - await this._refreshIntegrationService.setBetweenSteps(integration); + await this._refreshIntegrationService.setBetweenSteps( + integration, + (err as Error)?.message || '' + ); return false; } } @@ -601,7 +606,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/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/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(); } } 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/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/chatgpt-app-submission.json b/chatgpt-app-submission.json index f2f37513c0..e2f3a15def 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/docs/fork-delta.md b/docs/fork-delta.md index 2e9195f8bf..7fbaf7061c 100644 --- a/docs/fork-delta.md +++ b/docs/fork-delta.md @@ -30,6 +30,13 @@ 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 | +| `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 | +| `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`, `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) diff --git a/i18n.lock b/i18n.lock index 1f9a81b9d9..40ccfe4d6e 100644 --- a/i18n.lock +++ b/i18n.lock @@ -179,6 +179,7 @@ checksums: day: 47648cd60fc313bc3f05b70357a1d675 week: 436fdd694160827dd6ea4644cdd0a8f8 month: ae7bef950efc406ff0980affabc1a64c + list: 94f13e7ef909a4de9db7abaa1f9f0b61 remove_from_customer: f572c0e84941d0d2d5ec1f3a15fdcb66 show_more: 1fe3f6f31d04d6dd29c0b1b27b991b52 show_less: 890358798c3b1a1a2f4b2bfff366405d @@ -338,6 +339,10 @@ checksums: predicted_github_trending_change: db2e35c6bb26939186948182195e84df 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 @@ -567,10 +572,40 @@ 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 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 @@ -620,6 +655,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 @@ -657,6 +693,8 @@ checksums: payment_required: 3acec87fe94c0bdb92b35e3081f45061 no_content: 1d2af798c895527a7f8a4c72c2571fdd select_customer_tooltip: 1cd2f6f08560b7ebcc9285e845557673 + select_channels_tooltip: 3aab82dc90ccafe55059c32c5a33c222 + select_all: eedc7cdb02de467c15dc418a066a77f2 customers: c31a0dfd0d92496ba6f5673781db24ef hour: ec3113f22fc51d01f0c615c5496f8f87 minutes: c5442938e948e2e3937fb812cf68869c @@ -689,6 +727,7 @@ checksums: two_weeks: 2535083efa302c3dde7d06cce2455b74 repeat_post_every_label: f3e3fd4d95a0eeaf1eab0219278ae812 add_new_tag: 911360b396ef6db7a24eb53134f0f7d2 + edit_tag: 5a8b41a0364539c5d7c243f9d3695e9b tag_name: 9368b5a047572b6051f334af5aa76819 post_save_failed: 164475f6751549f91a5b6e7f759fbca8 post_is_too_long: 7784c6ecca2c7e58665e83e219aff80b @@ -706,6 +745,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 @@ -716,7 +756,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 @@ -734,7 +774,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 @@ -797,4 +838,40 @@ 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 some_files_failed_to_upload: 87c15cf17373c535da85470188df68c7 + rename_channel: 81dff725b16582b6a13c2f0a826eb2cb + channel_name: 9368b5a047572b6051f334af5aa76819 + 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_apple: 4e2ced07ca66bd1cf0562882d019aff2 + auth_method_farcaster: b09af25fcb497663e8594b9fc514b969 + auth_method_wallet: 4599a5c87198f2113d1e54d435a24cd5 + auth_method_generic: 58c80e21cf5e88d556ae604726c18e4d + 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 + 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/helpers/src/utils/sanitize.post.content.ts b/libraries/helpers/src/utils/sanitize.post.content.ts index 204fee139f..7be30dabc0 100644 --- a/libraries/helpers/src/utils/sanitize.post.content.ts +++ b/libraries/helpers/src/utils/sanitize.post.content.ts @@ -13,17 +13,32 @@ const ALLOWED_TAGS = [ 'h2', 'h3', 'span', + 'img', ]; const ALLOWED_ATTR = [ + 'dir', 'href', 'target', 'rel', '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..243098ef0c 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,30 +172,33 @@ export const stripHtmlValidation = ( 'strong', 'u', 'a', - ]) + ...(inlineImages ? ['img'] : []), + ] + ) .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') { return striptags( convertMention( value - .replace(/

    ([.\s\S]*?)<\/h1>/g, (match, p1) => { + .replace(/]*>([.\s\S]*?)<\/h1>/g, (match, p1) => { return `

    # ${p1}

    \n`; }) .replace(/&/gi, '&') .replace(/ /gi, ' ') .replace(/"/gi, '"') .replace(/'/gi, "'") - .replace(/

    ([.\s\S]*?)<\/h2>/g, (match, p1) => { + .replace(/]*>([.\s\S]*?)<\/h2>/g, (match, p1) => { return `

    ## ${p1}

    \n`; }) - .replace(/

    ([.\s\S]*?)<\/h3>/g, (match, p1) => { + .replace(/]*>([.\s\S]*?)<\/h3>/g, (match, p1) => { return `

    ### ${p1}

    \n`; }) .replace(/([.\s\S]*?)<\/u>/g, (match, p1) => { @@ -201,7 +210,7 @@ export const stripHtmlValidation = ( .replace(/([.\s\S]*?)<\/li.*?>/gm, (match, p1) => { return `
  • - ${p1.replace(/\n/gm, '')}
  • `; }) - .replace(/

    ([.\s\S]*?)<\/p>/g, (match, p1) => { + .replace(/]*>([.\s\S]*?)<\/p>/g, (match, p1) => { return `

    ${p1}

    \n`; }) .replace( @@ -217,7 +226,7 @@ export const stripHtmlValidation = ( .replace(/</gi, '<'); } - if (value.indexOf('

    ') === -1 && !none) { + if (!/]/i.test(value) && !none) { return value; } @@ -268,6 +277,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/chat/load.tools.service.ts b/libraries/nestjs-libraries/src/chat/load.tools.service.ts index bcc8fc5c1a..0b8a8df4ca 100644 --- a/libraries/nestjs-libraries/src/chat/load.tools.service.ts +++ b/libraries/nestjs-libraries/src/chat/load.tools.service.ts @@ -121,6 +121,16 @@ export class LoadToolsService { return expandedTools; } + // 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(agentId = 'postiz') { const tools = await this.loadTools(); const brand = getBrandConfig(); @@ -160,6 +170,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/mcp.relay.service.ts b/libraries/nestjs-libraries/src/chat/mcp.relay.service.ts index 7cb0bd0a92..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 = @@ -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`, @@ -184,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 (Developers or Public API on older versions) of your instance, and make sure it runs Postiz v2.19.0 or newer`, HttpStatus.BAD_REQUEST ); } @@ -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') diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/integration.list.tool.ts index 3f8a032dca..7feeb18552 100644 --- a/libraries/nestjs-libraries/src/chat/tools/integration.list.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/integration.list.tool.ts @@ -55,7 +55,7 @@ export class IntegrationListTool implements AgentToolInterface { ) .filter((p) => !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/chat/tools/integration.schedule.post.ts b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts index 9fa08a8e0a..72c06abc4f 100644 --- a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts +++ b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts @@ -92,7 +92,7 @@ If validation fails, the result contains output.errors describing what to fix; t content: z .string() .describe( - "The content of the post, HTML, Each line must be wrapped in

    here is the possible tags: h1, h2, h3, u, strong, li, ul, p (you can't have u and strong together)" + "The content of the post, HTML, Each line must be wrapped in

    here is the possible tags: h1, h2, h3, u, strong, li, ul, p (you can't have u and strong together), and img () only when the integration rules say pictures go inside the content" ), attachments: z .array(attachmentUrl) 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..4a60361384 --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/media.list.tool.ts @@ -0,0 +1,76 @@ +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() + .int() + .min(1) + .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(), + createdAt: z.string(), + }) + ), + }), + 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, + createdAt: p.createdAt.toISOString(), + })), + }; + }, + }); + } +} 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, }, 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, diff --git a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts index 01766806dc..117306736e 100644 --- a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts +++ b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts @@ -12,6 +12,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'; import { UploadWidgetTool } from '@gitroom/nestjs-libraries/chat/tools/upload.widget.tool'; @@ -35,6 +36,7 @@ export const toolList = [ ClippingWidgetTicketTool, GenerateImageTool, UploadFromUrlTool, + MediaListTool, UploadWidgetTool, UploadWidgetTicketTool, UploadWidgetStatusTool, 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 7b7fb4dbb1..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,11 +15,18 @@ 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 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 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 { error } on failure.`, +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: { annotations: { title: 'Upload Media From URL', @@ -29,7 +40,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 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 +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}`, + 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.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..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 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 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/database/prisma/autopost/autopost.service.ts b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts index 7bb54cbd7f..f1e8011549 100644 --- a/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts @@ -106,12 +106,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, @@ -119,7 +121,10 @@ export class AutopostService { }, ]), }); - } catch (err) {} + } catch (err) { + // Don't fall through and terminate an active autopost + return false; + } } try { 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..42d402ff0e 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,21 @@ export class IntegrationRepository { }); } + updateCustomName(org: string, id: string, name: string) { + return this._integration.model.integration.update({ + select: { + id: true, + }, + where: { + id, + organizationId: org, + }, + data: { + customName: name.trim() || null, + }, + }); + } + async setTimes(org: string, id: string, times: IntegrationTimeDto) { return this._integration.model.integration.update({ select: { @@ -349,7 +364,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) } : {}), @@ -765,6 +781,9 @@ export class IntegrationRepository { deleteChannel(org: string, id: string) { return this._integration.model.integration.update({ + select: { + id: true, + }, where: { id, organizationId: org, @@ -864,6 +883,19 @@ export class IntegrationRepository { } } + enableAllIntegrations(org: string) { + return this._integration.model.integration.updateMany({ + where: { + organizationId: org, + disabled: true, + deletedAt: null, + }, + 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 c677c49be3..da5d13e1ca 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -168,6 +168,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); } @@ -176,6 +180,13 @@ export class IntegrationService { return this._integrationRepository.getIntegrationById(org, id); } + getIntegrationByInternalId(org: string, internalId: string) { + return this._integrationRepository.getIntegrationByInternalId( + org, + internalId + ); + } + async refreshToken(provider: SocialProvider, refresh: string) { try { const { refreshToken, accessToken, expiresIn } = @@ -310,7 +321,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, @@ -395,6 +406,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); } @@ -450,6 +465,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) { @@ -490,7 +507,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); @@ -501,10 +518,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/media/media.repository.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts index 7f05d20b39..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,6 +160,7 @@ export class MediaRepository { thumbnail: true, alt: true, thumbnailTimestamp: true, + createdAt: true, }, skip: pageNum * 18, take: 18, 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 97d1f5c9b6..bf64b17b17 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -530,6 +530,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: { @@ -539,6 +565,7 @@ export class OrganizationRepository { users: { select: { role: true, + disabled: true, user: { select: { email: true, 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 6734cfcbad..0cbce842fc 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts @@ -94,6 +94,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); } 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 2d24173e26..c2830afdd0 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(); @@ -172,6 +198,7 @@ export class PostsRepository { releaseURL: true, releaseId: true, state: true, + error: true, intervalInDays: true, group: true, creationMethod: true, @@ -197,7 +224,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]; } @@ -272,6 +301,11 @@ export class PostsRepository { customerId: query.customer, } : {}), + ...(query.integrations + ? { + id: { in: query.integrations }, + } + : {}), }, }; @@ -371,6 +405,26 @@ export class PostsRepository { }); } + // the posts of the other channels in the batch, without the given group + getPostsByBatch(orgId: string, batchId: string, exceptGroup: string) { + return this._post.model.post.findMany({ + where: { + organizationId: orgId, + batchId, + group: { + not: exceptGroup, + }, + deletedAt: null, + integration: { + deletedAt: null, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }); + } + getPost( id: string, includeIntegration = false, @@ -417,7 +471,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, @@ -426,6 +485,7 @@ export class PostsRepository { }, data: { releaseId: String(releaseId), + ...(releaseURL ? { releaseURL } : {}), }, }); } @@ -554,12 +614,40 @@ export class PostsRepository { // Keep the existing group instead of rotating it, so open clients // (calendar) holding the group stay valid. Used by out-of-band updates // (agent / MCP / public API); the dashboard keeps the rotate-and-sweep. - keepGroup = false + keepGroup = false, + // Shared by the posts saved together for several channels, so opening one + // of them in the editor brings the others. + batchId?: string ) { const posts: Post[] = []; const uuid = uuidv4(); const group = keepGroup && body.group ? body.group : uuid; + // an edited post stays in the batch it was created in, even when it is + // saved on its own or with a group that was rotated since + const ids = body.value.map((value) => value.id).filter(Boolean); + const existingBatchId = + body.group || ids.length + ? ( + await this._post.model.post.findFirst({ + where: { + organizationId: orgId, + deletedAt: null, + batchId: { + not: null, + }, + OR: [ + ...(body.group ? [{ group: body.group }] : []), + ...(ids.length ? [{ id: { in: ids } }] : []), + ], + }, + select: { + batchId: true, + }, + }) + )?.batchId + : undefined; + for (const value of body.value) { const updateData = (type: 'create' | 'update') => ({ publishDate: dayjs(date).toDate(), @@ -587,6 +675,7 @@ export class PostsRepository { content: value.content, delay: value.delay || 0, group, + batchId: existingBatchId || batchId, intervalInDays: inter ? +inter : null, approvedSubmitForOrder: APPROVED_SUBMIT_FOR_ORDER.NO, ...(type === 'create' ? { creationMethod } : {}), 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..b3e168d707 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -18,7 +18,8 @@ import { } from '@prisma/client'; import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto'; import { GetPostsListDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.list.dto'; -import { shuffle } from 'lodash'; +import { groupBy, shuffle, uniqBy } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto'; import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; @@ -153,7 +154,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( @@ -162,6 +186,7 @@ export class PostsService { id: string; releaseId: string; releaseURL: string; + settings: string; integration: Integration; } ) { @@ -172,10 +197,21 @@ export class PostsService { const resolved = await integrationProvider.resolveReleaseId?.( post.integration.token, post.releaseId, - post.integration + post.integration, + JSON.parse(post.settings || '{}'), + post.releaseURL ); - 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,7 +220,77 @@ 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; + pending?: boolean; + unavailable?: boolean; + reconnect?: boolean; + }> { + 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!; + + // 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, reconnect: true }; + } + + 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, reconnect: true }; + } + } + + try { + const { releaseURL, pending, unavailable } = await this.resolveRelease( + orgId, + post + ); + return { releaseURL, pending, unavailable }; + } catch (e) { + console.log(e); + if (e instanceof RefreshToken && !forceRefresh) { + return this.getReleaseURL(orgId, postId, true); + } + } + + return { releaseURL: post.releaseURL, pending: true }; } async checkPostAnalytics( @@ -263,10 +369,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 []; @@ -540,18 +647,63 @@ export class PostsService { } async getPostsByGroup(orgId: string, group: string) { - const convertToJPEG = false; const loadAll = await this._postRepository.getPostsByGroup(orgId, group); const posts = this.arrangePostsByGroup(loadAll, undefined); if (!posts.length) { throw new NotFoundException('Post not found'); } + const batch = posts[0].batchId + ? await this._postRepository.getPostsByBatch( + orgId, + posts[0].batchId, + group + ) + : []; + + // the other channels the post was created with, the editor holds one + // post per channel, and a channel that repeats differently is its own post + const siblings = uniqBy( + Object.values(groupBy(batch, (post) => post.group)) + .map((batchPosts) => this.arrangePostsByGroup(batchPosts, undefined)) + .filter( + (batchPosts) => + batchPosts.length && + batchPosts[0].integrationId !== posts[0].integrationId && + batchPosts[0].intervalInDays === posts[0].intervalInDays + ), + (batchPosts) => batchPosts[0].integrationId + ); + + return { + ...(await this.groupForEditor(posts)), + siblings: await Promise.all( + siblings.map((batchPosts) => this.groupForEditor(batchPosts)) + ), + }; + } + + // the post as the editor loads it, its channel without the tokens + private async groupForEditor( + posts: PostWithConditionals[], + convertToJPEG = false + ) { return { group: posts?.[0]?.group, posts: await Promise.all( - (posts || []).map(async (post) => ({ + (posts || []).map(async ({ integration, ...post }) => ({ ...post, + ...(integration + ? { + integration: { + id: integration.id, + name: integration.name, + picture: integration.picture, + providerIdentifier: integration.providerIdentifier, + profile: integration.profile, + }, + } + : {}), image: await this.updateMedia( post.id, JSON.parse(post.image || '[]'), @@ -585,24 +737,11 @@ export class PostsService { async getPost(orgId: string, id: string, convertToJPEG = false) { const posts = await this.getPostsRecursively(id, true, orgId, true); - const list = { - group: posts?.[0]?.group, - posts: await Promise.all( - (posts || []).map(async (post) => ({ - ...post, - image: await this.updateMedia( - post.id, - JSON.parse(post.image || '[]'), - convertToJPEG - ), - })) - ), - integrationPicture: posts[0]?.integration?.picture, - integration: posts[0].integrationId, - settings: JSON.parse(posts[0].settings || '{}'), - }; + if (!posts?.[0]) { + throw new NotFoundException('Post not found'); + } - return list; + return this.groupForEditor(posts, convertToJPEG); } async getOldPosts(orgId: string, date: string) { @@ -964,6 +1103,7 @@ export class PostsService { keepGroup = false ): Promise { const postList = []; + const batchId = uuidv4(); for (const post of body.posts) { if ( (body.type === 'schedule' || body.type === 'now') && @@ -998,12 +1138,15 @@ export class PostsService { const { posts } = await this._postRepository.createOrUpdatePost( body.type, orgId, - body.type === 'now' ? dayjs().format('YYYY-MM-DDTHH:mm:00') : body.date, + body.type === 'now' + ? dayjs().format('YYYY-MM-DDTHH:mm:00') + : post.date || body.date, post, body.tags, creationMethod, body.inter, - keepGroup + keepGroup, + batchId ); if (!posts?.length) { @@ -1357,8 +1500,9 @@ export class PostsService { ); return this.findFreeDateTimeRecursive( orgId, - findTimes, - dayjs.utc().startOf('day') + findTimes.length ? findTimes : [120, 400, 700], + dayjs.utc().startOf('day'), + 0 ); } @@ -1374,8 +1518,17 @@ export class PostsService { private async findFreeDateTimeRecursive( orgId: string, times: number[], - date: dayjs.Dayjs + date: dayjs.Dayjs, + daysChecked: number ): Promise { + // stop searching after a year so an empty times list can never recurse forever + if (daysChecked > 365) { + return date + .clone() + .add(times[0] || 0, 'minutes') + .format('YYYY-MM-DDTHH:mm:00'); + } + const list = await this._postRepository.getPostsCountsByDates( orgId, times, @@ -1383,7 +1536,12 @@ export class PostsService { ); if (!list.length) { - return this.findFreeDateTimeRecursive(orgId, times, date.add(1, 'day')); + return this.findFreeDateTimeRecursive( + orgId, + times, + date.add(1, 'day'), + daysChecked + 1 + ); } const num = list.reduce((prev, curr) => { diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index d1356a21c1..3ef3383fd0 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -370,6 +370,7 @@ model Integration { internalId String organizationId String name String + customName String? picture String? providerIdentifier String type String @@ -463,6 +464,7 @@ model Post { content String delay Int @default(0) group String + batchId String? title String? description String? parentPostId String? @@ -493,6 +495,7 @@ model Post { tags TagsPosts[] @@index([group]) + @@index([batchId]) @@index([deletedAt]) @@index([publishDate]) @@index([state]) 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 7ecdda8966..54c6cd6d5b 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: { @@ -319,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 67b93ec819..726ccab6f5 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, @@ -108,9 +115,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 +127,26 @@ export class SubscriptionService { ); } + // channels are auto-disabled when a subscription lapses; once the new plan + // 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 && + totalChannels > (getCurrentSubscription?.totalChannels || 0) + ) { + try { + await this._integrationService.enableAllIntegrations(organizationId); + } catch (err) { + console.error( + 'Error enabling channels after subscription change:', + err + ); + } + } + if (from.team_members && !to.team_members) { await this._organizationService.disableOrEnableNonSuperAdminUsers( organizationId, @@ -169,11 +197,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 +209,28 @@ export class SubscriptionService { ); } + // channels are auto-disabled when a subscription lapses; once the new plan + // 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 && + totalChannels > (getCurrentSubscription?.totalChannels || 0) + ) { + 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) { await this._organizationService.disableOrEnableNonSuperAdminUsers( getOrgByCustomerId?.id!, @@ -229,6 +278,7 @@ export class SubscriptionService { return {}; } } catch (e) { + console.error('Error modifying subscription:', e); return {}; } } @@ -273,6 +323,7 @@ export class SubscriptionService { return {}; } } catch (e) { + console.error('Error modifying subscription:', e); return {}; } @@ -319,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'; @@ -327,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/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 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; +} 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/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; +} 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/nestjs-libraries/src/dtos/posts/create.post.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts index b7c82c8bfd..471e3d6091 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts @@ -68,6 +68,11 @@ export class Post { @IsString() group: string; + // a channel that keeps a date of its own, the others use the post date + @IsOptional() + @IsDateString() + date?: string; + @ValidateIf((o) => o.type !== 'draft') @ValidateNested() @Type(() => EmptySettings, { diff --git a/libraries/nestjs-libraries/src/dtos/posts/get.posts.list.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/get.posts.list.dto.ts index 70e764e8d4..32a8f42a89 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/get.posts.list.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/get.posts.list.dto.ts @@ -5,6 +5,7 @@ import { Min, Max, IsIn, + IsArray, } from 'class-validator'; import { Transform } from 'class-transformer'; @@ -28,6 +29,12 @@ export class GetPostsListDto { @IsString() customer?: string; + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => (value ? value.split(',') : [])) + integrations?: string[]; + @IsOptional() @IsIn(['all', 'scheduled', 'draft', 'published']) state?: PostListStateFilter = 'all'; 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/integrations/refresh.integration.service.ts b/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts index bf59c32bb1..8bed7b8be2 100644 --- a/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts +++ b/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts @@ -73,11 +73,22 @@ 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 + ); + } + // informAboutRefreshError (with the failure cause) already notifies // the user, and refreshNeeded sets the same flag disconnectChannel // would — calling disconnectChannel here sent a second, cause-less @@ -90,7 +101,7 @@ export class RefreshIntegrationService { await this._integrationService.informAboutRefreshError( integration.organizationId, integration, - cause + socialProvider.refreshErrorMessage?.(refreshError) || cause ); return false; @@ -103,11 +114,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.abstract.ts b/libraries/nestjs-libraries/src/integrations/social.abstract.ts index 472e342d7d..d131f60075 100644 --- a/libraries/nestjs-libraries/src/integrations/social.abstract.ts +++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts @@ -193,11 +193,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' ); } @@ -209,9 +211,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' ); } 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/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 || 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/listmonk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts index 9366e50306..7662e44975 100644 --- a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts @@ -12,7 +12,11 @@ import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-sett import { AuthService } from '@gitroom/helpers/auth/auth.service'; import slugify from 'slugify'; import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; +@Rules( + 'ListMonk sends the content as an email: pictures go inside the content as where they should appear, the src must be a picture from the media library (upload it with uploadFromUrlTool first), attachments are not sent' +) export class ListmonkProvider extends SocialAbstract implements SocialProvider { override maxConcurrentJob = 100; // Bluesky has moderate rate limits identifier = 'listmonk'; @@ -26,6 +30,10 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider { return 100000000; } + inlineImages() { + return true; + } + async customFields() { return [ { @@ -189,6 +197,10 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider { font-size: 15px; line-height: 1.6; } +.content img { + max-width: 100%; + height: auto; +}