From 9ee6cbadaf62f883df54261acedc26f1cc9589fc Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 24 Aug 2026 10:56:01 +0700 Subject: [PATCH 01/20] feat: migrate Veo3 video provider from kie.ai to EvoLink Replace the kie.ai submit/poll calls in the veo3 video provider with EvoLink's async API (POST /v1/videos/generations, GET /v1/tasks/{id}), gated by a new EVOLINK_API_KEY env var. Same Veo 3.1 Fast model, 8s with audio, same DTO/identifier/UI. Thrown errors now carry EvoLink's error code so content-policy failures map to the existing 422 safety response. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNtigh3UmZMbvyPsqrjxfn --- .../nestjs-libraries/src/videos/veo3/veo3.ts | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/libraries/nestjs-libraries/src/videos/veo3/veo3.ts b/libraries/nestjs-libraries/src/videos/veo3/veo3.ts index 7d9784952d..3ec2ee7726 100644 --- a/libraries/nestjs-libraries/src/videos/veo3/veo3.ts +++ b/libraries/nestjs-libraries/src/videos/veo3/veo3.ts @@ -33,7 +33,7 @@ class Veo3Params { dto: Veo3Params, tools: [], trial: false, - available: !!process.env.KIEAI_API_KEY, + available: !!process.env.EVOLINK_API_KEY, }) export class Veo3 extends VideoAbstract { override dto = Veo3Params; @@ -42,27 +42,33 @@ export class Veo3 extends VideoAbstract { customParams: Veo3Params ): Promise { const value = await ( - await fetch('https://api.kie.ai/api/v1/veo/generate', { + await fetch('https://api.evolink.ai/v1/videos/generations', { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${process.env.KIEAI_API_KEY}`, + Authorization: `Bearer ${process.env.EVOLINK_API_KEY}`, }, method: 'POST', signal: AbortSignal.timeout(30000), body: JSON.stringify({ + model: 'veo3.1-fast', prompt: customParams.prompt, - imageUrls: customParams?.images?.map((p) => p.path) || [], - model: 'veo3_fast', - aspectRatio: output === 'horizontal' ? '16:9' : '9:16', + image_urls: customParams?.images?.map((p) => p.path) || [], + aspect_ratio: output === 'horizontal' ? '16:9' : '9:16', + duration: 8, + generate_audio: true, }), }) ).json(); - if (value.code !== 200 && value.code !== 201) { - throw new Error(value?.msg || `Failed to generate video`); + const taskId = value?.id; + if (!taskId) { + throw new Error( + value?.error + ? `${value.error.code}: ${value.error.message}` + : `Failed to generate video` + ); } - const taskId = value.data.taskId; console.log('veo3 taskId', taskId); let attempts = 0; const maxAttempts = 180; // ~30 minutes at 10s interval @@ -73,40 +79,31 @@ export class Veo3 extends VideoAbstract { console.log('waiting for video to be ready'); const data = await ( - await fetch( - 'https://api.kie.ai/api/v1/veo/record-info?taskId=' + taskId, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${process.env.KIEAI_API_KEY}`, - }, - signal: AbortSignal.timeout(30000), - } - ) + await fetch('https://api.evolink.ai/v1/tasks/' + taskId, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.EVOLINK_API_KEY}`, + }, + signal: AbortSignal.timeout(30000), + }) ).json(); - if (data.code !== 200) { - throw new Error(data?.msg || `Failed to get video info`); + if (data?.status === 'completed') { + const videoUrl = data?.results || []; + if (videoUrl.length > 0) { + return videoUrl[0]; + } + throw new Error('Video generation succeeded but no video URL returned'); } - // successFlag: 0 = generating, 1 = success, anything else = failed - const successFlag = data?.data?.successFlag; - if (successFlag !== 0 && successFlag !== 1) { + if (data?.status !== 'pending' && data?.status !== 'processing') { throw new Error( - data?.data?.errorMessage || - `Video generation failed (status ${successFlag})` + data?.error + ? `${data.error.code}: ${data.error.message}` + : `Video generation failed (status ${data?.status})` ); } - const videoUrl = data?.data?.response?.resultUrls || []; - if (videoUrl.length > 0) { - return videoUrl[0]; - } - - if (successFlag === 1) { - throw new Error('Video generation succeeded but no video URL returned'); - } - await timer(10000); } } From 83c33dc32d2573e4b1f2f8307cf3cc9fd50c8b2f Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 26 Aug 2026 12:27:57 +0700 Subject: [PATCH 02/20] faet: rc --- .env.example | 5 + apps/backend/src/api/api.module.ts | 8 + .../src/api/routes/billing.controller.ts | 92 ++++++--- .../src/api/routes/payment.controller.ts | 36 ++++ .../src/api/routes/stripe.controller.ts | 48 ++--- .../src/api/routes/users.controller.ts | 38 ++-- .../billing/main.billing.component.tsx | 32 ++- .../src/database/prisma/database.module.ts | 6 + .../src/database/prisma/schema.prisma | 1 + .../subscriptions/subscription.repository.ts | 17 +- .../subscriptions/subscription.service.ts | 94 ++++++++- .../src/dtos/billing/billing.sync.dto.ts | 7 + .../payment/payment.provider.interface.ts | 185 +++++++++++++++++ .../payment/payment.provider.manager.ts | 47 +++++ .../src/services/payment/payment.providers.ts | 2 + .../src/services/payment/payment.service.ts | 146 ++++++++++++++ .../payment/providers/revenuecat.provider.ts | 186 ++++++++++++++++++ .../src/services/stripe.service.ts | 127 +++++++++--- 18 files changed, 956 insertions(+), 121 deletions(-) create mode 100644 apps/backend/src/api/routes/payment.controller.ts create mode 100644 libraries/nestjs-libraries/src/dtos/billing/billing.sync.dto.ts create mode 100644 libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts create mode 100644 libraries/nestjs-libraries/src/services/payment/payment.provider.manager.ts create mode 100644 libraries/nestjs-libraries/src/services/payment/payment.providers.ts create mode 100644 libraries/nestjs-libraries/src/services/payment/payment.service.ts create mode 100644 libraries/nestjs-libraries/src/services/payment/providers/revenuecat.provider.ts diff --git a/.env.example b/.env.example index 51ac5c3697..87d6b92144 100644 --- a/.env.example +++ b/.env.example @@ -128,6 +128,11 @@ STRIPE_PUBLISHABLE_KEY="" STRIPE_SECRET_KEY="" STRIPE_SIGNING_KEY="" STRIPE_SIGNING_KEY_CONNECT="" +# RevenueCat (App Store / Google Play subscriptions from the mobile app) +# Webhook URL: {BACKEND_URL}/payment/revenuecat, Authorization header value = REVENUECAT_WEBHOOK_SECRET +REVENUECAT_SECRET_KEY="" +REVENUECAT_WEBHOOK_SECRET="" +# IN_APP_PURCHASE_REJECT_SANDBOX=true # Developer Settings NX_ADD_PLUGINS=false diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index f42f35b2a8..e986350426 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -5,6 +5,10 @@ import { UsersController } from '@gitroom/backend/api/routes/users.controller'; import { AuthMiddleware } from '@gitroom/backend/services/auth/auth.middleware'; import { StripeController } from '@gitroom/backend/api/routes/stripe.controller'; import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; +import { PaymentController } from '@gitroom/backend/api/routes/payment.controller'; +import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; +import { PaymentProviderManager } from '@gitroom/nestjs-libraries/services/payment/payment.provider.manager'; +import { RevenueCatProvider } from '@gitroom/nestjs-libraries/services/payment/providers/revenuecat.provider'; import { AnalyticsController } from '@gitroom/backend/api/routes/analytics.controller'; import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard'; import { PermissionsService } from '@gitroom/backend/services/auth/permissions/permissions.service'; @@ -76,6 +80,7 @@ const authenticatedController = [ : [ RootController, StripeController, + PaymentController, AuthController, PublicController, MonitorController, @@ -87,6 +92,9 @@ const authenticatedController = [ providers: [ AuthService, StripeService, + PaymentService, + PaymentProviderManager, + RevenueCatProvider, OpenaiService, ExtractContentService, AuthMiddleware, diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index 2c206e74fb..49fe1a3c92 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -1,6 +1,13 @@ -import { Body, Controller, Get, HttpException, Param, Post, Req } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpException, + Param, + Post, + Req, +} from '@nestjs/common'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; -import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { Organization, User } from '@prisma/client'; import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto'; @@ -11,17 +18,25 @@ import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/n import { Request } from 'express'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; +import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; +import { BillingSyncDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.sync.dto'; @ApiTags('Billing') @Controller('/billing') export class BillingController { constructor( private _subscriptionService: SubscriptionService, - private _stripeService: StripeService, private _notificationService: NotificationService, - private _usersService: UsersService + private _usersService: UsersService, + private _paymentService: PaymentService ) {} + // Billing routes are the web platform; the org's own provider (or the web + // default when it has none) handles the action. + private provider(org: Organization) { + return this._paymentService.getProviderForOrganization(org.id, 'web'); + } + private async assertNoOtherSubscribedAccount(user: User) { const other = await this._usersService.getUserWithActiveSubscriptionByEmail( user.email, @@ -36,14 +51,14 @@ export class BillingController { @Param('id') body: string ) { return { - status: await this._stripeService.checkSubscription(org.id, body), + status: await (await this.provider(org)).checkSubscription(org.id, body), }; } @Get('/check-discount') async checkDiscount(@GetOrgFromRequest() org: Organization) { return { - offerCoupon: !(await this._stripeService.checkDiscount(org.paymentId)) + offerCoupon: !(await (await this.provider(org)).checkDiscount(org)) ? false : AuthService.signJWT({ discount: true }), }; @@ -51,13 +66,14 @@ export class BillingController { @Post('/apply-discount') async applyDiscount(@GetOrgFromRequest() org: Organization) { - await this._stripeService.applyDiscount(org.paymentId); + await (await this.provider(org)).applyDiscount(org); } @Post('/finish-trial') async finishTrial(@GetOrgFromRequest() org: Organization) { + const provider = await this.provider(org); try { - await this._stripeService.finishTrial(org.paymentId); + await provider.finishTrial(org); } catch (err) {} return { finish: true, @@ -83,7 +99,7 @@ export class BillingController { } const uniqueId = req?.cookies?.track; - return this._stripeService.embedded( + return (await this.provider(org)).embedded( uniqueId, org.id, user.id, @@ -104,7 +120,7 @@ export class BillingController { } const uniqueId = req?.cookies?.track; - return this._stripeService.subscribe( + return (await this.provider(org)).subscribe( uniqueId, org.id, user.id, @@ -113,12 +129,30 @@ export class BillingController { ); } + @Post('/sync') + async sync( + @GetOrgFromRequest() org: Organization, + @GetUserFromRequest() user: User, + @Body() body: BillingSyncDto + ) { + if (await this.assertNoOtherSubscribedAccount(user)) { + return { blocked: true }; + } + await this._paymentService.assertCanUseProvider(org.id, body.provider); + + try { + return await this._paymentService.syncSubscription(body.provider, org.id); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new HttpException((e as Error)?.message || 'Sync failed', 400); + } + } + @Get('/portal') async modifyPayment(@GetOrgFromRequest() org: Organization) { - const customer = await this._stripeService.getCustomerByOrganizationId( - org.id - ); - const { url } = await this._stripeService.createBillingPortalLink(customer); + const { url } = await (await this.provider(org)).portalLink(org.id); return { portal: url, }; @@ -126,7 +160,7 @@ export class BillingController { @Get('/') getCurrentBilling(@GetOrgFromRequest() org: Organization) { - return this._subscriptionService.getSubscriptionByOrganizationId(org.id); + return this._paymentService.getSubscription(org.id); } @Post('/cancel') @@ -142,15 +176,15 @@ export class BillingController { user.email ); - return this._stripeService.setToCancel(org.id); + return (await this.provider(org)).setToCancel(org.id); } @Post('/prorate') - prorate( + async prorate( @GetOrgFromRequest() org: Organization, @Body() body: BillingSubscribeDto ) { - return this._stripeService.prorate(org.id, body); + return (await this.provider(org)).prorate(org.id, body); } @Get('/charges') @@ -162,7 +196,7 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } - return this._stripeService.getCharges(org.id); + return (await this.provider(org)).getCharges(org.id); } @Post('/refund-charges') @@ -175,7 +209,7 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } - return this._stripeService.refundCharges(org.id, body.chargeIds); + return (await this.provider(org)).refundCharges(org.id, body.chargeIds); } @Post('/cancel-subscription') @@ -187,7 +221,7 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } - return this._stripeService.cancelSubscription(org.id); + return (await this.provider(org)).cancelSubscription(org.id); } @Get('/coupon-info') @@ -199,7 +233,7 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } - return this._stripeService.getCouponInfo(org.id); + return (await this.provider(org)).getCouponInfo(org.id); } @Post('/apply-coupon') @@ -212,7 +246,7 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } - return this._stripeService.applyCoupon(org.id, body); + return (await this.provider(org)).applyCoupon(org.id, body); } @Post('/cancel-coupon') @@ -224,12 +258,12 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } - return this._stripeService.cancelCoupon(org.id); + return (await this.provider(org)).cancelCoupon(org.id); } @Get('/chatbase-refund/preview') - chatbaseRefundPreview(@GetOrgFromRequest() org: Organization) { - return this._stripeService.chatbaseRefundPreview(org.id); + async chatbaseRefundPreview(@GetOrgFromRequest() org: Organization) { + return (await this.provider(org)).chatbaseRefundPreview(org.id); } @Post('/chatbase-refund') @@ -237,7 +271,7 @@ export class BillingController { @GetUserFromRequest() user: User, @GetOrgFromRequest() org: Organization ) { - const refund = await this._stripeService.chatbaseRefund(org.id); + const refund = await (await this.provider(org)).chatbaseRefund(org.id); if (refund.refunded) { await this._notificationService.sendEmail( @@ -264,8 +298,8 @@ export class BillingController { await this._subscriptionService.addSubscription( org.id, user.id, - body.subscription + body.subscription, + this._paymentService.getDefaultProviderName('web') ); } - } diff --git a/apps/backend/src/api/routes/payment.controller.ts b/apps/backend/src/api/routes/payment.controller.ts new file mode 100644 index 0000000000..f0a9964c09 --- /dev/null +++ b/apps/backend/src/api/routes/payment.controller.ts @@ -0,0 +1,36 @@ +import { + Controller, + HttpException, + Param, + Post, + RawBodyRequest, + Req, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; + +@ApiTags('Payment') +@Controller('/payment') +export class PaymentController { + constructor(private readonly _paymentService: PaymentService) {} + + @Post('/:provider') + async webhook( + @Param('provider') provider: string, + @Req() req: RawBodyRequest + ) { + try { + return await this._paymentService.webhook( + provider, + req.rawBody, + // @ts-ignore + req.headers + ); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + throw new HttpException(e, 500); + } + } +} diff --git a/apps/backend/src/api/routes/stripe.controller.ts b/apps/backend/src/api/routes/stripe.controller.ts index 37aadf24bc..699a0b3fea 100644 --- a/apps/backend/src/api/routes/stripe.controller.ts +++ b/apps/backend/src/api/routes/stripe.controller.ts @@ -5,49 +5,29 @@ import { RawBodyRequest, Req, } from '@nestjs/common'; -import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; import { ApiTags } from '@nestjs/swagger'; +import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; +import { STRIPE_PROVIDER } from '@gitroom/nestjs-libraries/services/payment/payment.providers'; +// Legacy webhook path, kept until the Stripe dashboard points at /payment/stripe @ApiTags('Stripe') @Controller('/stripe') export class StripeController { - constructor( - private readonly _stripeService: StripeService, - ) {} + constructor(private readonly _paymentService: PaymentService) {} @Post('/') - stripe(@Req() req: RawBodyRequest) { - const event = this._stripeService.validateRequest( - req.rawBody, - // @ts-ignore - req.headers['stripe-signature'], - process.env.STRIPE_SIGNING_KEY - ); - - // Maybe it comes from another stripe webhook - if ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - event?.data?.object?.metadata?.service !== 'gitroom' && - event.type !== 'invoice.payment_succeeded' - ) { - return { ok: true }; - } - + async stripe(@Req() req: RawBodyRequest) { try { - switch (event.type) { - case 'invoice.payment_succeeded': - return this._stripeService.paymentSucceeded(event); - case 'customer.subscription.created': - return this._stripeService.createSubscription(event); - case 'customer.subscription.updated': - return this._stripeService.updateSubscription(event); - case 'customer.subscription.deleted': - return this._stripeService.deleteSubscription(event); - default: - return { ok: true }; - } + return await this._paymentService.webhook( + STRIPE_PROVIDER, + req.rawBody, + // @ts-ignore + req.headers + ); } catch (e) { + if (e instanceof HttpException) { + throw e; + } throw new HttpException(e, 500); } } diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 896cc03b17..ca15e470e3 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -13,7 +13,7 @@ import { sign } from 'jsonwebtoken'; import { Organization, User } from '@prisma/client'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; -import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; +import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; import { Response, Request } from 'express'; import { AuthService } from '@gitroom/backend/services/auth/auth.service'; import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service'; @@ -41,7 +41,7 @@ import { export class UsersController { constructor( private _subscriptionService: SubscriptionService, - private _stripeService: StripeService, + private _paymentService: PaymentService, private _authService: AuthService, private _orgService: OrganizationService, private _userService: UsersService, @@ -214,7 +214,7 @@ export class UsersController { adminId ); - await this._stripeService.syncCustomerEmailsAfterSwitch([kept, switched]); + await this._paymentService.syncCustomerEmailsAfterSwitch([kept, switched]); return { success: true }; } @@ -259,10 +259,9 @@ export class UsersController { @Get('/subscription') @CheckPolicies([AuthorizationActions.Create, Sections.ADMIN]) async getSubscription(@GetOrgFromRequest() organization: Organization) { - const subscription = - await this._subscriptionService.getSubscriptionByOrganizationId( - organization.id - ); + const subscription = await this._paymentService.getSubscription( + organization.id + ); return subscription ? { subscription } : { subscription: undefined }; } @@ -270,7 +269,7 @@ export class UsersController { @Get('/subscription/tiers') @CheckPolicies([AuthorizationActions.Create, Sections.ADMIN]) async tiers() { - return this._stripeService.getPackages(); + return this._paymentService.getDefaultProvider('web').getPackages(); } @Post('/join-org') @@ -348,20 +347,15 @@ export class UsersController { user.id ); - if (process.env.STRIPE_PUBLISHABLE_KEY) { - for (const org of ownedOrgs) { - if (!org.paymentId) { - continue; - } - try { - await this._stripeService.cancelAllSubscriptions(org.id); - } catch (err) { - console.log(err); - throw new HttpException( - 'Could not cancel your subscription, please try again or contact support', - 400 - ); - } + for (const org of ownedOrgs) { + try { + await this._paymentService.cancelAllSubscriptions(org.id); + } catch (err) { + console.log(err); + throw new HttpException( + 'Could not cancel your subscription, please try again or contact support', + 400 + ); } } diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 523e442b85..ff75f66dba 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -29,6 +29,10 @@ import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; import { useDubClickId } from '@gitroom/frontend/components/layout/dubAnalytics'; import { LogoutComponent } from '@gitroom/frontend/components/layout/logout.component'; +type SubscriptionWithPlatform = Subscription & { + platform?: 'web' | 'mobile'; +}; + export const Prorate: FC<{ period: 'MONTHLY' | 'YEARLY'; pack: 'STANDARD' | 'PRO'; @@ -209,7 +213,7 @@ const Info: FC<{ ); }; export const MainBillingComponent: FC<{ - sub?: Subscription; + sub?: SubscriptionWithPlatform; }> = (props) => { const { sub } = props; const { isGeneral } = useVariables(); @@ -228,7 +232,7 @@ export const MainBillingComponent: FC<{ !!queryParams.get('finishTrial') ); - const [subscription, setSubscription] = useState( + const [subscription, setSubscription] = useState( sub ); const [loading, setLoading] = useState(false); @@ -449,6 +453,30 @@ export const MainBillingComponent: FC<{ router.replace('/'); return null; } + if (subscription?.platform && subscription.platform !== 'web') { + return ( +
+
{t('plans', 'Plans')}
+
+
+ {t('subscription_managed_by', 'Your subscription is managed by')}{' '} + {subscription.provider} +
+
+ {t( + 'subscription_manage_on_platform', + 'Please go to {{platform}} to manage it', + { platform: subscription.platform } + )} +
+
+ +
+ +
+
+ ); + } return (
diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts index 252b01d396..9660d68911 100644 --- a/libraries/nestjs-libraries/src/database/prisma/database.module.ts +++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts @@ -17,6 +17,9 @@ import { MediaRepository } from '@gitroom/nestjs-libraries/database/prisma/media import { NotificationsRepository } from '@gitroom/nestjs-libraries/database/prisma/notifications/notifications.repository'; import { EmailService } from '@gitroom/nestjs-libraries/services/email.service'; import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; +import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; +import { PaymentProviderManager } from '@gitroom/nestjs-libraries/services/payment/payment.provider.manager'; +import { RevenueCatProvider } from '@gitroom/nestjs-libraries/services/payment/providers/revenuecat.provider'; import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service'; import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service'; @@ -68,6 +71,9 @@ import { AdminStatsService } from '@gitroom/nestjs-libraries/database/prisma/adm PostsService, PostsRepository, StripeService, + PaymentService, + PaymentProviderManager, + RevenueCatProvider, SignatureRepository, AutopostRepository, AutopostService, diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index a0c7ca1540..623bc0c68f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -288,6 +288,7 @@ model Subscription { id String @id @default(cuid()) organizationId String @unique subscriptionTier SubscriptionTier + provider String @default("stripe") identifier String? cancelAt DateTime? period Period 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 afaa9b09a4..1071b7fc50 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -88,9 +88,10 @@ export class SubscriptionRepository { }); } - deleteSubscriptionByCustomerId(customerId: string) { + deleteSubscriptionByCustomerId(customerId: string, provider: string) { return this._subscription.model.subscription.deleteMany({ where: { + provider, organization: { paymentId: customerId, }, @@ -98,6 +99,15 @@ export class SubscriptionRepository { }); } + deleteSubscriptionByOrgId(organizationId: string, provider: string) { + return this._subscription.model.subscription.deleteMany({ + where: { + organizationId, + provider, + }, + }); + } + updateCustomerId(organizationId: string, customerId: string) { return this._organization.model.organization.update({ where: { @@ -136,6 +146,7 @@ export class SubscriptionRepository { } async createOrUpdateSubscription( + provider: string, isTrailing: boolean, identifier: string, customerId: string, @@ -156,7 +167,7 @@ export class SubscriptionRepository { await this._subscription.model.subscription.upsert({ where: { organizationId: findOrg.id, - ...(!code + ...(!code && customerId ? { organization: { paymentId: customerId, @@ -166,6 +177,7 @@ export class SubscriptionRepository { }, update: { subscriptionTier: billing, + provider, totalChannels, period, identifier, @@ -176,6 +188,7 @@ export class SubscriptionRepository { create: { organizationId: findOrg.id, subscriptionTier: billing, + provider, isLifetime: !!code, totalChannels, period, 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 f607e6d134..aaf2ed471d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -33,14 +33,47 @@ export class SubscriptionService { return this._subscriptionRepository.getCode(code); } - async deleteSubscription(customerId: string) { + // Customer-keyed flows must never touch a subscription owned by another provider + private async isManagedBy(customerId: string, provider: string) { + const current = + await this._subscriptionRepository.getSubscriptionByCustomerId( + customerId + ); + return !current || current.provider === provider; + } + + async deleteSubscription(customerId: string, provider: string) { + if (!(await this.isManagedBy(customerId, provider))) { + return { count: 0 }; + } await this.modifySubscription( customerId, pricing.FREE.channel || 0, 'FREE' ); return this._subscriptionRepository.deleteSubscriptionByCustomerId( - customerId + customerId, + provider + ); + } + + // Store-managed subscriptions (RevenueCat etc.) have no Stripe customer, they are keyed by org + async deleteSubscriptionByOrgId(organizationId: string, provider: string) { + const current = await this._subscriptionRepository.getSubscriptionByOrgId( + organizationId + ); + if (!current || current.provider !== provider || current.isLifetime) { + return false; + } + + await this.modifySubscriptionByOrg( + organizationId, + pricing.FREE.channel || 0, + 'FREE' + ); + return this._subscriptionRepository.deleteSubscriptionByOrgId( + organizationId, + provider ); } @@ -171,6 +204,7 @@ export class SubscriptionService { } async createOrUpdateSubscription( + provider: string, isTrailing: boolean, identifier: string, customerId: string, @@ -182,6 +216,9 @@ export class SubscriptionService { org?: string ) { if (!code) { + if (!(await this.isManagedBy(customerId, provider))) { + return {}; + } try { const load = await this.modifySubscription( customerId, @@ -196,6 +233,7 @@ export class SubscriptionService { } } return this._subscriptionRepository.createOrUpdateSubscription( + provider, isTrailing, identifier, customerId, @@ -208,6 +246,50 @@ export class SubscriptionService { ); } + async createOrUpdateSubscriptionByOrg( + isTrailing: boolean, + organizationId: string, + provider: string, + identifier: string, + totalChannels: number, + billing: 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE', + period: 'MONTHLY' | 'YEARLY', + cancelAt: number | null + ) { + const current = await this._subscriptionRepository.getSubscriptionByOrgId( + organizationId + ); + if (current && (current.isLifetime || current.provider !== provider)) { + return {}; + } + + try { + const load = await this.modifySubscriptionByOrg( + organizationId, + totalChannels, + billing + ); + if (!load) { + return {}; + } + } catch (e) { + return {}; + } + + return this._subscriptionRepository.createOrUpdateSubscription( + provider, + isTrailing, + identifier, + '', + totalChannels, + billing, + period, + cancelAt, + undefined, + { id: organizationId } + ); + } + getSubscriptionByIdentifier(identifier: string) { return this._subscriptionRepository.getSubscriptionByIdentifier(identifier); } @@ -247,9 +329,15 @@ export class SubscriptionService { }; } - async addSubscription(orgId: string, userId: string, subscription: any) { + async addSubscription( + orgId: string, + userId: string, + subscription: any, + provider: string + ) { await this._subscriptionRepository.setCustomerId(orgId, userId); return this.createOrUpdateSubscription( + provider, false, makeId(5), userId, diff --git a/libraries/nestjs-libraries/src/dtos/billing/billing.sync.dto.ts b/libraries/nestjs-libraries/src/dtos/billing/billing.sync.dto.ts new file mode 100644 index 0000000000..f3b1bcf087 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/billing/billing.sync.dto.ts @@ -0,0 +1,7 @@ +import { IsDefined, IsString } from 'class-validator'; + +export class BillingSyncDto { + @IsDefined() + @IsString() + provider: string; +} diff --git a/libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts b/libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts new file mode 100644 index 0000000000..be4be9e784 --- /dev/null +++ b/libraries/nestjs-libraries/src/services/payment/payment.provider.interface.ts @@ -0,0 +1,185 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { Organization } from '@prisma/client'; +import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto'; +import { AdminApplyCouponDto } from '@gitroom/nestjs-libraries/dtos/billing/admin.apply.coupon.dto'; + +export type PaymentPlatform = 'web' | 'mobile'; + +// Every billing use-case goes through this contract. Webhooks and `platform` +// are mandatory; everything else has a default that says "not supported on +// this platform" so a provider only implements what its platform offers +// (e.g. app stores have no hosted checkout / portal / coupons). +export abstract class PaymentProviderAbstract { + // Where the subscription is bought / managed. An organization can only be + // subscribed on one platform at a time, the other platform is blocked. + abstract platform: PaymentPlatform; + + // Turn the raw webhook request into a trusted event (throw on bad signature / secret) + abstract validateWebhook( + rawBody: Buffer, + headers: Record + ): Promise | any; + + // Apply a validated webhook event to our subscriptions + abstract processWebhook(event: any): Promise; + + protected notSupported(): never { + throw new HttpException( + `This action is not supported on ${this.platform}`, + 400 + ); + } + + // --- subscription lifecycle ------------------------------------------- + + // Client initiated re-check of the organization subscription against the + // provider (mobile app after purchase / restore) + async syncSubscription(organizationId: string): Promise<{ active: boolean }> { + return this.notSupported(); + } + + // Hosted checkout page + async subscribe( + uniqueId: string, + organizationId: string, + userId: string, + body: BillingSubscribeDto, + allowTrial: boolean + ): Promise { + return this.notSupported(); + } + + // Embedded checkout (client secret rendered inside our page) + async embedded( + uniqueId: string, + organizationId: string, + userId: string, + body: BillingSubscribeDto, + allowTrial: boolean + ): Promise { + return this.notSupported(); + } + + // Price difference preview when switching plans + async prorate( + organizationId: string, + body: BillingSubscribeDto + ): Promise { + return this.notSupported(); + } + + // Toggle cancel-at-period-end + async setToCancel(organizationId: string): Promise { + return this.notSupported(); + } + + // Cancel everything right now (account deletion). Must not throw when the + // provider has nothing to cancel server side. + async cancelAllSubscriptions(organizationId: string): Promise { + return this.notSupported(); + } + + // Self-service portal (payment method / invoices) + async portalLink(organizationId: string): Promise<{ url: string }> { + return this.notSupported(); + } + + // Post-checkout poll: is this subscription id active for the org + async checkSubscription( + organizationId: string, + subscriptionId: string + ): Promise { + return this.notSupported(); + } + + // Available plans / prices + async getPackages(): Promise { + return this.notSupported(); + } + + // --- trial & discounts -------------------------------------------------- + + async finishTrial(organization: Organization): Promise { + return this.notSupported(); + } + + async checkDiscount(organization: Organization): Promise { + return false; + } + + async applyDiscount(organization: Organization): Promise { + return this.notSupported(); + } + + async lifetimeDeal(organizationId: string, code: string): Promise { + return this.notSupported(); + } + + // --- admin -------------------------------------------------------------- + + async getCharges(organizationId: string): Promise { + return this.notSupported(); + } + + async refundCharges( + organizationId: string, + chargeIds: string[] + ): Promise { + return this.notSupported(); + } + + async cancelSubscription(organizationId: string): Promise { + return this.notSupported(); + } + + async getCouponInfo(organizationId: string): Promise { + return this.notSupported(); + } + + async applyCoupon( + organizationId: string, + body: AdminApplyCouponDto + ): Promise { + return this.notSupported(); + } + + async cancelCoupon(organizationId: string): Promise { + return this.notSupported(); + } + + async chatbaseRefundPreview(organizationId: string): Promise { + return this.notSupported(); + } + + async chatbaseRefund( + organizationId: string + ): Promise<{ refunded: boolean; amount?: number; currency?: string }> { + return this.notSupported(); + } + + // After a login swap, keep the provider's customer email in sync (no-op by default) + async syncCustomerEmailsAfterSwitch( + accounts: { id: string; email: string }[] + ): Promise {} +} + +export interface PaymentProviderParams { + provider: string; +} + +export function PaymentProvider(params: PaymentProviderParams) { + return function (target: any) { + Injectable()(target); + + const existingMetadata = + Reflect.getMetadata('payment-provider', PaymentProviderAbstract) || []; + + existingMetadata.push({ target, provider: params.provider }); + + Reflect.defineMetadata( + 'payment-provider', + existingMetadata, + PaymentProviderAbstract + ); + }; +} diff --git a/libraries/nestjs-libraries/src/services/payment/payment.provider.manager.ts b/libraries/nestjs-libraries/src/services/payment/payment.provider.manager.ts new file mode 100644 index 0000000000..a5952c8b80 --- /dev/null +++ b/libraries/nestjs-libraries/src/services/payment/payment.provider.manager.ts @@ -0,0 +1,47 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { + PaymentPlatform, + PaymentProviderAbstract, +} from '@gitroom/nestjs-libraries/services/payment/payment.provider.interface'; + +@Injectable() +export class PaymentProviderManager { + constructor(private _moduleRef: ModuleRef) {} + + private metadata(): { target: any; provider: string }[] { + return ( + Reflect.getMetadata('payment-provider', PaymentProviderAbstract) || [] + ); + } + + getProvider(provider: string): PaymentProviderAbstract { + const found = this.metadata().find((m) => m.provider === provider); + + if (!found) { + throw new HttpException(`Payment provider ${provider} not found`, 400); + } + + return this._moduleRef.get(found.target, { strict: false }); + } + + getProviders(): { name: string; provider: PaymentProviderAbstract }[] { + return this.metadata().map((m) => ({ + name: m.provider, + provider: this._moduleRef.get(m.target, { strict: false }), + })); + } + + // First registered provider of a platform is the one new subscriptions use + getDefaultProvider(platform: PaymentPlatform) { + const found = this.getProviders().find( + (p) => p.provider.platform === platform + ); + + if (!found) { + throw new Error(`No payment provider registered for ${platform}`); + } + + return found; + } +} diff --git a/libraries/nestjs-libraries/src/services/payment/payment.providers.ts b/libraries/nestjs-libraries/src/services/payment/payment.providers.ts new file mode 100644 index 0000000000..ddf9a2035d --- /dev/null +++ b/libraries/nestjs-libraries/src/services/payment/payment.providers.ts @@ -0,0 +1,2 @@ +export const STRIPE_PROVIDER = 'stripe'; +export const REVENUECAT_PROVIDER = 'revenuecat'; diff --git a/libraries/nestjs-libraries/src/services/payment/payment.service.ts b/libraries/nestjs-libraries/src/services/payment/payment.service.ts new file mode 100644 index 0000000000..01bb75a9c1 --- /dev/null +++ b/libraries/nestjs-libraries/src/services/payment/payment.service.ts @@ -0,0 +1,146 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { PaymentProviderManager } from '@gitroom/nestjs-libraries/services/payment/payment.provider.manager'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { PaymentPlatform } from '@gitroom/nestjs-libraries/services/payment/payment.provider.interface'; + +@Injectable() +export class PaymentService { + constructor( + private _paymentProviderManager: PaymentProviderManager, + private _subscriptionService: SubscriptionService + ) {} + + async webhook( + provider: string, + rawBody: Buffer, + headers: Record + ) { + const paymentProvider = this._paymentProviderManager.getProvider(provider); + const event = await paymentProvider.validateWebhook(rawBody, headers); + return paymentProvider.processWebhook(event); + } + + syncSubscription(provider: string, organizationId: string) { + return this._paymentProviderManager + .getProvider(provider) + .syncSubscription(organizationId); + } + + getDefaultProvider(platform: PaymentPlatform) { + return this._paymentProviderManager.getDefaultProvider(platform).provider; + } + + getDefaultProviderName(platform: PaymentPlatform) { + return this._paymentProviderManager.getDefaultProvider(platform).name; + } + + // Subscription row + the platform (web / mobile) of the provider that owns it, + // so clients can tell the user where to manage the subscription. + async getSubscription(organizationId: string) { + const subscription = + await this._subscriptionService.getSubscriptionByOrganizationId( + organizationId + ); + if (!subscription) { + return null; + } + + // A dangling provider name (provider removed from the build) must not + // break reading the subscription + let platform: PaymentPlatform | undefined; + try { + platform = this._paymentProviderManager.getProvider( + subscription.provider + ).platform; + } catch (err) { + platform = undefined; + } + + return { + ...subscription, + platform, + }; + } + + // The provider that handles billing actions for this organization on the + // given platform: the one owning the current subscription, otherwise the + // platform default. Throws when the subscription lives on another platform. + async getProviderForOrganization( + organizationId: string, + platform: PaymentPlatform + ) { + const subscription = + await this._subscriptionService.getSubscriptionByOrganizationId( + organizationId + ); + + if (!subscription) { + return this._paymentProviderManager.getDefaultProvider(platform).provider; + } + + const current = this._paymentProviderManager.getProvider( + subscription.provider + ); + if (current.platform !== platform) { + throw new HttpException( + `Your subscription is managed on ${current.platform}, please use ${current.platform} to manage it`, + 400 + ); + } + + return current; + } + + // An organization subscribed through one provider cannot checkout, sync or + // manage the subscription through another one. + async assertCanUseProvider(organizationId: string, provider: string) { + const subscription = + await this._subscriptionService.getSubscriptionByOrganizationId( + organizationId + ); + + if (!subscription || subscription.provider === provider) { + return; + } + + const current = this._paymentProviderManager.getProvider( + subscription.provider + ); + const requested = this._paymentProviderManager.getProvider(provider); + + throw new HttpException( + current.platform !== requested.platform + ? `Your subscription is managed on ${current.platform}, please use ${current.platform} to manage it` + : `Your subscription is managed by ${subscription.provider}`, + 400 + ); + } + + // Account deletion: the provider owning the org's subscription, or - when + // there is no row (missed webhook, cleaned up) - every provider, so nothing + // keeps charging a deleted account. Providers no-op when they own nothing. + async cancelAllSubscriptions(organizationId: string) { + const subscription = + await this._subscriptionService.getSubscriptionByOrganizationId( + organizationId + ); + + if (subscription) { + return this._paymentProviderManager + .getProvider(subscription.provider) + .cancelAllSubscriptions(organizationId); + } + + for (const { provider } of this._paymentProviderManager.getProviders()) { + await provider.cancelAllSubscriptions(organizationId); + } + } + + async syncCustomerEmailsAfterSwitch( + accounts: { id: string; email: string }[] + ) { + for (const { provider } of this._paymentProviderManager.getProviders()) { + await provider.syncCustomerEmailsAfterSwitch(accounts); + } + } +} diff --git a/libraries/nestjs-libraries/src/services/payment/providers/revenuecat.provider.ts b/libraries/nestjs-libraries/src/services/payment/providers/revenuecat.provider.ts new file mode 100644 index 0000000000..aeded4179a --- /dev/null +++ b/libraries/nestjs-libraries/src/services/payment/providers/revenuecat.provider.ts @@ -0,0 +1,186 @@ +import { HttpException } from '@nestjs/common'; +import dayjs from 'dayjs'; +import { uniq } from 'lodash'; +import { + PaymentPlatform, + PaymentProvider, + PaymentProviderAbstract, +} from '@gitroom/nestjs-libraries/services/payment/payment.provider.interface'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; +import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; + +import { REVENUECAT_PROVIDER } from '@gitroom/nestjs-libraries/services/payment/payment.providers'; + +interface RevenueCatSubscription { + expires_date: string | null; + purchase_date: string; + original_purchase_date: string; + unsubscribe_detected_at: string | null; + billing_issues_detected_at: string | null; + is_sandbox: boolean; + store: string; + period_type: string; +} + +// RevenueCat is the source of truth for App Store / Google Play subscriptions. +// The webhook and the app only tell us *which* subscriber to re-check; we always +// load the subscriber from the API instead of trusting the event payload. +// app_user_id === Postiz organization id (the app calls Purchases.logIn(orgId)). +// Product ids end with . or _, e.g. com.postiz.mob.pro.yearly +@PaymentProvider({ provider: REVENUECAT_PROVIDER }) +export class RevenueCatProvider extends PaymentProviderAbstract { + platform: PaymentPlatform = 'mobile'; + + constructor( + private _subscriptionService: SubscriptionService, + private _organizationService: OrganizationService + ) { + super(); + } + + validateWebhook( + rawBody: Buffer, + headers: Record + ) { + const secret = process.env.REVENUECAT_WEBHOOK_SECRET; + if (!secret || headers.authorization !== secret) { + throw new HttpException('Invalid webhook authorization', 401); + } + + return JSON.parse(rawBody.toString('utf8')); + } + + async processWebhook(body: any) { + const event = body?.event; + if (!event || event.type === 'TEST') { + return { ok: true }; + } + + const organizations = uniq( + [ + event.app_user_id, + event.original_app_user_id, + event.transferred_from, + event.transferred_to, + ] + .flat() + .filter((p: string) => p && !p.startsWith('$RCAnonymousID:')) + ) as string[]; + + for (const organizationId of organizations) { + await this.syncSubscription(organizationId); + } + + return { ok: true }; + } + + // Store subscriptions can only be cancelled by the user in the App Store / + // Google Play; account deletion must not be blocked by it. + override async cancelAllSubscriptions(organizationId: string) { + return; + } + + override async syncSubscription(organizationId: string) { + const organization = await this._organizationService.getOrgById( + organizationId + ); + if (!organization) { + return { active: false }; + } + + const active = await this.getActiveSubscription(organizationId); + if (!active) { + await this._subscriptionService.deleteSubscriptionByOrgId( + organizationId, + REVENUECAT_PROVIDER + ); + return { active: false }; + } + + const { billing, period } = this.parseProductId(active.productId); + + // Store trials (intro offers) keep the same trial restrictions as a Stripe trial + await this._subscriptionService.createOrUpdateSubscriptionByOrg( + active.subscription.period_type === 'trial', + organizationId, + REVENUECAT_PROVIDER, + active.productId, + pricing[billing].channel!, + billing, + period, + active.subscription.unsubscribe_detected_at && + active.subscription.expires_date + ? dayjs(active.subscription.expires_date).unix() + : null + ); + + return { active: true }; + } + + private async getActiveSubscription(organizationId: string) { + if (!process.env.REVENUECAT_SECRET_KEY) { + throw new HttpException('RevenueCat is not configured', 400); + } + + const response = await fetch( + `https://api.revenuecat.com/v1/subscribers/${encodeURIComponent( + organizationId + )}`, + { + headers: { + Authorization: `Bearer ${process.env.REVENUECAT_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new HttpException( + `RevenueCat subscriber request failed (${response.status})`, + 500 + ); + } + + const { subscriber } = await response.json(); + const subscriptions: Record = + subscriber?.subscriptions || {}; + + const rejectSandbox = !!process.env.IN_APP_PURCHASE_REJECT_SANDBOX; + + return Object.entries(subscriptions) + .map(([productId, subscription]) => ({ productId, subscription })) + .filter( + ({ subscription }) => + (!rejectSandbox || !subscription.is_sandbox) && + (!subscription.expires_date || + dayjs(subscription.expires_date).isAfter(dayjs())) + ) + .sort((a, b) => + dayjs(b.subscription.expires_date || '2999-01-01').diff( + dayjs(a.subscription.expires_date || '2999-01-01') + ) + )[0]; + } + + private parseProductId(productId: string) { + const parts = productId.split(/[._]/); + const period = (parts.pop() || '').toUpperCase(); + const billing = (parts.pop() || '').toUpperCase(); + + if ( + !['MONTHLY', 'YEARLY'].includes(period) || + !['STANDARD', 'TEAM', 'PRO', 'ULTIMATE'].includes(billing) + ) { + throw new HttpException( + `Unknown RevenueCat product identifier: ${productId}`, + 400 + ); + } + + return { + billing: billing as 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE', + period: period as 'MONTHLY' | 'YEARLY', + }; + } +} diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 06c6559a2e..1858277e9a 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -11,21 +11,68 @@ import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum'; +import { + PaymentPlatform, + PaymentProvider, + PaymentProviderAbstract, +} from '@gitroom/nestjs-libraries/services/payment/payment.provider.interface'; + +import { STRIPE_PROVIDER } from '@gitroom/nestjs-libraries/services/payment/payment.providers'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_nothing'); -@Injectable() -export class StripeService { +@PaymentProvider({ provider: STRIPE_PROVIDER }) +export class StripeService extends PaymentProviderAbstract { + platform: PaymentPlatform = 'web'; + constructor( private _subscriptionService: SubscriptionService, private _organizationService: OrganizationService, private _userService: UsersService, private _trackService: TrackService - ) {} + ) { + super(); + } validateRequest(rawBody: Buffer, signature: string, endpointSecret: string) { return stripe.webhooks.constructEvent(rawBody, signature, endpointSecret); } + validateWebhook( + rawBody: Buffer, + headers: Record + ) { + return this.validateRequest( + rawBody, + headers['stripe-signature'] as string, + process.env.STRIPE_SIGNING_KEY + ); + } + + async processWebhook(event: Stripe.Event) { + // Maybe it comes from another stripe webhook + if ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + event?.data?.object?.metadata?.service !== 'gitroom' && + event.type !== 'invoice.payment_succeeded' + ) { + return { ok: true }; + } + + switch (event.type) { + case 'invoice.payment_succeeded': + return this.paymentSucceeded(event); + case 'customer.subscription.created': + return this.createSubscription(event); + case 'customer.subscription.updated': + return this.updateSubscription(event); + case 'customer.subscription.deleted': + return this.deleteSubscription(event); + default: + return { ok: true }; + } + } + async checkValidCard( event: | Stripe.CustomerSubscriptionCreatedEvent @@ -97,11 +144,7 @@ export class StripeService { } async createSubscription(event: Stripe.CustomerSubscriptionCreatedEvent) { - const { - uniqueId, - billing, - period, - } = event.data.object.metadata as { + const { uniqueId, billing, period } = event.data.object.metadata as { billing: 'STANDARD' | 'PRO'; period: 'MONTHLY' | 'YEARLY'; uniqueId: string; @@ -117,6 +160,7 @@ export class StripeService { } return this._subscriptionService.createOrUpdateSubscription( + STRIPE_PROVIDER, event.data.object.status !== 'active', uniqueId, event.data.object.customer as string, @@ -127,11 +171,7 @@ export class StripeService { ); } async updateSubscription(event: Stripe.CustomerSubscriptionUpdatedEvent) { - const { - uniqueId, - billing, - period, - } = event.data.object.metadata as { + const { uniqueId, billing, period } = event.data.object.metadata as { billing: 'STANDARD' | 'PRO'; period: 'MONTHLY' | 'YEARLY'; uniqueId: string; @@ -143,6 +183,7 @@ export class StripeService { } return this._subscriptionService.createOrUpdateSubscription( + STRIPE_PROVIDER, event.data.object.status !== 'active', uniqueId, event.data.object.customer as string, @@ -155,7 +196,8 @@ export class StripeService { async deleteSubscription(event: Stripe.CustomerSubscriptionDeletedEvent) { await this._subscriptionService.deleteSubscription( - event.data.object.customer as string + event.data.object.customer as string, + STRIPE_PROVIDER ); } @@ -202,7 +244,10 @@ export class StripeService { const users = await this._organizationService.getTeam(organization.id); const customer = await stripe.customers.create({ - email: users.users[0].user.email.indexOf('@') > -1 ? users.users[0].user.email : `${users.users[0].user.email}@postiz.com`, + email: + users.users[0].user.email.indexOf('@') > -1 + ? users.users[0].user.email + : `${users.users[0].user.email}@postiz.com`, name: organization.name, }); await this._subscriptionService.updateCustomerId( @@ -370,7 +415,10 @@ export class StripeService { if (hasFailedPayment) { // Payment already failed — cancel immediately and delete subscription await stripe.subscriptions.cancel(sub.id); - await this._subscriptionService.deleteSubscription(customer); + await this._subscriptionService.deleteSubscription( + customer, + STRIPE_PROVIDER + ); return { id, @@ -391,6 +439,9 @@ export class StripeService { } async cancelAllSubscriptions(organizationId: string) { + if (!process.env.STRIPE_PUBLISHABLE_KEY) { + return; + } // getOrgById must not filter deletedAt, this can run for an organization // that was already soft deleted by an account deletion const org = await this._organizationService.getOrgById(organizationId); @@ -410,7 +461,10 @@ export class StripeService { await stripe.subscriptions.cancel(subscription.id); } - await this._subscriptionService.deleteSubscription(org.paymentId); + await this._subscriptionService.deleteSubscription( + org.paymentId, + STRIPE_PROVIDER + ); } async getCustomerByOrganizationId(organizationId: string) { @@ -490,7 +544,10 @@ export class StripeService { try { await stripe.customers.update(customer, { - email: user.email.indexOf('@') > -1 ? user.email : `${user.email}@postiz.com`, + email: + user.email.indexOf('@') > -1 + ? user.email + : `${user.email}@postiz.com`, ...(body.dub ? { metadata: { @@ -599,10 +656,15 @@ export class StripeService { return { url }; } - async finishTrial(paymentId: string) { + async portalLink(organizationId: string) { + const customer = await this.getCustomerByOrganizationId(organizationId); + return this.createBillingPortalLink(customer); + } + + async finishTrial(organization: Organization) { const list = ( await stripe.subscriptions.list({ - customer: paymentId, + customer: organization.paymentId, }) ).data.filter((f) => f.status === 'trialing'); @@ -611,8 +673,9 @@ export class StripeService { }); } - async checkDiscount(customer: string) { - if (!process.env.STRIPE_DISCOUNT_ID) { + async checkDiscount(organization: Organization) { + const customer = organization.paymentId; + if (!process.env.STRIPE_DISCOUNT_ID || !customer) { return false; } @@ -650,8 +713,9 @@ export class StripeService { return true; } - async applyDiscount(customer: string) { - const check = this.checkDiscount(customer); + async applyDiscount(organization: Organization) { + const customer = organization.paymentId; + const check = this.checkDiscount(organization); if (!check) { return false; } @@ -993,7 +1057,10 @@ export class StripeService { } await stripe.subscriptions.cancel(subscriptions[0].id); - await this._subscriptionService.deleteSubscription(customer); + await this._subscriptionService.deleteSubscription( + customer, + STRIPE_PROVIDER + ); return { cancelled: true }; } @@ -1228,9 +1295,7 @@ export class StripeService { ? invoiceSubscription : invoiceSubscription?.id; - chargeSubscription = subscriptions.find( - (f) => f.id === subscriptionId - ); + chargeSubscription = subscriptions.find((f) => f.id === subscriptionId); if (chargeSubscription) { lastCharge = charge; @@ -1306,7 +1371,10 @@ export class StripeService { } if (preview.subscriptionIds.length) { - await this._subscriptionService.deleteSubscription(org?.paymentId!); + await this._subscriptionService.deleteSubscription( + org?.paymentId!, + STRIPE_PROVIDER + ); } return { @@ -1339,6 +1407,7 @@ export class StripeService { const findPricing = pricing[nextPackage]; await this._subscriptionService.createOrUpdateSubscription( + STRIPE_PROVIDER, false, makeId(10), organizationId, From 6ca278a7227f29d352fab99e49c975569d06d54b Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 27 Aug 2026 10:28:09 +0700 Subject: [PATCH 03/20] feat: tc --- .env.example | 2 +- apps/backend/src/api/api.module.ts | 2 -- .../src/api/routes/stripe.controller.ts | 34 ------------------- 3 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 apps/backend/src/api/routes/stripe.controller.ts diff --git a/.env.example b/.env.example index 87d6b92144..4211d67cac 100644 --- a/.env.example +++ b/.env.example @@ -129,7 +129,7 @@ STRIPE_SECRET_KEY="" STRIPE_SIGNING_KEY="" STRIPE_SIGNING_KEY_CONNECT="" # RevenueCat (App Store / Google Play subscriptions from the mobile app) -# Webhook URL: {BACKEND_URL}/payment/revenuecat, Authorization header value = REVENUECAT_WEBHOOK_SECRET +# Webhook URLs: {BACKEND_URL}/payment/stripe and {BACKEND_URL}/payment/revenuecat, Authorization header value = REVENUECAT_WEBHOOK_SECRET REVENUECAT_SECRET_KEY="" REVENUECAT_WEBHOOK_SECRET="" # IN_APP_PURCHASE_REJECT_SANDBOX=true diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index e986350426..54787f590e 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -3,7 +3,6 @@ import { AuthController } from '@gitroom/backend/api/routes/auth.controller'; import { AuthService } from '@gitroom/backend/services/auth/auth.service'; import { UsersController } from '@gitroom/backend/api/routes/users.controller'; import { AuthMiddleware } from '@gitroom/backend/services/auth/auth.middleware'; -import { StripeController } from '@gitroom/backend/api/routes/stripe.controller'; import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service'; import { PaymentController } from '@gitroom/backend/api/routes/payment.controller'; import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; @@ -79,7 +78,6 @@ const authenticatedController = [ ? [RootController, OAuthController] : [ RootController, - StripeController, PaymentController, AuthController, PublicController, diff --git a/apps/backend/src/api/routes/stripe.controller.ts b/apps/backend/src/api/routes/stripe.controller.ts deleted file mode 100644 index 699a0b3fea..0000000000 --- a/apps/backend/src/api/routes/stripe.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - Controller, - HttpException, - Post, - RawBodyRequest, - Req, -} from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; -import { STRIPE_PROVIDER } from '@gitroom/nestjs-libraries/services/payment/payment.providers'; - -// Legacy webhook path, kept until the Stripe dashboard points at /payment/stripe -@ApiTags('Stripe') -@Controller('/stripe') -export class StripeController { - constructor(private readonly _paymentService: PaymentService) {} - - @Post('/') - async stripe(@Req() req: RawBodyRequest) { - try { - return await this._paymentService.webhook( - STRIPE_PROVIDER, - req.rawBody, - // @ts-ignore - req.headers - ); - } catch (e) { - if (e instanceof HttpException) { - throw e; - } - throw new HttpException(e, 500); - } - } -} From 1450d87600df3c3ad3f11b79536132e5bd52de9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:53:13 +0000 Subject: [PATCH 04/20] chore: add EVOLINK_API_KEY to .env.example Co-authored-by: egelhaus <156946629+egelhaus@users.noreply.github.com> --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index dba96b8df8..9e31f6fe76 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,7 @@ EXTENSION_ID="" # Misc Settings OPENAI_API_KEY="" +# EVOLINK_API_KEY="" # EvoLink API key for Veo3 AI video generation (https://evolink.ai) NEXT_PUBLIC_DISCORD_SUPPORT="" NEXT_PUBLIC_POLOTNO="" # NOT_SECURED=false From 6d661f760dae762457ebe4e3cc750679a42a8236 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Fri, 28 Aug 2026 22:34:52 +0200 Subject: [PATCH 05/20] feat(organization-repository): enhance organization filtering and user selection fields --- .../organizations/organization.repository.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 947d5e964d..cb82925184 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -126,6 +126,38 @@ export class OrganizationRepository { contains: name, }, }, + { + organization: { + OR: [ + { + paymentId: { + equals: name, + }, + }, + { + subscription: { + identifier: { + equals: name, + }, + }, + }, + { + Integration: { + some: { + id: name, + }, + }, + }, + { + post: { + some: { + id: name, + }, + }, + }, + ], + }, + }, { user: { OR: [ @@ -154,13 +186,20 @@ export class OrganizationRepository { select: { id: true, role: true, + disabled: true, organization: { select: { id: true, name: true, + paymentId: true, + deletedAt: true, subscription: { select: { subscriptionTier: true, + identifier: true, + isLifetime: true, + period: true, + cancelAt: true, }, }, }, @@ -170,6 +209,9 @@ export class OrganizationRepository { id: true, name: true, email: true, + activated: true, + providerName: true, + deletedAt: true, }, }, }, From d2a7b50af90f52a82a2d41161edadac7c7668cf2 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Sat, 29 Aug 2026 18:44:55 +0200 Subject: [PATCH 06/20] feat(pr-template): enhance QA section requirements and clarity in PR descriptions --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f10cb4c74..dab2318fa1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,8 +64,9 @@ const useCommunity = () => { - Whenever you generate a PR, PR description, or similar, **always** follow the PR Template (.github/PULL_REQUEST_TEMPLATE.md) - Every PR description **must** contain a `# QA` section with real, numbered steps a reviewer can follow to verify the change (setup, action, expected result), written so they can be run without asking the author anything. This is not optional and applies to humans and agents alike, including one-line fixes. The section is extracted verbatim and shown on the review board, so: - Use the exact heading `# QA` (`# Testing`, `# Test plan`, `# How to test`, `# How to verify`, `# Verification`, `# Steps to test` and `# Manual testing` are also recognised, but prefer `# QA`). The whole heading must match, so something like `## Testing philosophy` is not picked up. - - Never leave the template placeholder in place, and never write `N/A`, `TBD`, `todo`, `none` or an empty checkbox there - those all count as no QA at all and the board will show the PR as missing testing notes. - - Steps inside a fenced code block are ignored, so keep them as plain numbered lines. + - Never leave the template placeholder in place, and never write `N/A`, `TBD`, `todo`, `none` or a bare empty checkbox as the whole section - those all count as no QA at all and the board will show the PR as missing testing notes. + - Steps inside a fenced code block are ignored, so keep them as plain numbered lines. Write each step as a numbered checkbox (`1. [ ] step`) so a reviewer can tick it off while working through it - the numbering is what the board extracts, the checkbox is for the reviewer. +- Every PR description **must** answer `# What kind of change does this PR introduce?` with actual detail, not just a category. `Bug fix.` / `Feature.` on its own is not acceptable. State the type, the area it touches (backend, frontend, orchestrator, a specific provider or screen), and in one to three sentences what concretely changed and where - the key function, endpoint, file or field - plus what deliberately stayed the same. A reader should understand the change from this section alone, without opening the diff. - Avoid as much as possible creating new files with pure logic of algorithms, it's usually wrong - When you write code, make sure that what you add looks like something similar somewhere else in the code, don't make weird patterns - When you finished running, run another agents that matches the new code with the existing system code, to see that it looks similar and is not a weird pattern. From a1b9456523989b5c951d8acbe2b57748f90a0867 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Sat, 29 Aug 2026 18:47:44 +0200 Subject: [PATCH 07/20] feat(pr-template): improve clarity and detail in PR description guidelines --- .github/PULL_REQUEST_TEMPLATE.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9fc816ae13..e728f8a883 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,7 +2,21 @@ # What kind of change does this PR introduce? -eg: Bug fix, feature, docs update, ... + # Why was this change needed? @@ -16,13 +30,18 @@ eg: Did you discuss this change with anybody before working on it (not required, # Checklist: From 295fbc067a5469dd8ae41ba32a7739bf584eaa91 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 30 Aug 2026 17:47:01 +0700 Subject: [PATCH 08/20] feat: change evolink model --- ...eo3.provider.tsx => seedance.provider.tsx} | 8 +++---- .../videos/video.render.component.tsx | 2 +- .../src/openai/generation.error.ts | 2 +- .../{veo3/veo3.ts => seedance/seedance.ts} | 24 +++++++++++-------- .../src/videos/video.module.ts | 4 ++-- 5 files changed, 22 insertions(+), 18 deletions(-) rename apps/frontend/src/components/videos/providers/{veo3.provider.tsx => seedance.provider.tsx} (91%) rename libraries/nestjs-libraries/src/videos/{veo3/veo3.ts => seedance/seedance.ts} (81%) diff --git a/apps/frontend/src/components/videos/providers/veo3.provider.tsx b/apps/frontend/src/components/videos/providers/seedance.provider.tsx similarity index 91% rename from apps/frontend/src/components/videos/providers/veo3.provider.tsx rename to apps/frontend/src/components/videos/providers/seedance.provider.tsx index 3e591b2fb2..5841710eee 100644 --- a/apps/frontend/src/components/videos/providers/veo3.provider.tsx +++ b/apps/frontend/src/components/videos/providers/seedance.provider.tsx @@ -12,7 +12,7 @@ export interface Voice { preview_url: string; } -const VEO3Settings: FC = () => { +const SeedanceSettings: FC = () => { const { register, watch, setValue, formState } = useFormContext(); const { value } = useVideo(); @@ -57,8 +57,8 @@ const VEO3Settings: FC = () => { ); }; -const VeoComponent = () => { - return ; +const SeedanceComponent = () => { + return ; }; -videoWrapper('veo3', VeoComponent); +videoWrapper('seedance', SeedanceComponent); diff --git a/apps/frontend/src/components/videos/video.render.component.tsx b/apps/frontend/src/components/videos/video.render.component.tsx index f09d690560..e1716fbc90 100644 --- a/apps/frontend/src/components/videos/video.render.component.tsx +++ b/apps/frontend/src/components/videos/video.render.component.tsx @@ -1,6 +1,6 @@ import { createContext, FC, useCallback, useContext, useEffect } from 'react'; import './providers/image-text-slides.provider'; -import './providers/veo3.provider'; +import './providers/seedance.provider'; import { videosList } from '@gitroom/frontend/components/videos/video.wrapper'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; diff --git a/libraries/nestjs-libraries/src/openai/generation.error.ts b/libraries/nestjs-libraries/src/openai/generation.error.ts index 90f710865b..6493cc3e02 100644 --- a/libraries/nestjs-libraries/src/openai/generation.error.ts +++ b/libraries/nestjs-libraries/src/openai/generation.error.ts @@ -11,7 +11,7 @@ const SAFETY_MESSAGE_REGEX = /** * Normalizes errors thrown by AI generation providers (OpenAI image/chat, - * LangChain DALL-E, Fal, Veo3, HeyGen, ElevenLabs, ...) into a clean + * LangChain DALL-E, Fal, Seedance, HeyGen, ElevenLabs, ...) into a clean * HttpException so a provider rejection (most notably an OpenAI safety * violation) returns a proper response instead of crashing the backend. * diff --git a/libraries/nestjs-libraries/src/videos/veo3/veo3.ts b/libraries/nestjs-libraries/src/videos/seedance/seedance.ts similarity index 81% rename from libraries/nestjs-libraries/src/videos/veo3/veo3.ts rename to libraries/nestjs-libraries/src/videos/seedance/seedance.ts index 3ec2ee7726..6f7493c4f5 100644 --- a/libraries/nestjs-libraries/src/videos/veo3/veo3.ts +++ b/libraries/nestjs-libraries/src/videos/seedance/seedance.ts @@ -14,7 +14,7 @@ class Image { @IsString() path: string; } -class Veo3Params { +class SeedanceParams { @IsString() prompt: string; @@ -26,21 +26,22 @@ class Veo3Params { } @Video({ - identifier: 'veo3', - title: 'Veo3 (Audio + Video)', + identifier: 'seedance', + title: 'Seedance 2.0 (Audio + Video)', description: 'Generate videos with the most advanced video model.', placement: 'text-to-image', - dto: Veo3Params, + dto: SeedanceParams, tools: [], trial: false, available: !!process.env.EVOLINK_API_KEY, }) -export class Veo3 extends VideoAbstract { - override dto = Veo3Params; +export class Seedance extends VideoAbstract { + override dto = SeedanceParams; async process( output: 'vertical' | 'horizontal', - customParams: Veo3Params + customParams: SeedanceParams ): Promise { + const imageUrls = customParams?.images?.map((p) => p.path) || []; const value = await ( await fetch('https://api.evolink.ai/v1/videos/generations', { headers: { @@ -50,11 +51,14 @@ export class Veo3 extends VideoAbstract { method: 'POST', signal: AbortSignal.timeout(30000), body: JSON.stringify({ - model: 'veo3.1-fast', + model: imageUrls.length + ? 'seedance-2.0-fast-reference-to-video' + : 'seedance-2.0-fast-text-to-video', prompt: customParams.prompt, - image_urls: customParams?.images?.map((p) => p.path) || [], + ...(imageUrls.length ? { image_urls: imageUrls } : {}), aspect_ratio: output === 'horizontal' ? '16:9' : '9:16', duration: 8, + quality: '720p', generate_audio: true, }), }) @@ -69,7 +73,7 @@ export class Veo3 extends VideoAbstract { ); } - console.log('veo3 taskId', taskId); + console.log('seedance taskId', taskId); let attempts = 0; const maxAttempts = 180; // ~30 minutes at 10s interval while (true) { diff --git a/libraries/nestjs-libraries/src/videos/video.module.ts b/libraries/nestjs-libraries/src/videos/video.module.ts index cced4cc572..cdc8c71aea 100644 --- a/libraries/nestjs-libraries/src/videos/video.module.ts +++ b/libraries/nestjs-libraries/src/videos/video.module.ts @@ -1,11 +1,11 @@ import { Global, Module } from '@nestjs/common'; import { ImagesSlides } from '@gitroom/nestjs-libraries/videos/images-slides/images.slides'; import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager'; -import { Veo3 } from '@gitroom/nestjs-libraries/videos/veo3/veo3'; +import { Seedance } from '@gitroom/nestjs-libraries/videos/seedance/seedance'; @Global() @Module({ - providers: [ImagesSlides, Veo3, VideoManager], + providers: [ImagesSlides, Seedance, VideoManager], get exports() { return this.providers; }, From 2d9adb840acb870a700bde22e4049bba7b0ce7ec Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 30 Aug 2026 18:38:26 +0700 Subject: [PATCH 09/20] feat: stripe changes --- apps/backend/src/api/api.module.ts | 2 ++ .../src/api/routes/stripe.controller.ts | 24 +++++++++++++++++++ .../src/services/stripe.service.ts | 9 +++---- 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/api/routes/stripe.controller.ts diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 54787f590e..ba1e687e71 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -50,6 +50,7 @@ import { AppleProvider } from '@gitroom/backend/services/auth/providers/apple.pr import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider'; import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider'; import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider'; +import { StripeController } from '@gitroom/backend/api/routes/stripe.controller'; const authenticatedController = [ UsersController, @@ -79,6 +80,7 @@ const authenticatedController = [ : [ RootController, PaymentController, + StripeController, AuthController, PublicController, MonitorController, diff --git a/apps/backend/src/api/routes/stripe.controller.ts b/apps/backend/src/api/routes/stripe.controller.ts new file mode 100644 index 0000000000..793b1d00a9 --- /dev/null +++ b/apps/backend/src/api/routes/stripe.controller.ts @@ -0,0 +1,24 @@ +import { + Controller, + Post, + RawBodyRequest, + Req, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PaymentService } from '@gitroom/nestjs-libraries/services/payment/payment.service'; + +@ApiTags('Stripe') +@Controller('/stripe') +export class StripeController { + constructor(private readonly _paymentService: PaymentService) {} + + @Post('/') + async stripe(@Req() req: RawBodyRequest) { + return await this._paymentService.webhook( + 'stripe', + req.rawBody, + // @ts-ignore + req.headers + ); + } +} diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 1858277e9a..e465ce3d3a 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -330,8 +330,6 @@ export class StripeService extends PaymentProviderAbstract { }, })); - const proration_date = Math.floor(Date.now() / 1000); - const currentUserSubscription = { data: ( await stripe.subscriptions.list({ @@ -346,8 +344,7 @@ export class StripeService extends PaymentProviderAbstract { customer, subscription: currentUserSubscription?.data?.[0]?.id, subscription_details: { - proration_behavior: 'create_prorations', - billing_cycle_anchor: 'now', + proration_behavior: 'always_invoice', items: [ { id: currentUserSubscription?.data?.[0]?.items?.data?.[0]?.id, @@ -355,14 +352,14 @@ export class StripeService extends PaymentProviderAbstract { quantity: 1, }, ], - proration_date: proration_date, }, }); return { - price: price?.amount_remaining ? price?.amount_remaining / 100 : 0, + price: price?.amount_due ? price?.amount_due / 100 : 0, }; } catch (err) { + console.error('Error calculating proration:', err); return { price: 0 }; } } From 60ffa4df2277130cdbf255e81aa13f0e8f31fd1e Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 30 Aug 2026 18:56:07 +0700 Subject: [PATCH 10/20] feat: fix X --- .../integrations/social/linkedin.page.provider.ts | 13 ++++++------- .../src/integrations/social/x.provider.ts | 6 ++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts index 03de1f9c71..36bbdce5b5 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -446,13 +446,12 @@ export class LinkedinPageProvider postId: string, date: number ): Promise { - const endDate = dayjs().unix() * 1000; - const startDate = dayjs().subtract(date, 'days').unix() * 1000; - - // Fetch share statistics for the specific post + // Fetch lifetime share statistics for the specific post. + // LinkedIn does not support time-bound statistics for specific share queries, + // so no timeIntervals is sent and elements come back without a timeRange. const shareStatsUrl = `https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent( `urn:li:organization:${integrationId}` - )}&shares=List(${encodeURIComponent(postId)})&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`; + )}&shares=List(${encodeURIComponent(postId)})`; const { elements: shareElements }: { elements: PostShareStatElement[] } = await ( @@ -488,7 +487,7 @@ export class LinkedinPageProvider const analytics = (shareElements || []).reduce( (all, current) => { if (typeof current?.totalShareStatistics !== 'undefined') { - const dateStr = dayjs(current.timeRange.start).format('YYYY-MM-DD'); + const dateStr = dayjs(current.timeRange?.start).format('YYYY-MM-DD'); all['Impressions'].push({ total: current.totalShareStatistics.impressionCount || 0, @@ -927,7 +926,7 @@ export interface PostShareStatElement { impressionCount: number; commentCount: number; }; - timeRange: TimeRange; + timeRange?: TimeRange; } export interface SocialActionsResponse { diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 080262d864..51512fbdf2 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -1405,9 +1405,11 @@ export class XProvider extends SocialAbstract implements SocialProvider { ...(token ? { pagination_token: token } : {}), }); + const list = tweets.data.data || []; + return [ - ...tweets.data.data, - ...(tweets.data.data.length === 100 + ...list, + ...(list.length === 100 && tweets.meta.next_token ? await this.loadAllTweets( client, id, From e1baea0875cacefb9ddc33e6f8c704a3ac2e9028 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 10:48:45 +0700 Subject: [PATCH 11/20] feat: dynamic path --- .../nestjs-libraries/src/chat/start.mcp.ts | 113 +++++++----------- 1 file changed, 40 insertions(+), 73 deletions(-) diff --git a/libraries/nestjs-libraries/src/chat/start.mcp.ts b/libraries/nestjs-libraries/src/chat/start.mcp.ts index 0099162c2b..34bb1235d2 100644 --- a/libraries/nestjs-libraries/src/chat/start.mcp.ts +++ b/libraries/nestjs-libraries/src/chat/start.mcp.ts @@ -81,39 +81,38 @@ export const startMcp = async (app: INestApplication) => { }); const oauthResource = new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL!).toString(); - const oauthMiddleware = createOAuthMiddleware({ - oauth: { - resource: oauthResource, - authorizationServers: [oauthResource], - scopesSupported: oauthScopes, - validateToken: async (token: string) => { - const org = await resolveAuth(token); - if (!org) { - return { valid: false, error: 'invalid_token', errorDescription: 'Invalid API Key or OAuth token' }; - } - return { valid: true, subject: token }; - }, - }, - mcpPath: '/mcp-oauth', - }); - // Same authorization server as /mcp-oauth, only the protected resource differs - const claudeOauthResource = new URL('/mcp-oauth-claude', process.env.NEXT_PUBLIC_BACKEND_URL!).toString(); - const claudeOauthMiddleware = createOAuthMiddleware({ - oauth: { - resource: claudeOauthResource, - authorizationServers: [oauthResource], - scopesSupported: oauthScopes, - validateToken: async (token: string) => { - const org = await resolveAuth(token); - if (!org) { - return { valid: false, error: 'invalid_token', errorDescription: 'Invalid API Key or OAuth token' }; - } - return { valid: true, subject: token }; + // Every OAuth-protected MCP path is its own RFC 9728 protected resource, but + // they all share the /mcp-oauth authorization server (the token endpoint + // ignores the RFC 8707 resource param, so one AS covers all of them) + const createResourceMiddleware = (mcpPath: string) => + createOAuthMiddleware({ + oauth: { + resource: new URL(mcpPath, process.env.NEXT_PUBLIC_BACKEND_URL!).toString(), + authorizationServers: [oauthResource], + scopesSupported: oauthScopes, + validateToken: async (token: string) => { + const org = await resolveAuth(token); + if (!org) { + return { valid: false, error: 'invalid_token', errorDescription: 'Invalid API Key or OAuth token' }; + } + return { valid: true, subject: token }; + }, }, - }, - mcpPath: '/mcp-oauth-claude', - }); + mcpPath, + }); + + const oauthResources: Record< + string, + { middleware: ReturnType; mcpServer: MCPServer } + > = { + // ChatGPT app submission + '/mcp-oauth': { middleware: createResourceMiddleware('/mcp-oauth'), mcpServer: oauthServer }, + // Claude connector directory submission + '/mcp-oauth-claude': { middleware: createResourceMiddleware('/mcp-oauth-claude'), mcpServer: claudeOauthServer }, + // Clients that register themselves through DCR (/oauth/register) + '/mcp-oauth-dynamic': { middleware: createResourceMiddleware('/mcp-oauth-dynamic'), mcpServer: claudeOauthServer }, + }; if (process.env.OPENAI_APP_CHALLANGE) { app.use('/.well-known/openai-apps-challenge', (req: Request, res: Response) => { @@ -123,17 +122,17 @@ export const startMcp = async (app: INestApplication) => { } app.use('/.well-known/oauth-protected-resource', async (req: Request, res: Response, next: () => void) => { - // Only the /mcp-oauth and /mcp-oauth-claude resources are OAuth-protected. + // Only the paths in oauthResources are OAuth-protected. // Answering discovery on any other path (including the root, which clients // fall back to) makes them demand OAuth for /mcp/:id too - if (req.path !== '/mcp-oauth' && req.path !== '/mcp-oauth-claude') { + const resource = oauthResources[req.path]; + if (!resource) { next(); return; } const url = new URL('/.well-known/oauth-protected-resource', process.env.NEXT_PUBLIC_BACKEND_URL); - const middleware = req.path === '/mcp-oauth-claude' ? claudeOauthMiddleware : oauthMiddleware; - await middleware(req, res, url); + await resource.middleware(req, res, url); }); app.use('/.well-known/oauth-authorization-server', async (req: Request, res: Response, next: () => void) => { @@ -202,50 +201,18 @@ export const startMcp = async (app: INestApplication) => { }); }); - app.use('/mcp-oauth', async (req: Request, res: Response, next: () => void) => { - // Skip if this is the /mcp/:id route - if (req.path !== '/' && req.path !== '') { - next(); - return; - } - - const url = new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL); - - const result = await oauthMiddleware(req, res, url); - if (!result.proceed) return; - - const token = result.tokenValidation?.subject; - const auth = await resolveAuth(token!); - if (!auth) { - res.status(401).json({ error: 'invalid_token', error_description: 'Could not resolve organization' }); - return; - } - - fixAcceptHeader(req); - await runWithContext({ requestId: token!, auth }, async () => { - await oauthServer.startHTTP({ - url: url, - httpPath: url.pathname, - options: { - serverless: true, - enableJsonResponse: true, - }, - req, - res, - }); - }); - }); - - app.use('/mcp-oauth-claude', async (req: Request, res: Response, next: () => void) => { + app.use(Object.keys(oauthResources), async (req: Request, res: Response, next: () => void) => { // Skip if this is the /mcp/:id route if (req.path !== '/' && req.path !== '') { next(); return; } - const url = new URL('/mcp-oauth-claude', process.env.NEXT_PUBLIC_BACKEND_URL); + // baseUrl is the mount path that matched, e.g. /mcp-oauth-claude + const { middleware, mcpServer } = oauthResources[req.baseUrl]; + const url = new URL(req.baseUrl, process.env.NEXT_PUBLIC_BACKEND_URL); - const result = await claudeOauthMiddleware(req, res, url); + const result = await middleware(req, res, url); if (!result.proceed) return; const token = result.tokenValidation?.subject; @@ -257,7 +224,7 @@ export const startMcp = async (app: INestApplication) => { fixAcceptHeader(req); await runWithContext({ requestId: token!, auth }, async () => { - await claudeOauthServer.startHTTP({ + await mcpServer.startHTTP({ url: url, httpPath: url.pathname, options: { From 0d8d0f228564f77193bd6e055b2751538675d002 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 11:00:55 +0700 Subject: [PATCH 12/20] feat: localhost for mcp --- .env.example | 8 ++- .../database/prisma/oauth/oauth.service.ts | 67 +++++++++++++------ .../src/dtos/oauth/register-client.dto.ts | 8 +-- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index 4211d67cac..f8c7832479 100644 --- a/.env.example +++ b/.env.example @@ -106,9 +106,11 @@ OPENAI_OAUTH_CLIENT_ID="" # MCP OAuth Dynamic Client Registration (RFC 7591) redirect-domain allowlist. # When set (comma separated), POST /oauth/register only accepts redirect_uris # whose host matches a listed domain or one of its subdomains; unset or empty -# means any client can self-register. Example locks DCR to the claude.ai -# connector (note: also blocks localhost callbacks, e.g. Claude Code - -# those clients can use the API-key /mcp/:id endpoints instead). +# means any client can self-register. Only https callbacks are checked: +# loopback (http://localhost:8787/callback - Grok, Claude Code) and +# private-use scheme callbacks (cursor://...) stay on the user's machine +# and are always accepted. Example locks web callbacks to the claude.ai +# connector; add cursor.com for Cursor. # DCR_VERIFIED_DOMAINS="claude.ai,claude.com" NEXT_PUBLIC_DISCORD_SUPPORT="" NEXT_PUBLIC_POLOTNO="" diff --git a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts index ea18333f72..5c4e20b981 100644 --- a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts @@ -20,6 +20,17 @@ const oauthScope = (clientId: string) => 'mcp:write', ].join(' '); +// Schemes a browser would execute instead of navigating away from the +// consent screen, so they can never be a redirect_uri +const browserSchemes = [ + 'javascript:', + 'data:', + 'blob:', + 'file:', + 'vbscript:', + 'about:', +]; + @Injectable() export class OAuthService { constructor(private _oauthRepository: OAuthRepository) {} @@ -99,6 +110,7 @@ export class OAuthService { async registerDynamicClient(dto: RegisterClientDto) { const redirectUris = dto.redirect_uris.map((uri) => uri.trim()); + const verifiedDomains = this.verifiedDomainList(); for (const uri of redirectUris) { let parsed: URL; try { @@ -109,31 +121,48 @@ export class OAuthService { HttpStatus.BAD_REQUEST ); } - const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname); - if (parsed.protocol !== 'https:' && !isLoopback) { + + // The consent screen navigates to the redirect_uri, so schemes the + // browser would execute in our origin can never be a callback + if (browserSchemes.includes(parsed.protocol)) { throw new HttpException( - { error: 'invalid_redirect_uri', error_description: 'redirect_uris must use https (http is allowed for loopback only)' }, + { error: 'invalid_redirect_uri', error_description: `redirect_uri scheme "${parsed.protocol}" is not allowed` }, HttpStatus.BAD_REQUEST ); } - } - const verifiedDomains = this.verifiedDomainList(); - if (verifiedDomains.length) { - for (const uri of redirectUris) { - const host = new URL(uri).hostname.toLowerCase(); - const isVerified = verifiedDomains.some( - (domain) => host === domain || host.endsWith('.' + domain) + const isLoopback = + parsed.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname); + // Private-use schemes (RFC 8252 §7.1), e.g. + // cursor://anysphere.cursor-mcp/oauth/callback + const isPrivateScheme = !['http:', 'https:'].includes(parsed.protocol); + if (parsed.protocol === 'http:' && !isLoopback) { + throw new HttpException( + { error: 'invalid_redirect_uri', error_description: 'redirect_uris must use https or a private-use scheme (http is allowed for loopback only)' }, + HttpStatus.BAD_REQUEST + ); + } + + // Loopback and private-use callbacks never leave the user's machine + // (native clients like Cursor, Grok and Claude Code), so only web + // callbacks are held to the verified domain list + if (isLoopback || isPrivateScheme || !verifiedDomains.length) { + continue; + } + + const host = parsed.hostname.toLowerCase(); + const isVerified = verifiedDomains.some( + (domain) => host === domain || host.endsWith('.' + domain) + ); + if (!isVerified) { + throw new HttpException( + { + error: 'invalid_redirect_uri', + error_description: `redirect_uri host "${host}" is not a verified domain`, + }, + HttpStatus.BAD_REQUEST ); - if (!isVerified) { - throw new HttpException( - { - error: 'invalid_redirect_uri', - error_description: `redirect_uri host "${host}" is not a verified domain`, - }, - HttpStatus.BAD_REQUEST - ); - } } } diff --git a/libraries/nestjs-libraries/src/dtos/oauth/register-client.dto.ts b/libraries/nestjs-libraries/src/dtos/oauth/register-client.dto.ts index 6ee2defe2e..fa41de1a2a 100644 --- a/libraries/nestjs-libraries/src/dtos/oauth/register-client.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/oauth/register-client.dto.ts @@ -4,16 +4,14 @@ import { IsDefined, IsOptional, IsString, - IsUrl, } from 'class-validator'; export class RegisterClientDto { + // https, http loopback or a private-use scheme (cursor://...), + // validated in OAuthService.registerDynamicClient @IsArray() @ArrayNotEmpty() - @IsUrl( - { require_tld: false, require_protocol: true, protocols: ['http', 'https'] }, - { each: true } - ) + @IsString({ each: true }) @IsDefined() redirect_uris: string[]; From 94778dbdc335c541d9ebf29e5fbb87d92570e0bc Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 11:20:32 +0700 Subject: [PATCH 13/20] feat: remove filter --- libraries/nestjs-libraries/src/chat/start.mcp.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/chat/start.mcp.ts b/libraries/nestjs-libraries/src/chat/start.mcp.ts index 34bb1235d2..70881b7d50 100644 --- a/libraries/nestjs-libraries/src/chat/start.mcp.ts +++ b/libraries/nestjs-libraries/src/chat/start.mcp.ts @@ -110,8 +110,9 @@ export const startMcp = async (app: INestApplication) => { '/mcp-oauth': { middleware: createResourceMiddleware('/mcp-oauth'), mcpServer: oauthServer }, // Claude connector directory submission '/mcp-oauth-claude': { middleware: createResourceMiddleware('/mcp-oauth-claude'), mcpServer: claudeOauthServer }, - // Clients that register themselves through DCR (/oauth/register) - '/mcp-oauth-dynamic': { middleware: createResourceMiddleware('/mcp-oauth-dynamic'), mcpServer: claudeOauthServer }, + // Clients that register themselves through DCR (/oauth/register) - not + // directory-reviewed, so they get the full toolset (media generation included) + '/mcp-oauth-dynamic': { middleware: createResourceMiddleware('/mcp-oauth-dynamic'), mcpServer: oauthServer }, }; if (process.env.OPENAI_APP_CHALLANGE) { From 50b171e6870509d4301007ec708611eeebb11bbc Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 11:31:43 +0700 Subject: [PATCH 14/20] feat: mcp changes --- chatgpt-app-submission.json | 54 +++++++++---------- .../src/chat/tools/generate.image.tool.ts | 50 +++++++++++++---- .../src/chat/tools/generate.video.tool.ts | 14 +++-- 3 files changed, 76 insertions(+), 42 deletions(-) diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index f95c38ce63..d85c0a1080 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -155,43 +155,43 @@ }, "test_cases": [ { - "description": "List the user's Postiz groups and connected social channels.", - "user_prompt": "Show me my Postiz customer groups, then list the connected channels for the Acme group.", + "description": "List the user's connected Postiz channels.", + "user_prompt": "Which social channels do I have connected in Postiz? Show the platform and channel name for each one.", "file_attachment_urls": null, - "tools_triggered": "groupList, integrationList", - "expected_output": "Returns available groups and the connected channels for the selected group with enough detail to choose where to post.", + "tools_triggered": "integrationList", + "expected_output": "Calls integrationList and lists every connected channel with its platform (for example LinkedIn, X) and channel name, so the user can pick where to post. No post is created.", "expected_output_url": null }, { - "description": "Upload a remote media URL and schedule a social post.", - "user_prompt": "Use this public image URL for a LinkedIn post tomorrow at 9:00 UTC: https://example.com/product.png. I confirm the copy and time.", + "description": "Upload two remote images and create a LinkedIn draft (nothing is published).", + "user_prompt": "Create a LinkedIn draft in Postiz for next Monday at 10:00 UTC with the text \"Testing the Postiz ChatGPT app\" and attach these two images: https://platform.postiz.com/auth/bg-login.png and https://platform.postiz.com/auth/login-box.png. Keep it as a draft, do not schedule or publish it.", "file_attachment_urls": null, - "tools_triggered": "integrationList, integrationSchema, uploadFromUrlTool, integrationSchedulePostTool", - "expected_output": "Uploads the media, validates LinkedIn requirements, and creates a scheduled Postiz post or returns clear validation errors.", + "tools_triggered": "integrationList, uploadFromUrlTool, integrationSchedulePostTool", + "expected_output": "Finds the LinkedIn channel, calls uploadFromUrlTool once per image (each returns a hosted media path), then calls integrationSchedulePostTool with type \"draft\" and both hosted attachments. Returns the new post id and confirms it was saved as a draft. Nothing is published to LinkedIn. Running the case again creates another draft and succeeds the same way.", "expected_output_url": null }, { - "description": "Resolve provider-specific settings before scheduling.", - "user_prompt": "Schedule a Reddit post for next Monday and use the correct subreddit flair for /r/startups. I confirm the post details.", + "description": "Explain platform rules and available settings for a channel.", + "user_prompt": "What are the posting rules and the available settings for my LinkedIn channel in Postiz?", "file_attachment_urls": null, - "tools_triggered": "integrationList, integrationSchema, triggerTool, integrationSchedulePostTool", - "expected_output": "Retrieves Reddit requirements and flair options, then creates the scheduled post with the selected settings or reports validation errors.", + "tools_triggered": "integrationList, integrationSchema", + "expected_output": "Resolves the LinkedIn channel id, calls integrationSchema and summarises the platform rules (character limit, attachment rules such as carousels needing at least two images) and the settings keys (post_as_images_carousel, carousel_name). Read-only, no post is created or changed.", "expected_output_url": null }, { - "description": "Find an existing scheduled post and update provider settings.", - "user_prompt": "Find my scheduled posts for next week and change the selected post's provider setting to the correct Reddit flair.", + "description": "Find an existing draft and update only its provider settings.", + "user_prompt": "Find my Postiz LinkedIn draft with the text \"Testing the Postiz ChatGPT app\" and turn it into an image carousel named \"Product launch\". Don't change the text or the date.", "file_attachment_urls": null, - "tools_triggered": "postsListTool, integrationSchema, triggerTool, postSettingsTool", - "expected_output": "Returns matching posts, resolves valid settings, and updates only the requested provider settings on a draft or scheduled post.", + "tools_triggered": "postsListTool, integrationSchema, postSettingsTool", + "expected_output": "Calls postsListTool for the coming weeks, picks the matching LinkedIn draft (created by the previous test case), then calls postSettingsTool with post_as_images_carousel = true and carousel_name = \"Product launch\". Returns the post id and confirms the settings were updated while the content and date stayed the same. If no matching draft exists, it says so and does not create one.", "expected_output_url": null }, { - "description": "Generate media assets for a future social post.", - "user_prompt": "Generate an image for a product launch post, then create a vertical image-text-slides video with a suitable voice.", + "description": "Generate an image and save it to the media library.", + "user_prompt": "Generate an image of a sunrise over mountains for a product launch post and save it to my Postiz media library. Don't create a post.", "file_attachment_urls": null, - "tools_triggered": "generateImageTool, generateVideoOptions, videoFunctionTool, generateVideoTool", - "expected_output": "Creates media assets in the Postiz media library and returns hosted media URLs or actionable generation errors.", + "tools_triggered": "generateImageTool", + "expected_output": "Calls generateImageTool with the prompt and returns the hosted image URL saved in the media library. If the account has no AI image credits, the tool returns a clear error message that is relayed to the user. No post is created.", "expected_output_url": null } ], @@ -201,23 +201,23 @@ "user_prompt": "Move my team meeting from tomorrow to Friday afternoon.", "file_attachment_urls": null, "tools_triggered": null, - "expected_output": "The app should not be invoked because calendar event management is outside Postiz social scheduling workflows.", + "expected_output": "No Postiz tool is called. ChatGPT answers directly, because calendar events are not social media posts.", "expected_output_url": null }, { - "description": "Do not trigger for unsupported deletion requests.", - "user_prompt": "Delete the scheduled post I made yesterday from Postiz.", + "description": "Do not trigger when the user only wants copy written, not posted.", + "user_prompt": "Write me three short tweet ideas about productivity. Just write them here, don't post or schedule anything.", "file_attachment_urls": null, "tools_triggered": null, - "expected_output": "The app should explain that deletion is unsupported through these tools and direct the user to delete the post in Postiz.", + "expected_output": "No Postiz tool is called. ChatGPT writes the three ideas in the conversation, because the user explicitly asked not to post or schedule.", "expected_output_url": null }, { - "description": "Do not trigger for private direct-message workflows.", - "user_prompt": "Send a private Slack DM to Sarah saying the campaign draft is ready.", + "description": "Do not trigger for email.", + "user_prompt": "Send an email to my team letting them know the campaign launch moved to next week.", "file_attachment_urls": null, "tools_triggered": null, - "expected_output": "The app should not be invoked because private direct messaging is outside the supported Postiz publishing workflows.", + "expected_output": "No Postiz tool is called. ChatGPT answers directly (or uses an email app if one is available), because email is not a social media channel Postiz publishes to.", "expected_output_url": null } ] diff --git a/libraries/nestjs-libraries/src/chat/tools/generate.image.tool.ts b/libraries/nestjs-libraries/src/chat/tools/generate.image.tool.ts index 0dfb63ed14..cb935b79cf 100644 --- a/libraries/nestjs-libraries/src/chat/tools/generate.image.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/generate.image.tool.ts @@ -5,12 +5,16 @@ import { Injectable } from '@nestjs/common'; import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; @Injectable() export class GenerateImageTool implements AgentToolInterface { private storage = UploadFactory.createStorage(); - constructor(private _mediaService: MediaService) {} + constructor( + private _mediaService: MediaService, + private _subscriptionService: SubscriptionService + ) {} name = 'generateImageTool'; run() { @@ -32,23 +36,47 @@ export class GenerateImageTool implements AgentToolInterface { inputSchema: z.object({ prompt: z.string(), }), + // Mastra validates the return against this schema, so it must also + // allow the graceful { error } shape (same as uploadFromUrlTool) outputSchema: z.object({ - id: z.string(), - path: z.string(), + id: z.string().optional(), + path: z.string().optional(), + error: z.string().optional(), }), execute: async (inputData, context) => { checkAuth(inputData, context); const org = JSON.parse((context?.requestContext as any)?.get('organization') as string); - const image = await this._mediaService.generateImage( - inputData.prompt, - org - ); + try { + // Same credit gate as the dashboard's /media/generate-image route - + // only enforced when billing is enabled (cloud), self-hosted is free + const total = await this._subscriptionService.checkCredits(org); + if (process.env.STRIPE_PUBLISHABLE_KEY && total.credits <= 0) { + return { + error: 'No AI image credits are available on this account.', + }; + } - const file = await this.storage.uploadSimple( - 'data:image/png;base64,' + image - ); + const image = await this._mediaService.generateImage( + inputData.prompt, + org + ); - return this._mediaService.saveFile(org.id, file.split('/').pop(), file); + const file = await this.storage.uploadSimple( + 'data:image/png;base64,' + image + ); + + return await this._mediaService.saveFile( + org.id, + file.split('/').pop(), + file + ); + } catch (err) { + return { + error: `Image generation failed: ${ + err instanceof Error ? err.message : String(err) + }. The user's image credit was not used.`, + }; + } }, }); } diff --git a/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts b/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts index 655821a604..e8b1e969f6 100644 --- a/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts @@ -1,7 +1,7 @@ import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface'; import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { IntegrationManager, socialIntegrationList, @@ -78,10 +78,16 @@ export class GenerateVideoTool implements AgentToolInterface { url: value.path, }; } catch (err) { + // SubscriptionException (402) carries { section, action } and its + // message is just "Subscription Exception", so translate it + const message = + err instanceof HttpException && err.getStatus() === 402 + ? 'No AI video credits are available on this account.' + : err instanceof Error + ? err.message + : String(err); return { - error: `Video generation failed: ${ - err instanceof Error ? err.message : String(err) - }. The user's video credit was not used.`, + error: `Video generation failed: ${message}. The user's video credit was not used.`, }; } }, From c98ea0465022c116e9b82d000369183ab757da7d Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 12:03:18 +0700 Subject: [PATCH 15/20] feat: fix linkedin dto --- .../src/integrations/social/linkedin.provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index 958605a50f..a4b6a9494f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -73,6 +73,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { override maxConcurrentJob = 2; refreshWait = true; editor = 'normal' as const; + dto = LinkedinDto; maxLength() { return 3000; } From 883327663d36864db4fa4d40a79b286d434ca789 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 12:32:16 +0700 Subject: [PATCH 16/20] video status tool --- .../src/activities/video.activity.ts | 24 +++ apps/orchestrator/src/app.module.ts | 4 + .../src/workflows/generate.video.workflow.ts | 22 +++ apps/orchestrator/src/workflows/index.ts | 1 + chatgpt-app-submission.json | 14 +- .../nestjs-libraries/src/chat/start.mcp.ts | 1 + .../src/chat/tools/generate.video.tool.ts | 8 +- .../src/chat/tools/tool.list.ts | 2 + .../src/chat/tools/video.status.tool.ts | 65 +++++++++ .../database/prisma/media/media.service.ts | 137 ++++++++++++++---- 10 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 apps/orchestrator/src/activities/video.activity.ts create mode 100644 apps/orchestrator/src/workflows/generate.video.workflow.ts create mode 100644 libraries/nestjs-libraries/src/chat/tools/video.status.tool.ts diff --git a/apps/orchestrator/src/activities/video.activity.ts b/apps/orchestrator/src/activities/video.activity.ts new file mode 100644 index 0000000000..8964f8ce9f --- /dev/null +++ b/apps/orchestrator/src/activities/video.activity.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { Activity, ActivityMethod } from 'nestjs-temporal-core'; +import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; +import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; +import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto'; + +@Injectable() +@Activity() +export class VideoActivity { + constructor( + private _mediaService: MediaService, + private _organizationService: OrganizationService + ) {} + + @ActivityMethod() + async generateVideo(organizationId: string, body: VideoDto) { + const org = await this._organizationService.getOrgById(organizationId); + if (!org) { + throw new Error('Organization not found'); + } + + return this._mediaService.generateVideo(org, body); + } +} diff --git a/apps/orchestrator/src/app.module.ts b/apps/orchestrator/src/app.module.ts index d7b7f8f1e8..e2707b93ce 100644 --- a/apps/orchestrator/src/app.module.ts +++ b/apps/orchestrator/src/app.module.ts @@ -5,6 +5,8 @@ import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/databa import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service'; import { EmailActivity } from '@gitroom/orchestrator/activities/email.activity'; import { IntegrationsActivity } from '@gitroom/orchestrator/activities/integrations.activity'; +import { VideoActivity } from '@gitroom/orchestrator/activities/video.activity'; +import { VideoModule } from '@gitroom/nestjs-libraries/videos/video.module'; import { HealthController } from '@gitroom/orchestrator/health.controller'; const activities = [ @@ -12,10 +14,12 @@ const activities = [ AutopostService, EmailActivity, IntegrationsActivity, + VideoActivity, ]; @Module({ imports: [ DatabaseModule, + VideoModule, getTemporalModule(true, require.resolve('./workflows'), activities), ], controllers: [HealthController], diff --git a/apps/orchestrator/src/workflows/generate.video.workflow.ts b/apps/orchestrator/src/workflows/generate.video.workflow.ts new file mode 100644 index 0000000000..51cb9a5734 --- /dev/null +++ b/apps/orchestrator/src/workflows/generate.video.workflow.ts @@ -0,0 +1,22 @@ +import { proxyActivities } from '@temporalio/workflow'; +import { VideoActivity } from '@gitroom/orchestrator/activities/video.activity'; +import type { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto'; + +// Generating a video consumes a credit, so a failed attempt is never retried +const { generateVideo } = proxyActivities({ + startToCloseTimeout: '40 minute', + taskQueue: 'main', + retry: { + maximumAttempts: 1, + }, +}); + +export async function generateVideoWorkflow({ + organizationId, + body, +}: { + organizationId: string; + body: VideoDto; +}) { + return generateVideo(organizationId, body); +} diff --git a/apps/orchestrator/src/workflows/index.ts b/apps/orchestrator/src/workflows/index.ts index 7fa5962256..b94459d15a 100644 --- a/apps/orchestrator/src/workflows/index.ts +++ b/apps/orchestrator/src/workflows/index.ts @@ -14,3 +14,4 @@ export * from './missing.post.workflow'; export * from './send.email.workflow'; export * from './refresh.token.workflow'; export * from './streak.workflow'; +export * from './generate.video.workflow'; diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index d85c0a1080..82d417524f 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -123,11 +123,23 @@ "destructiveHint": false }, "justifications": { - "read_only_justification": "Generates a video, consumes the user's video generation credit, uploads the result, and saves it to the media library.", + "read_only_justification": "Starts a background video generation job that consumes the user's video generation credit, uploads the result, and saves it to the media library.", "open_world_justification": "Calls external generation and storage services to create media for the user's workspace.", "destructive_justification": "Does not delete or overwrite existing media, revoke access, or publish content." } }, + "videoStatusTool": { + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false + }, + "justifications": { + "read_only_justification": "Only reads the status of a video generation job the user started and returns the hosted video URL once it is done.", + "open_world_justification": "Does not write to public internet state or third-party systems.", + "destructive_justification": "Does not delete, overwrite, revoke access, or send content." + } + }, "generateImageTool": { "annotations": { "readOnlyHint": false, diff --git a/libraries/nestjs-libraries/src/chat/start.mcp.ts b/libraries/nestjs-libraries/src/chat/start.mcp.ts index 70881b7d50..8890799215 100644 --- a/libraries/nestjs-libraries/src/chat/start.mcp.ts +++ b/libraries/nestjs-libraries/src/chat/start.mcp.ts @@ -49,6 +49,7 @@ export const startMcp = async (app: INestApplication) => { const claudeHiddenTools = [ 'generateImageTool', 'generateVideoTool', + 'videoStatusTool', 'generateVideoOptions', 'videoFunctionTool', ]; diff --git a/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts b/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts index e8b1e969f6..eab6f19040 100644 --- a/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/generate.video.tool.ts @@ -38,6 +38,8 @@ export class GenerateVideoTool implements AgentToolInterface { in case the user specified a platform that requires attachment and attachment was not provided, ask if they want to generate a picture of a video. In many cases 'videoFunctionTool' will need to be called first, to get things like voice id + Generating takes a few minutes, so this only starts the generation and returns a jobId: + poll 'videoStatusTool' with it until the status is "completed" to get the video url. Here are the type of video that can be generated: ${this._videoManager .getAllVideos() @@ -55,14 +57,14 @@ export class GenerateVideoTool implements AgentToolInterface { ), }), outputSchema: z.object({ - url: z.string().optional(), + jobId: z.string().optional(), error: z.string().optional(), }), execute: async (inputData, context) => { checkAuth(inputData, context); const org = JSON.parse((context?.requestContext as any)?.get('organization') as string); try { - const value = await this._mediaService.generateVideo(org, { + const value = await this._mediaService.startGenerateVideo(org, { type: inputData.identifier, output: inputData.output, customParams: inputData.customParams.reduce( @@ -75,7 +77,7 @@ export class GenerateVideoTool implements AgentToolInterface { }); return { - url: value.path, + jobId: value.jobId, }; } catch (err) { // SubscriptionException (402) carries { section, action } and its diff --git a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts index ef386474f2..dc5fc98934 100644 --- a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts +++ b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts @@ -4,6 +4,7 @@ import { IntegrationSchedulePostTool } from './integration.schedule.post'; import { GenerateVideoOptionsTool } from '@gitroom/nestjs-libraries/chat/tools/generate.video.options.tool'; import { VideoFunctionTool } from '@gitroom/nestjs-libraries/chat/tools/video.function.tool'; import { GenerateVideoTool } from '@gitroom/nestjs-libraries/chat/tools/generate.video.tool'; +import { VideoStatusTool } from '@gitroom/nestjs-libraries/chat/tools/video.status.tool'; import { GenerateImageTool } from '@gitroom/nestjs-libraries/chat/tools/generate.image.tool'; import { IntegrationListTool } from '@gitroom/nestjs-libraries/chat/tools/integration.list.tool'; import { GroupListTool } from '@gitroom/nestjs-libraries/chat/tools/group.list.tool'; @@ -22,6 +23,7 @@ export const toolList = [ GenerateVideoOptionsTool, VideoFunctionTool, GenerateVideoTool, + VideoStatusTool, GenerateImageTool, UploadFromUrlTool, ]; diff --git a/libraries/nestjs-libraries/src/chat/tools/video.status.tool.ts b/libraries/nestjs-libraries/src/chat/tools/video.status.tool.ts new file mode 100644 index 0000000000..225103ce59 --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/video.status.tool.ts @@ -0,0 +1,65 @@ +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 { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; +import { z } from 'zod'; + +@Injectable() +export class VideoStatusTool implements AgentToolInterface { + constructor(private _mediaService: MediaService) {} + name = 'videoStatusTool'; + + run() { + return createTool({ + id: 'videoStatusTool', + mcp: { + annotations: { + title: 'Video Generation Status', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + description: `Check the status of a video generation started with 'generateVideoTool', using the jobId it returned. + Generating a video takes a few minutes: while the status is "pending", wait about 30 seconds and call again. + When the status is "completed" the result contains the hosted video url, which can be used as a post attachment. + When the status is "failed" the result contains the error message. + `, + inputSchema: z.object({ + jobId: z.string().describe('The jobId returned by generateVideoTool'), + }), + outputSchema: z.object({ + status: z.enum(['pending', 'completed', 'failed']).optional(), + id: z.string().optional(), + url: z.string().optional(), + error: z.string().optional(), + }), + execute: async (inputData, context) => { + checkAuth(inputData, context); + const org = JSON.parse( + (context?.requestContext as any)?.get('organization') as string + ); + try { + const job = await this._mediaService.getGenerateVideoStatus( + org, + inputData.jobId + ); + + return { + status: job.status, + id: job.id, + url: job.path, + error: job.error, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + error: `Video job lookup failed: ${message}`, + }; + } + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts index 1d1164a919..c56534b85e 100644 --- a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts @@ -13,6 +13,10 @@ import { Sections, SubscriptionException, } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; +import { TemporalService } from 'nestjs-temporal-core'; +import { TypedSearchAttributes } from '@temporalio/common'; +import { organizationId } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; @Injectable() export class MediaService { @@ -22,7 +26,8 @@ export class MediaService { private _mediaRepository: MediaRepository, private _openAi: OpenaiService, private _subscriptionService: SubscriptionService, - private _videoManager: VideoManager + private _videoManager: VideoManager, + private _temporalService: TemporalService ) {} async deleteMedia(org: string, id: string) { @@ -86,35 +91,35 @@ export class MediaService { return true; } - async generateVideo(org: Organization, body: VideoDto) { - try { - const totalCredits = await this._subscriptionService.checkCredits( - org, - 'ai_videos' - ); + private async validateVideoRequest(org: Organization, body: VideoDto) { + const totalCredits = await this._subscriptionService.checkCredits( + org, + 'ai_videos' + ); + + if (totalCredits.credits <= 0) { + throw new SubscriptionException({ + action: AuthorizationActions.Create, + section: Sections.VIDEOS_PER_MONTH, + }); + } - if (totalCredits.credits <= 0) { - throw new SubscriptionException({ - action: AuthorizationActions.Create, - section: Sections.VIDEOS_PER_MONTH, - }); - } + const video = this._videoManager.getVideoByName(body.type); + if (!video) { + throw new Error(`Video type ${body.type} not found`); + } - const video = this._videoManager.getVideoByName(body.type); - if (!video) { - throw new Error(`Video type ${body.type} not found`); - } + if (!video.trial && org.isTrailing) { + throw new HttpException('This video is not available in trial mode', 406); + } - if (!video.trial && org.isTrailing) { - throw new HttpException( - 'This video is not available in trial mode', - 406 - ); - } + await video.instance.processAndValidate(body.customParams); + return video; + } - console.log(body.customParams); - await video.instance.processAndValidate(body.customParams); - console.log('no err'); + async generateVideo(org: Organization, body: VideoDto) { + try { + const video = await this.validateVideoRequest(org, body); return await this._subscriptionService.useCredit( org, @@ -134,6 +139,86 @@ export class MediaService { } } + // Generating a video takes minutes, longer than an MCP request can stay open, + // so the generation runs in a workflow and the caller polls its status by job id + async startGenerateVideo(org: Organization, body: VideoDto) { + // validated here as well as in the workflow so bad input fails before a job exists + try { + await this.validateVideoRequest(org, body); + } catch (err) { + throw generationError(err); + } + + const client = this._temporalService.client.getRawClient(); + if (!client) { + throw new HttpException('Video generation is not available', 503); + } + + const jobId = `video_${org.id}_${makeId(10)}`; + await client.workflow.start('generateVideoWorkflow', { + workflowId: jobId, + taskQueue: 'main', + args: [ + { + organizationId: org.id, + body, + }, + ], + typedSearchAttributes: new TypedSearchAttributes([ + { + key: organizationId, + value: org.id, + }, + ]), + }); + + return { jobId }; + } + + async getGenerateVideoStatus( + org: Organization, + jobId: string + ): Promise<{ + status: 'pending' | 'completed' | 'failed'; + id?: string; + path?: string; + error?: string; + }> { + // the job id carries the organization, so one org can't poll another's job + if (!jobId.startsWith(`video_${org.id}_`)) { + throw new HttpException('Video job not found', 404); + } + + const handle = await this._temporalService.client.getWorkflowHandle(jobId); + let status: string; + try { + status = (await handle.describe()).status.name; + } catch (err) { + throw new HttpException('Video job not found', 404); + } + + if (status === 'RUNNING') { + return { status: 'pending' }; + } + + try { + const media = (await handle.result()) as Awaited< + ReturnType + >; + return { status: 'completed', id: media.id, path: media.path }; + } catch (err) { + // the workflow failure wraps the activity failure which wraps the actual error + let cause: any = err; + while (cause?.cause && cause.cause !== cause) { + cause = cause.cause; + } + return { + status: 'failed', + error: cause?.message || String(err), + }; + } + } + async videoFunction(identifier: string, functionName: string, body: any) { const video = this._videoManager.getVideoByName(identifier); if (!video) { From 47580d1d840434f84a09b8965975b131a3c4b7c0 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 12:50:19 +0700 Subject: [PATCH 17/20] feat: fix video --- .../src/activities/video.activity.ts | 17 ++++++++++++++--- .../organizations/organization.repository.ts | 18 ++++++++++++++++++ .../organizations/organization.service.ts | 4 ++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/orchestrator/src/activities/video.activity.ts b/apps/orchestrator/src/activities/video.activity.ts index 8964f8ce9f..d23d52428e 100644 --- a/apps/orchestrator/src/activities/video.activity.ts +++ b/apps/orchestrator/src/activities/video.activity.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { Activity, ActivityMethod } from 'nestjs-temporal-core'; import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; @@ -14,11 +14,22 @@ export class VideoActivity { @ActivityMethod() async generateVideo(organizationId: string, body: VideoDto) { - const org = await this._organizationService.getOrgById(organizationId); + // credits are checked against the subscription, so it has to be loaded with the org + const org = await this._organizationService.getOrgByIdWithSubscription( + organizationId + ); if (!org) { throw new Error('Organization not found'); } - return this._mediaService.generateVideo(org, body); + try { + return await this._mediaService.generateVideo(org, body); + } catch (err) { + // only the message survives the workflow failure, and a SubscriptionException's is not readable + if (err instanceof HttpException && err.getStatus() === 402) { + throw new Error('No AI video credits are available on this account.'); + } + throw err; + } } } 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 cb82925184..3b08182e95 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -269,6 +269,24 @@ export class OrganizationRepository { }); } + getOrgByIdWithSubscription(id: string) { + return this._organization.model.organization.findUnique({ + where: { + id, + }, + include: { + subscription: { + select: { + subscriptionTier: true, + totalChannels: true, + isLifetime: true, + createdAt: true, + }, + }, + }, + }); + } + getUsersByEmail(email: string) { return this._user.model.user.findMany({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts index 48233805a7..a5b62517c4 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts @@ -51,6 +51,10 @@ export class OrganizationService { return this._organizationRepository.getOrgById(id); } + getOrgByIdWithSubscription(id: string) { + return this._organizationRepository.getOrgByIdWithSubscription(id); + } + getOrgByApiKey(api: string) { return this._organizationRepository.getOrgByApiKey(api); } From 342f7003c447c30801a1e1f10f9ead9ccddca378 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Sep 2026 13:34:44 +0700 Subject: [PATCH 18/20] feat: failures --- .../src/openai/fal.service.ts | 45 ++++--- .../src/openai/generation.error.ts | 2 + .../src/videos/images-slides/images.slides.ts | 111 +++++++++++------- 3 files changed, 94 insertions(+), 64 deletions(-) diff --git a/libraries/nestjs-libraries/src/openai/fal.service.ts b/libraries/nestjs-libraries/src/openai/fal.service.ts index 0b6b17466a..04d5cb9ada 100644 --- a/libraries/nestjs-libraries/src/openai/fal.service.ts +++ b/libraries/nestjs-libraries/src/openai/fal.service.ts @@ -10,25 +10,32 @@ export class FalService { text: string, isVertical: boolean = false ): Promise { - const { images, video, ...all } = await ( - await limit(() => - fetch(`https://fal.run/fal-ai/${model}`, { - method: 'POST', - headers: { - Authorization: `Key ${process.env.FAL_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - prompt: text, - aspect_ratio: isVertical ? '9:16' : '16:9', - resolution: '720p', - num_images: 1, - output_format: 'jpeg', - expand_prompt: true, - }), - }) - ) - ).json(); + const response = await limit(() => + fetch(`https://fal.run/fal-ai/${model}`, { + method: 'POST', + headers: { + Authorization: `Key ${process.env.FAL_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: text, + aspect_ratio: isVertical ? '9:16' : '16:9', + resolution: '720p', + num_images: 1, + output_format: 'jpeg', + expand_prompt: true, + }), + signal: AbortSignal.timeout(300000), + }) + ); + + if (!response.ok) { + throw new Error( + `fal ${response.status}: ${(await response.text()).slice(0, 500)}` + ); + } + + const { images, video, ...all } = await response.json(); console.log(all, video, images); diff --git a/libraries/nestjs-libraries/src/openai/generation.error.ts b/libraries/nestjs-libraries/src/openai/generation.error.ts index 6493cc3e02..05d9b644fc 100644 --- a/libraries/nestjs-libraries/src/openai/generation.error.ts +++ b/libraries/nestjs-libraries/src/openai/generation.error.ts @@ -38,5 +38,7 @@ export function generationError(err: any): HttpException { // Not a recognized safety rejection (e.g. an invalid-parameter 400) — return // a generic message rather than mislabeling it as a content-safety issue. + // The real reason (a quota, a bad key, ...) is only useful to the operator, so log it + console.error('AI generation failed:', message); return new HttpException('AI generation failed, please try again later.', 500); } diff --git a/libraries/nestjs-libraries/src/videos/images-slides/images.slides.ts b/libraries/nestjs-libraries/src/videos/images-slides/images.slides.ts index 688d1efdac..8d05342e08 100644 --- a/libraries/nestjs-libraries/src/videos/images-slides/images.slides.ts +++ b/libraries/nestjs-libraries/src/videos/images-slides/images.slides.ts @@ -23,6 +23,17 @@ const transloadit = new Transloadit({ authSecret: process.env.TRANSLOADIT_SECRET || 'just empty text', }); +// ElevenLabs reports quota and permission problems as 401 with the reason in +// the body, as either { detail: { status, message } } or { detail: 'text' } +async function elevenLabsError(response: Response) { + const { detail } = await response.json().catch(() => ({ detail: undefined })); + const reason = + typeof detail === 'string' + ? detail + : detail?.message || detail?.status || response.statusText; + return `ElevenLabs ${response.status}: ${reason}`; +} + async function getAudioDuration(buffer: Buffer): Promise { const metadata = await parseBuffer(buffer, 'audio/mpeg'); return metadata.format.duration || 0; @@ -75,44 +86,48 @@ export class ImagesSlides extends VideoAbstract { customParams.prompt ); + // Plain async calls so a failed image or voice request rejects Promise.all + // and fails the job, instead of a promise that never settles and a job that + // hangs until the workflow times out const generated = await Promise.all( list.reduce((all, current) => { all.push( - new Promise(async (res) => { - res({ - len: 0, - url: await this._falService.generateImageFromText( - 'ideogram/v2', - current.imagePrompt, - output === 'vertical' - ), - }); - }) + (async () => ({ + len: 0, + url: await this._falService.generateImageFromText( + 'ideogram/v2', + current.imagePrompt, + output === 'vertical' + ), + }))() ); all.push( - new Promise(async (res) => { - const buffer = Buffer.from( - await ( - await limit(() => - fetch( - `https://api.elevenlabs.io/v1/text-to-speech/${customParams.voice}?output_format=mp3_44100_128`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'xi-api-key': process.env.ELEVENSLABS_API_KEY || '', - }, - body: JSON.stringify({ - text: current.voiceText, - model_id: 'eleven_multilingual_v2', - }), - } - ) - ) - ).arrayBuffer() + (async () => { + const response = await limit(() => + fetch( + `https://api.elevenlabs.io/v1/text-to-speech/${customParams.voice}?output_format=mp3_44100_128`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'xi-api-key': process.env.ELEVENSLABS_API_KEY || '', + }, + body: JSON.stringify({ + text: current.voiceText, + model_id: 'eleven_multilingual_v2', + }), + signal: AbortSignal.timeout(60000), + } + ) ); + if (!response.ok) { + throw new Error(await elevenLabsError(response)); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const { path } = await this.storage.uploadFile({ buffer, mimetype: 'audio/mp3', @@ -126,7 +141,7 @@ export class ImagesSlides extends VideoAbstract { encoding: '', }); - res({ + return { len: await getAudioDuration(buffer), url: path.indexOf('http') === -1 @@ -135,8 +150,8 @@ export class ImagesSlides extends VideoAbstract { process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY + path : path, - }); - }) + }; + })() ); return all; @@ -172,6 +187,7 @@ export class ImagesSlides extends VideoAbstract { 'subtitles.srt': srt, }, waitForCompletion: true, + timeout: 30 * 60 * 1000, params: { steps: { ...split.reduce((all, current, index) => { @@ -242,18 +258,23 @@ export class ImagesSlides extends VideoAbstract { @ExposeVideoFunction() async loadVoices(data: any) { - const { voices } = await ( - await fetch( - 'https://api.elevenlabs.io/v2/voices?page_size=40&category=premade', - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'xi-api-key': process.env.ELEVENSLABS_API_KEY || '', - }, - } - ) - ).json(); + const response = await fetch( + 'https://api.elevenlabs.io/v2/voices?page_size=40&category=premade', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'xi-api-key': process.env.ELEVENSLABS_API_KEY || '', + }, + signal: AbortSignal.timeout(30000), + } + ); + + if (!response.ok) { + throw new Error(await elevenLabsError(response)); + } + + const { voices } = await response.json(); return { voices: voices.map((voice: any) => ({ From eae605df251a38b26e66b4020c1b61c6b6b0520c Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 31 Aug 2026 17:30:31 +0700 Subject: [PATCH 19/20] feat(orchestrator): post workflow v1.1.1 retries never-started activities on heartbeat timeout Restores a 3 minute heartbeatTimeout on postSocialPending, finalizePost and postComment and retries at the workflow level when the heartbeat timeout fires with no heartbeat ever received, which means the worker never ran the activity. Comment proxy drops SDK retries; exhausted retries now mark the post unconfirmed instead of leaving it in QUEUE. --- .../src/activities/post.activity.ts | 2 +- apps/orchestrator/src/workflows/index.ts | 1 + .../post-workflows/post.workflow.v1.1.1.ts | 724 ++++++++++++++++++ .../database/prisma/posts/posts.service.ts | 2 +- 4 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.1.ts diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index 27f1b97e7a..d7cacf7066 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -117,7 +117,7 @@ export class PostActivity { for (const post of list) { await this._temporalService.client .getRawClient() - .workflow.signalWithStart('postWorkflowV110', { + .workflow.signalWithStart('postWorkflowV111', { workflowId: `post_${post.id}`, taskQueue: 'main', signal: 'poke', diff --git a/apps/orchestrator/src/workflows/index.ts b/apps/orchestrator/src/workflows/index.ts index b94459d15a..83a1477f52 100644 --- a/apps/orchestrator/src/workflows/index.ts +++ b/apps/orchestrator/src/workflows/index.ts @@ -8,6 +8,7 @@ export * from './post-workflows/post.workflow.v1.0.7'; export * from './post-workflows/post.workflow.v1.0.8'; export * from './post-workflows/post.workflow.v1.0.9'; export * from './post-workflows/post.workflow.v1.1.0'; +export * from './post-workflows/post.workflow.v1.1.1'; export * from './autopost.workflow'; export * from './digest.email.workflow'; export * from './missing.post.workflow'; diff --git a/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.1.ts b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.1.ts new file mode 100644 index 0000000000..900bb9af68 --- /dev/null +++ b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.1.ts @@ -0,0 +1,724 @@ +import { PostActivity } from '@gitroom/orchestrator/activities/post.activity'; +import { + ActivityFailure, + ApplicationFailure, + startChild, + proxyActivities, + sleep, + defineSignal, + setHandler, +} from '@temporalio/workflow'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { capitalize, sortBy } from 'lodash'; +import { PostResponse } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { + TimeoutFailure, + TimeoutType, + TypedSearchAttributes, +} from '@temporalio/common'; +import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; + +// The publishing activities heartbeat every 15s from their first line, so a +// heartbeat timeout can only mean the worker never ran the activity at all. +// Shared by the proxies and the error classifier so the two never drift. +const HEARTBEAT_TIMEOUT = 3 * 60 * 1000; + +const proxyTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, + }); +}; + +// postComment publishes through providers that can legitimately run long +// (media conversion + upload), so it gets a large time budget. The +// heartbeatTimeout exists to detect an activity that was never started: the +// activity heartbeats from its first line, so no heartbeat at all means the +// worker never ran it and nothing was published. No SDK retries: an +// automatic retry of a heartbeat timeout would run again even when the first +// attempt did publish, duplicating the comment. The workflow decides whether +// a failure is safe to retry (see handleActivityError). +const proxyCommentTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '30 minute', + heartbeatTimeout: HEARTBEAT_TIMEOUT, + taskQueue, + retry: { + maximumAttempts: 1, + }, + }); +}; + +// checkPostStatus is a single read-only status call, so it gets a short timeout +// and fast retries - retrying it can never duplicate a post. +const proxyCheckTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '2 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '10 seconds', + }, + }); +}; + +// postSocialPending / finalizePost run irreversible publishing mutations, so no +// automatic retries - a retried activity whose previous (timed-out) attempt +// still completed in the background would publish twice. The workflow retries +// deliberately, and treats timeouts as "outcome unknown". +// The heartbeatTimeout exists to detect an activity that was never started: +// both activities heartbeat from their first line, so no heartbeat at all +// means the worker never ran them and nothing was published. The workflow +// decides whether that is safe to retry (see handleActivityError); every +// other timeout still marks the post as unconfirmed. +const proxyMutationTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '30 minute', + heartbeatTimeout: HEARTBEAT_TIMEOUT, + taskQueue, + retry: { + maximumAttempts: 1, + }, + }); +}; + +const { + getPostsList, + getPost, + inAppNotification, + changeState, + updatePost, + sendWebhooks, + isCommentable, +} = proxyActivities({ + startToCloseTimeout: '10 minute', + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, +}); + +const poke = defineSignal('poke'); + +const iterate = Array.from({ length: 5 }); + +// ~30 minutes at 20s interval (longer than the old in-activity loop, timers are +// free). Multi-item flows (stories, chunked uploads) consume several checks per +// item, so the budget must cover the largest realistic post, not one poll cycle. +const maxPendingChecks = 90; + +export async function postWorkflowV111({ + taskQueue, + postId, + organizationId, + postNow = false, +}: { + taskQueue: string; + postId: string; + organizationId: string; + postNow?: boolean; +}) { + // Dynamic task queue, for concurrency + const { + getIntegrationById, + refreshTokenWithCause, + internalPlugs, + globalPlugs, + processInternalPlug, + processPlug, + } = proxyTaskQueue(taskQueue); + + const { checkPostStatus } = proxyCheckTaskQueue(taskQueue); + + const { postComment } = proxyCommentTaskQueue(taskQueue); + + const { postSocialPending, finalizePost } = proxyMutationTaskQueue(taskQueue); + + let poked = false; + setHandler(poke, () => { + poked = true; + }); + + // get all the posts and comments to post + const firstPost = await getPost(organizationId, postId); + + // in case doesn't exists for some reason, fail it + if (!firstPost) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + if (!postNow && firstPost.state !== 'QUEUE') { + await changeState(firstPost.id, 'ERROR', 'Already posted', [firstPost]); + return; + } + + // wait for the scheduled publish date + if (!postNow) { + await sleep( + dayjs(firstPost.publishDate).isBefore(dayjs()) + ? 0 + : dayjs(firstPost.publishDate).diff(dayjs(), 'millisecond') + ); + } + + // Captured AFTER the scheduling sleep: the repeat-post delay is + // "interval minus time spent publishing", so it must be measured from the + // publish time, not from when the workflow was started. Measuring from the + // workflow start subtracted the whole scheduling wait from the interval + // (a post scheduled further out than its interval repeated immediately). + const startTime = new Date(); + + const postsListBefore = await getPostsList(organizationId, postId); + const [post] = postsListBefore; + + if (!post) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + // if refresh is needed from last time, let's inform the user + if (post.integration?.refreshNeeded) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because you need to reconnect it. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Refresh channel needed', + postsListBefore + ); + return; + } + + // if it's disabled, inform the user + if (post.integration?.disabled) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because it's disabled. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Channel disabled', + postsListBefore + ); + return; + } + + // Do we need to post comment for this social? + const toComment: boolean = + postsListBefore.length === 1 + ? false + : await isCommentable(post.integration); + + const postsList = toComment ? postsListBefore : [postsListBefore[0]]; + + // list of all the saved results + const postsResults: PostResponse[] = []; + + // Every catch block below used to repeat the same failure classification, so + // it is centralized here: detect the failure type, refresh the token when + // needed, and tell the caller what to do. + // 'retry' - the token was refreshed, or the activity never started (heartbeat + // timeout with no heartbeat), run the action again + // 'stop' - the token could not be refreshed + // 'bad-body' - the platform rejected the action + // 'timeout' - the activity timed out, its outcome is unknown + // 'unknown' - anything else (transient errors) + const handleActivityError = async ( + err: unknown, + getIntegration?: () => Promise, + startedAt?: number + ): Promise<{ + type: 'retry' | 'stop' | 'bad-body' | 'timeout' | 'unknown'; + message: string; + }> => { + if ( + err instanceof ActivityFailure && + err.cause instanceof TimeoutFailure + ) { + // A heartbeat timeout that fires right at the heartbeat window after we + // invoked the activity, with the activity heartbeating every 15s from + // its first line, means the worker never ran it at all: nothing was + // published, so it is safe to run again. One that fires later means the + // activity did run for a while and its outcome is unknown. Only the + // callers that heartbeat pass startedAt. + if ( + startedAt !== undefined && + err.cause.timeoutType === TimeoutType.HEARTBEAT && + Date.now() - startedAt <= HEARTBEAT_TIMEOUT + 10_000 + ) { + return { type: 'retry', message: '' }; + } + return { type: 'timeout', message: '' }; + } + + const cause = + err instanceof ActivityFailure && err.cause instanceof ApplicationFailure + ? err.cause + : undefined; + + if (cause?.type === 'refresh_token') { + const refresh = await refreshTokenWithCause( + getIntegration ? await getIntegration() : post.integration, + cause.message || '' + ); + if (!refresh || !refresh.accessToken) { + return { type: 'stop', message: cause.message || '' }; + } + + if (!getIntegration) { + post.integration.token = refresh.accessToken; + } + + return { type: 'retry', message: cause.message || '' }; + } + + if (cause?.type === 'bad_body') { + return { type: 'bad-body', message: cause.message || '' }; + } + + return { type: 'unknown', message: '' }; + }; + + // The platform may have accepted the post but we can't confirm it was + // published - mark the error with a distinct message so the user checks the + // account before reposting manually and duplicating it. + const markUnconfirmed = async (err: any) => { + await changeState(postsList[0].id, 'ERROR', err, postsList); + await inAppNotification( + post.organizationId, + `We couldn't confirm your post on ${capitalize( + post.integration?.providerIdentifier + )}`, + `Your post was sent to ${capitalize( + post.integration?.providerIdentifier + )}, but we couldn't confirm it was published. Please check your ${ + post?.integration?.name + } account before posting again to avoid duplicates.`, + true, + false, + 'fail' + ); + }; + + // The post/comment was already accepted by the platform but returned as + // "pending": poll the read-only status check with durable timers until it + // completes. Errors are fully handled here (never rethrown), otherwise they + // would bubble to the posting retry loop and re-run the publish. + const resolvePending = async ( + pending: PostResponse + ): Promise => { + let pendingData = pending.pendingData; + let errorAttempts = 0; + let startedAt: number | undefined; + + for (let check = 0; check < maxPendingChecks; check++) { + // only finalizePost heartbeats, so a checkPostStatus failure must never + // carry a stale timestamp + startedAt = undefined; + try { + let result = await checkPostStatus(post.integration, pendingData); + + // commit the check's state BEFORE finalizePost runs: if finalize dies + // mid-mutation, the next check must see what it had already authorized, + // so providers can detect the interrupted attempt instead of running + // the mutation again + if (result.status !== 'completed') { + pendingData = result.pendingData; + } + + // polling is done, run the remaining provider mutations + if (result.status === 'ready') { + startedAt = Date.now(); + result = await finalizePost(post.integration, result.pendingData); + } + + if (result.status === 'completed') { + return { + id: pending.id, + postId: result.postId, + releaseURL: result.releaseURL, + status: 'success', + }; + } + + pendingData = result.pendingData; + + // a fully successful iteration proves the platform is reachable: the + // error budget bounds consecutive failures, not blips accumulated over + // a long upload + errorAttempts = 0; + } catch (err) { + const handle = await handleActivityError(err, undefined, startedAt); + + // token refreshed, or finalize never started, check again right away + if (handle.type === 'retry') { + continue; + } + + // the token could not be refreshed while checking, but the platform + // already accepted the post - warn about a possible live post + if (handle.type === 'stop') { + await markUnconfirmed(err); + return false; + } + + // the platform explicitly failed the post, it was not published + if (handle.type === 'bad-body') { + await changeState(postsList[0].id, 'ERROR', err, postsList); + await inAppNotification( + post.organizationId, + `Error posting on ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `An error occurred while posting on ${ + post.integration?.providerIdentifier + }${handle.message ? `: ${handle.message}` : ``}`, + true, + false, + 'fail' + ); + return false; + } + + // unknown error on a read-only check, retry a few more times + errorAttempts++; + if (errorAttempts >= iterate.length) { + break; + } + } + + // the platform is still processing, wait before the next check + await sleep('20 seconds'); + } + + // no verdict from the platform after all the checks + await markUnconfirmed('Could not confirm the post status'); + return false; + }; + + // iterate over the posts + for (let i = 0; i < postsList.length; i++) { + const before = postsResults.length; + // once the platform accepted the post, the catch below must never retry + // the publish - retrying after updatePost / notification errors would + // duplicate the post + let posted = false; + let updated = false; + // this is a small trick to repeat an action in case of token refresh + for (const _ of iterate) { + // captured right before each publish call so a heartbeat timeout can be + // measured against this attempt, not a previous one + let startedAt: number | undefined; + try { + // first post the main post + if (i === 0) { + startedAt = Date.now(); + postsResults.push( + ...(await postSocialPending(post.integration as Integration, [ + postsList[i], + ])) + ); + + // then post the comments if any + } else { + if (postsList[i].delay) { + await sleep(60000 * Math.max(0, Number(postsList[i].delay ?? 0))); + } + + startedAt = Date.now(); + postsResults.push( + ...(await postComment( + postsResults[0].postId, + postsResults.length === 1 + ? undefined + : postsResults[i - 1].postId, + post.integration, + [postsList[i]] + )) + ); + } + + posted = true; + + // the platform accepted the post but is still processing it: resolve + // it here before marking anything, resolvePending handles its own + // errors so a failed status check can never re-run the publish above + if (postsResults[i].status === 'pending') { + let resolved: PostResponse | false = false; + try { + resolved = await resolvePending(postsResults[i]); + } catch (err) { + // never let a pending-resolution error reach the outer catch, it + // would retry the post and duplicate it. Best-effort error state, + // otherwise the post stays in QUEUE and the missing-posts sweep + // would re-publish it. + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + resolved = false; + } + if (!resolved) { + return false; + } + postsResults[i] = resolved; + } + + // mark post as successful + await updatePost( + postsList[i].id, + postsResults[i].postId, + postsResults[i].releaseURL + ); + updated = true; + + if (i === 0) { + // send notification on a sucessful post + await inAppNotification( + post.integration.organizationId, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )}`, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )} at ${postsResults[0].releaseURL}`, + true, + true + ); + } + + // break the current while to move to the next post + break; + } catch (err) { + // the post is already live: never re-run the publish + if (posted) { + if (!updated) { + // still marked QUEUE, record the error so the missing-posts sweep + // doesn't re-publish it + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + return false; + } + + // already marked published, a failed notification shouldn't abort + // the rest of the flow + break; + } + + const handle = await handleActivityError(err, undefined, startedAt); + + // token refreshed, or the publish never started, repeat the action + if (handle.type === 'retry') { + continue; + } + + // the activity timed out: the platform may still complete the publish + // in the background, so never retry it + if (handle.type === 'timeout') { + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + return false; + } + + // for other errors, change state and inform the user if needed + await changeState(postsList[0].id, 'ERROR', err, postsList); + + if (handle.type === 'stop') { + return false; + } + + // specific case for bad body errors + if (handle.type === 'bad-body') { + await inAppNotification( + post.organizationId, + `Error posting${i === 0 ? ' ' : ' comments '}on ${ + post.integration?.providerIdentifier + } for ${post?.integration?.name}`, + `An error occurred while posting${i === 0 ? ' ' : ' comments '}on ${ + post.integration?.providerIdentifier + }${handle.message ? `: ${handle.message}` : ``}`, + true, + false, + 'fail' + ); + return false; + } + } + } + + if (postsResults.length === before) { + // all retries exhausted without success: record it, otherwise the post + // stays in QUEUE with no error and the missing-posts sweep re-publishes + // it. A retried publish may have run without reporting, so treat the + // outcome as unknown. + try { + await markUnconfirmed('Could not publish after several attempts'); + } catch (e) { + /**empty**/ + } + return false; + } + } + + // send webhooks for the post + await sendWebhooks( + postsResults[0].postId, + post.organizationId, + post.integration.id + ); + + // load internal plugs like repost by other users + const internalPlugsList = await internalPlugs( + post.integration, + JSON.parse(post.settings) + ); + + // load global plugs, like repost a post if it gets to a certain number of likes + const globalPlugsList = (await globalPlugs(post.integration)).reduce( + (all, current) => { + for (let i = 1; i <= current.totalRuns; i++) { + all.push({ + ...current, + delay: current.delay * i, + }); + } + + return all; + }, + [] + ); + + // Check if the post is repeatable + const repeatPost = !post.intervalInDays + ? [] + : [ + { + type: 'repeat-post', + delay: + post.intervalInDays * 24 * 60 * 60 * 1000 - + (new Date().getTime() - startTime.getTime()), + }, + ]; + + // Sort all the actions by delay, so we can process them in order + const list = sortBy( + [...internalPlugsList, ...globalPlugsList, ...repeatPost], + 'delay' + ); + + // process all the plugs in order, we are using while because in some cases we need to remove items from the list + while (list.length > 0) { + // get the next to process + const todo = list.shift(); + + // wait for the delay + await sleep(Math.max(0, Number(todo.delay ?? 0))); + + // process internal plug + if (todo.type === 'internal-plug') { + for (const _ of iterate) { + try { + await processInternalPlug({ ...todo, post: postsResults[0].postId }); + } catch (err) { + const handle = await handleActivityError(err, () => + getIntegrationById(organizationId, todo.integration) + ); + + if (handle.type === 'stop' || handle.type === 'bad-body') { + break; + } + + continue; + } + break; + } + } + + // process global plug + if (todo.type === 'global') { + for (const _ of iterate) { + try { + const process = await processPlug({ + ...todo, + postId: postsResults[0].postId, + }); + if (process) { + const toDelete = list + .reduce((all, current, index) => { + if (current.plugId === todo.plugId) { + all.push(index); + } + + return all; + }, []) + .reverse(); + + for (const index of toDelete) { + list.splice(index, 1); + } + } + } catch (err) { + const handle = await handleActivityError(err); + + if (handle.type === 'stop' || handle.type === 'bad-body') { + break; + } + + continue; + } + + break; + } + } + + // process repeat post in a new workflow, this is important so the other plugs can keep running + if (todo.type === 'repeat-post') { + await startChild(postWorkflowV111, { + parentClosePolicy: 'ABANDON', + args: [ + { + taskQueue, + postId, + organizationId, + postNow: true, + }, + ], + workflowId: `post_${post.id}_${makeId(10)}`, + typedSearchAttributes: new TypedSearchAttributes([ + { + key: postIdSearchParam, + value: postId, + }, + ]), + }); + } + } +} 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 961c0a5e19..bc67276986 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -727,7 +727,7 @@ export class PostsService { try { await this._temporalService.client .getRawClient() - ?.workflow.start('postWorkflowV110', { + ?.workflow.start('postWorkflowV111', { workflowId: `post_${postId}`, taskQueue: 'main', workflowIdConflictPolicy: 'TERMINATE_EXISTING', From b2e438f598479a65e7cb6789dfd881ddffaec878 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 2 Sep 2026 12:06:11 +0700 Subject: [PATCH 20/20] feat: reminders --- .../src/workflows/streak.workflow.ts | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/orchestrator/src/workflows/streak.workflow.ts b/apps/orchestrator/src/workflows/streak.workflow.ts index 0ee49ed00d..28845e4d37 100644 --- a/apps/orchestrator/src/workflows/streak.workflow.ts +++ b/apps/orchestrator/src/workflows/streak.workflow.ts @@ -1,11 +1,12 @@ -import { proxyActivities, sleep } from '@temporalio/workflow'; +import { patched, proxyActivities, sleep } from '@temporalio/workflow'; import { EmailActivity } from '@gitroom/orchestrator/activities/email.activity'; -const { sendEmailAsync, getUserOrgs, setStreak } = proxyActivities({ - startToCloseTimeout: '10 minute', - taskQueue: 'main', - cancellationType: 'ABANDON', -}); +const { sendEmailAsync, getUserOrgs, setStreak } = + proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue: 'main', + cancellationType: 'ABANDON', + }); export async function streakWorkflow({ organizationId, @@ -15,17 +16,35 @@ export async function streakWorkflow({ await setStreak(organizationId, 'start'); await sleep(79200000); const userOrgs = await getUserOrgs(organizationId); - for (const user of userOrgs.users) { - if (!user.user.sendStreakEmails) { - continue; + + if (!patched('reminder')) { + for (const user of userOrgs.users) { + if (!user.user.sendStreakEmails) { + continue; + } + await sendEmailAsync( + user.user.email, + 'Streak Reminder', + '

You are about to lose your streak in two hours! schedule a post now to keep it!

', + 'bottom' + ); } - await sendEmailAsync( - user.user.email, - 'Streak Reminder', - '

You are about to lose your streak in two hours! schedule a post now to keep it!

', - 'bottom' - ); } + await sleep(7200000); + + if (patched('reminder')) { + 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'); }