Compare commits

...

63 commits

Author SHA1 Message Date
Nevo David
09088a5391 Merge remote-tracking branch 'origin/main'
Some checks failed
Build / build (22.12.0) (push) Has been cancelled
Code Quality Analysis / Analyze (javascript-typescript) (push) Has been cancelled
2026-05-18 20:49:30 +07:00
Nevo David
faeb89853b feat: threads error 2026-05-18 20:32:45 +07:00
Nevo David
6fc51da7e3
Merge pull request #1538 from gitroomhq/feat/list-view-post-filters
Add state filter (all/scheduled/draft/published) to list view
2026-05-18 20:24:39 +07:00
Santosh Bhandari
415c9c4ba8
Merge pull request #1537 from gitroomhq/tiktok-info
Show TikTok title/content restriction notice for video posts
2026-05-18 10:29:12 +00:00
Santosh Bhandari
e19c855da6
Merge pull request #1539 from gitroomhq/fix/tiktok-pending-share-error-message
Clarify TikTok pending-share error mentions the 24-hour window
2026-05-18 10:27:59 +00:00
Santosh Bhandari
7e0bb7075e fix: clarify TikTok pending-share error mentions 24-hour window
Some checks failed
Build / build (22.12.0) (push) Has been cancelled
TikTok's spam_risk_too_many_pending_share limit applies per 24-hour
period; the previous message did not state the window.
2026-05-18 15:28:03 +05:45
Nevo David
0b3328daeb feat: pinterest fixes 2026-05-18 16:39:38 +07:00
Santosh Bhandari
4811741e63 feat: filter list view by post state (all/scheduled/draft/published)
Adds a state filter to the calendar list view so users can see all
posts (default) or narrow to scheduled, draft, or published. The
backend repository now switches its WHERE/orderBy off the new query
param; 'all' includes ERROR posts so failed publishes remain visible.
2026-05-18 15:21:28 +05:45
Santosh Bhandari
7dda2812d7 feat: add TikTok restriction notice for video posts
Show an inline warning explaining title/content limitations for
direct-post vs upload-only video modes on TikTok.
2026-05-18 14:44:25 +05:45
Nevo David
2316a45388 feat: upgrade nextjs due to security risks 2026-05-18 13:55:12 +07:00
Nevo David
38b0ac8c70 feat: update nestjs 2026-05-15 16:15:50 +07:00
Nevo David
17fa64726c feat: tracking 2026-05-14 18:10:01 +07:00
Nevo David
03ddef66e2 feat: trial tracker 2026-05-14 17:43:42 +07:00
Nevo David
0dce16029e feat: google tag 2026-05-14 17:32:54 +07:00
Nevo David
03aa6b13dd Merge remote-tracking branch 'origin/main' 2026-05-14 16:51:27 +07:00
Nevo David
630602858e feat: gtm 2026-05-14 16:50:01 +07:00
Nevo David
715d3e40fd
Merge pull request #1514 from gitroomhq/feat/creation-method-tracking
feat: track post creation method (WEB/API/MCP/AUTOPOST)
2026-05-14 12:40:16 +07:00
Santosh Bhandari
e63d6d2cf2 feat: restrict public API creation methods to CLI and API 2026-05-14 10:06:58 +05:45
Nevo David
f2ebadab9e
Merge pull request #1515 from gitroomhq/feat/has-extension-helper
feat: hasExtension helper for media type detection
2026-05-14 11:13:36 +07:00
Nevo David
1677714670
Merge branch 'main' into feat/has-extension-helper 2026-05-14 11:13:12 +07:00
Enno Gelhaus
b4635f026b
feat: increase default api rate limit to 90 2026-05-13 17:43:22 +02:00
Nevo David
5f2f5581b2 feat: 3d secure fix 2026-05-13 19:53:53 +07:00
Nevo David
7cc3d9bd78 feat: stripe fix 2026-05-13 09:01:22 +07:00
Nevo David
d2c1eabc8b feat: fix workflow after sleep 2026-05-12 23:57:13 +07:00
Nevo David
4ee5231cb2 Merge remote-tracking branch 'origin/main' 2026-05-12 23:34:02 +07:00
Nevo David
16abf0dc9a feat: posting if no subscription 2026-05-12 23:33:49 +07:00
Enno Gelhaus
86368d7b7b
feat: upgrade ci pnpm 2026-05-12 15:13:56 +02:00
Nevo David
e986d9e493 feat: strip links 2026-05-12 19:51:23 +07:00
Santosh Bhandari
aa0c16b648 feat: accept CLI creation method via public API 2026-05-12 17:25:10 +05:45
Santosh Bhandari
510f396389 feat: show creation method badge only when impersonating 2026-05-12 17:06:29 +05:45
Santosh Bhandari
80b6bdcabe feat: hasExtension helper for media type detection 2026-05-12 16:23:11 +05:45
Santosh Bhandari
e153ab0a9b feat: track post creation method (WEB/API/MCP/AUTOPOST) 2026-05-12 14:41:59 +05:45
Nevo David
cf0ab36a23 feat: deletedAt integrations ignore 2026-05-12 13:27:58 +07:00
Nevo David
009bd36528 feat: fix tiktok url ownership error 2026-05-11 17:50:14 +07:00
Nevo David
905392513f feat: no post 2026-05-11 14:16:08 +07:00
Nevo David
7be292094a feat: no post 2026-05-11 14:15:33 +07:00
Nevo David
6e55eb3b92 feat: more cover for pending queues 2026-05-11 13:57:46 +07:00
Nevo David
39f2a176e1 Merge remote-tracking branch 'origin/main' 2026-05-11 11:19:41 +07:00
Nevo David
638b071283 feat: corrupted file 2026-05-11 11:19:02 +07:00
Nevo David
060c77a68c
Merge pull request #1488 from gitroomhq/fix/ui-modal-max-width
Some checks failed
Build / build (22.12.0) (push) Has been cancelled
Code Quality Analysis / Analyze (javascript-typescript) (push) Has been cancelled
ui: update the modal such that for long text won't cause overflow
2026-05-07 10:40:39 +07:00
Nevo David
d4405906bd
Merge pull request #1489 from gitroomhq/feat/ui-notification-time
feat: update notification list to be scrollable and added time
2026-05-07 10:40:21 +07:00
Nevo David
c8f1074f48
Merge pull request #1494 from gitroomhq/fix/registration-email-lowercase
fix: lowercase email on local registration
2026-05-06 12:10:52 +07:00
Santosh Bhandari
dcb1b0188a fix: lowercase email on local registration 2026-05-06 10:46:25 +05:45
Enno Gelhaus
22f436e72e
feat: simplify pr template 2026-05-04 21:00:24 +02:00
Enno Gelhaus
53f0967e67
feat: merge queue 2026-05-04 15:50:13 +02:00
Santosh Bhandari
18a1a80871 feat: update notification list to be scrollable and added time 2026-05-04 19:05:21 +05:45
Santosh Bhandari
a6967c8519 ui: update the modal such that for long text won't cause overflow 2026-05-04 18:41:34 +05:45
Enno Gelhaus
7e92764ad2
Merge pull request #1482 from gitroomhq/feat/contribute-checker
feat: contributor form
2026-05-04 10:29:58 +02:00
Enno Gelhaus
1bf32426c7
Update contribution form link in CONTRIBUTING.md 2026-05-04 10:29:09 +02:00
Enno Gelhaus
779764aa5d
Fix contribution form link in PR template
Updated contribution form link in PR template.
2026-05-04 10:28:48 +02:00
Nevo David
232ebb2528
Merge pull request #1483 from gitroomhq/fix/linkedin-gif-sharp-compress-removal
fix: remove processing GIF via sharp in linkedin
2026-05-04 15:03:25 +07:00
Santosh Bhandari
d056225053 fix: remove processing GIF via sharp in linkedin 2026-05-04 08:09:25 +05:45
Enno Gelhaus
e419e05f09
Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-03 16:27:56 +02:00
Enno Gelhaus
47ce014204
feat: contributor form 2026-05-03 14:17:54 +02:00
Nevo David
9d14b0262d Merge remote-tracking branch 'origin/main' 2026-05-02 14:00:32 +07:00
Nevo David
971042a074 feat: shrink workflow payload 2026-05-02 14:00:09 +07:00
Nevo David
c3976e554f
Merge pull request #1479 from gitroomhq/fix/discord-provider-error-handler
fix: properly handle error in discord provider
2026-05-01 09:42:49 +07:00
Santosh Bhandari
ef111eb1c4 fix: update error message 2026-04-30 21:45:25 +05:45
Enno Gelhaus
3ee35a7348
feat: temporarily disable cache to fix build 2026-04-30 17:52:36 +02:00
Santosh Bhandari
d6bc6eb0ff fix: properly handle error in discord provider 2026-04-30 21:28:11 +05:45
Nevo David
0d98fc02fb feat: X errors and force upload
Some checks failed
Build / build (22.12.0) (push) Has been cancelled
Code Quality Analysis / Analyze (javascript-typescript) (push) Has been cancelled
2026-04-30 18:34:18 +07:00
Nevo David
7264c00298 feat: err 2026-04-30 17:44:06 +07:00
Nevo David
bb7cd46a4f feat: errors 2026-04-30 17:24:01 +07:00
77 changed files with 3654 additions and 1008 deletions

View file

@ -1,3 +1,5 @@
<!-- Remember to first apply via [the contribution form](https://contribute.postiz.com/p/postiz) before submitting a PR. -->
# What kind of change does this PR introduce?
eg: Bug fix, feature, docs update, ...
@ -16,5 +18,5 @@ Put a "X" in the boxes below to indicate you have followed the checklist;
- [ ] I have read the [CONTRIBUTING](https://github.com/gitroomhq/postiz-app/blob/main/CONTRIBUTING.md) guide.
- [ ] I confirm I have not used AI to submit this PR or generate code for it.
- [ ] I checked that there were not similar issues or PRs already open for this.
- [ ] This PR fixes just ONE issue (do not include multiple issues or types of change in the same PR) For example, don't try and fix a UI issue and include new dependencies in the same PR.
- [ ] I checked that there were no similar issues or PRs already open for this.
- [ ] This PR fixes just ONE issue

View file

@ -3,6 +3,8 @@ name: Build
on:
push:
merge_group:
pull_request:
jobs:
build:
@ -27,7 +29,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
version: 10
run_install: false
- name: Get pnpm store directory
@ -35,15 +37,15 @@ jobs:
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: |
${{ env.STORE_PATH }}
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-
# - name: Setup pnpm cache
# uses: actions/cache@v4
# with:
# path: |
# ${{ env.STORE_PATH }}
# ${{ github.workspace }}/.next/cache
# key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# restore-keys: |
# ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install

View file

@ -1,5 +1,5 @@
---
name: "Code Quality Analysis"
name: "Code Quality Analysis"
on:
push:
@ -9,6 +9,8 @@ on:
- apps/**
- '!apps/docs/**'
- libraries/**
merge_group:
jobs:
analyze:

View file

@ -1,52 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# Overall
max-failures: 3
# Other
require-maintainer-can-modify: true
max-negative-reactions: 3
require-conventional-title: true
# Description
max-emoji-count: 2
max-code-references: 3
blocked-terms: "Generated with Claude Code,Generated with Codex"
# PR Template
require-pr-template: true
strict-pr-template-sections: "What kind of change does this PR introduce?,Why was this change needed?,Checklist:"
optional-pr-template-sections: "Other information:"
max-additional-pr-template-sections: 2
# User
detect-spam-usernames: true
min-account-age: 30
max-daily-forks: 5
min-profile-completeness: 4
# Exemptions
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-users: "nevo-david,egelhaus"
exempt-bots: "postiz-agent[bot]"
# Actions
exempt-label: "exempt"
close-pr: true
failure-add-pr-labels: "spam"
failure-pr-message: "This PR has been marked as Spam, please re-open if this is a mistake."

3
.gitignore vendored
View file

@ -58,3 +58,6 @@ Thumbs.db
.secrets/
libraries/plugins/src/plugins.ts
i18n.cache
# Generated by apps/frontend/scripts/fetch-gtm.mjs on install
apps/frontend/public/g.js

View file

@ -6,6 +6,10 @@ Contributions are welcome - code, docs, whatever it might be! If this is your fi
The main documentation site has a [developer guide](https://docs.postiz.com/developer-guide) . That guide provides you a good understanding of the project structure, and how to setup your development environment. Read this document after you have read that guide. This document is intended to provide you a good understanding of how to submit your first contribution.
## Apply via the contribution form
To submit your contribution, please fill out the [contribution form](https://contribute.postiz.com/p/postiz). This helps us evaluate whether your contribution is a good fit for the project. We will review your submission and get back to you as soon as possible.
## Write code with others
This is an open source project, with an open and welcoming community that is always keen to welcome new contributors. We recommend the two best ways to interact with the community are:

View file

@ -37,6 +37,7 @@ import { OAuthAppController } from '@gitroom/backend/api/routes/oauth-app.contro
import { ApprovedAppsController } from '@gitroom/backend/api/routes/approved-apps.controller';
import { OAuthController, OAuthAuthorizedController } from '@gitroom/backend/api/routes/oauth.controller';
import { AnnouncementsController } from '@gitroom/backend/api/routes/announcements.controller';
import { AdminController } from '@gitroom/backend/api/routes/admin.controller';
import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager';
import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider';
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
@ -63,6 +64,7 @@ const authenticatedController = [
ApprovedAppsController,
OAuthAuthorizedController,
AnnouncementsController,
AdminController,
];
@Module({
imports: [UploadModule],

View file

@ -0,0 +1,47 @@
import {
Controller,
Get,
HttpException,
Query,
} from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { ErrorsService } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.service';
@ApiTags('Admin')
@Controller('/admin')
export class AdminController {
constructor(private _errorsService: ErrorsService) {}
private assertSuperAdmin(user: User) {
if (!user?.isSuperAdmin) {
throw new HttpException('Unauthorized', 400);
}
}
@Get('/errors')
async listErrors(
@GetUserFromRequest() user: User,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('platform') platform?: string,
@Query('email') email?: string,
@Query('unknownFirst') unknownFirst?: string
) {
this.assertSuperAdmin(user);
return this._errorsService.listErrors({
page: page ? parseInt(page, 10) : 0,
limit: limit ? parseInt(limit, 10) : 20,
platform: platform || undefined,
email: email || undefined,
unknownFirst: unknownFirst === 'true' || unknownFirst === '1',
});
}
@Get('/errors/platforms')
async listPlatforms(@GetUserFromRequest() user: User) {
this.assertSuperAdmin(user);
return this._errorsService.listPlatforms();
}
}

View file

@ -101,6 +101,7 @@ export class IntegrationsController {
internalId: p.internalId,
disabled: p.disabled,
editor: findIntegration.editor,
stripLinks: !!findIntegration?.stripLinks?.(),
picture: p.picture || '/no-picture.jpg',
identifier: p.providerIdentifier,
inBetweenSteps: p.inBetweenSteps,

View file

@ -175,7 +175,7 @@ export class PostsController {
) {
console.log(JSON.stringify(rawBody, null, 2));
const body = await this._postsService.mapTypeToPost(rawBody, org.id);
return this._postsService.createPost(org.id, body);
return this._postsService.createPost(org.id, body, 'WEB');
}
@Post('/generator/draft')

View file

@ -36,7 +36,7 @@ import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
throttlers: [
{
ttl: 3600000,
limit: process.env.API_LIMIT ? Number(process.env.API_LIMIT) : 30,
limit: process.env.API_LIMIT ? Number(process.env.API_LIMIT) : 90,
},
],
storage: new ThrottlerStorageRedisService(ioRedis),

View file

@ -55,7 +55,7 @@ async function start() {
})
);
app.use(['/copilot/*', '/posts'], (req: any, res: any, next: any) => {
app.use(['/copilot/{*splat}', '/posts'], (req: any, res: any, next: any) => {
json({ limit: '50mb' })(req, res, next);
});

View file

@ -49,7 +49,10 @@ const PUBLIC_API_ALLOWED_MIME = new Set<string>([
'video/mp4',
]);
import * as Sentry from '@sentry/nestjs';
import { socialIntegrationList, IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import {
socialIntegrationList,
IntegrationManager,
} from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { getValidationSchemas } from '@gitroom/nestjs-libraries/chat/validation.schemas.helper';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
@ -167,8 +170,33 @@ export class PublicIntegrationsController {
);
body.type = rawBody.type;
if (
process.env.RESTRICT_UPLOAD_DOMAINS &&
body.posts.some((p) =>
p.value.some((a) =>
a.image.some(
(i) => i.path.indexOf(process.env.RESTRICT_UPLOAD_DOMAINS) === -1
)
)
)
) {
throw new HttpException(
{
msg: `All media must be uploaded through our upload API route and contain the domain: ${process.env.RESTRICT_UPLOAD_DOMAINS}`,
},
400
);
}
const allowedCreationMethods = ['CLI', 'API'] as const;
const creationMethod = allowedCreationMethods.includes(
rawBody.creationMethod
)
? (rawBody.creationMethod as 'CLI' | 'API')
: 'API';
console.log(JSON.stringify(body, null, 2));
return this._postsService.createPost(org.id, body);
return this._postsService.createPost(org.id, body, creationMethod);
}
@Delete('/posts/:id')
@ -238,7 +266,9 @@ export class PublicIntegrationsController {
if (integrationProvider.externalUrl) {
throw new HttpException(
{ msg: 'This integration requires an external URL and is not supported via the public API' },
{
msg: 'This integration requires an external URL and is not supported via the public API',
},
400
);
}

View file

@ -43,6 +43,9 @@ export class AuthService {
if (process.env.DISALLOW_PLUS && body.email.includes('+')) {
throw new Error('Email with plus sign is not allowed');
}
if (body instanceof CreateOrgUserDto) {
body.email = body.email.toLowerCase();
}
const user = await this._userService.getUserByEmail(body.email);
if (body instanceof CreateOrgUserDto) {
if (user) {

View file

@ -5,6 +5,8 @@
"type": "module",
"scripts": {
"dev": "dotenv -e ../../.env -- next dev -p 4200",
"fetch-gtm": "node scripts/fetch-gtm.mjs",
"postinstall": "node scripts/fetch-gtm.mjs",
"build": "next build",
"build:sentry": "dotenv -e ../../.env -- next build",
"start": "dotenv -e ../../.env -- next start -p 4200",

View file

@ -0,0 +1,50 @@
import { writeFile, mkdir, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const envPath = resolve(__dirname, '..', '..', '..', '.env');
const outPath = resolve(__dirname, '..', 'public', 'g.js');
if (!process.env.NEXT_PUBLIC_GTM_ID && existsSync(envPath)) {
const content = await readFile(envPath, 'utf8');
for (const raw of content.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!process.env[key]) process.env[key] = value;
}
}
const id = process.env.NEXT_PUBLIC_GTM_ID;
if (!id) {
console.log('[fetch-gtm] NEXT_PUBLIC_GTM_ID not set, skipping');
process.exit(0);
}
const url = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(id)}`;
try {
console.log(`[fetch-gtm] fetching ${url}`);
const res = await fetch(url);
if (!res.ok) {
console.warn(`[fetch-gtm] non-OK response ${res.status}, skipping`);
process.exit(0);
}
const body = await res.text();
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, body, 'utf8');
console.log(`[fetch-gtm] wrote ${outPath} (${body.length} bytes)`);
} catch (err) {
console.warn(`[fetch-gtm] failed: ${err?.message || err}, skipping`);
process.exit(0);
}

View file

@ -12,6 +12,7 @@ import { VideoOrImage } from '@gitroom/react/helpers/video.or.image';
import { CopyClient } from '@gitroom/frontend/components/preview/copy.client';
import { getT } from '@gitroom/react/translation/get.translation.service.backend';
import { RenderPreviewDateClient } from '@gitroom/frontend/components/preview/render.preview.date.client';
import { CreationMethodBadge } from '@gitroom/frontend/components/launches/creation.method.badge';
dayjs.extend(utc);
export const metadata: Metadata = {
@ -142,6 +143,12 @@ export default async function Auth(
<span className="text-sm text-gray-500">
@{post[0].integration.profile}
</span>
{index === 0 && (
<CreationMethodBadge
creationMethod={p.creationMethod}
size="md"
/>
)}
</div>
<div className="flex flex-col gap-[20px]">
<div

View file

@ -0,0 +1,17 @@
export const dynamic = 'force-dynamic';
import { AdminErrorsComponent } from '@gitroom/frontend/components/admin/admin-errors.component';
import { Metadata } from 'next';
import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side';
export const metadata: Metadata = {
title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Admin Errors`,
description: '',
};
export default async function Page() {
return (
<div className="bg-newBgColorInner flex-1 flex-col flex p-[20px] gap-[12px]">
<AdminErrorsComponent />
</div>
);
}

View file

@ -15,6 +15,7 @@ import { PHProvider } from '@gitroom/react/helpers/posthog';
import UtmSaver from '@gitroom/helpers/utils/utm.saver';
import { DubAnalytics } from '@gitroom/frontend/components/layout/dubAnalytics';
import { FacebookComponent } from '@gitroom/frontend/components/layout/facebook.component';
import { GoogleTagManagerComponent } from '@gitroom/frontend/components/layout/gtm.component';
import { cookies } from 'next/headers';
import {
cookieName,
@ -81,6 +82,8 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
disableXAnalytics={!!process.env.DISABLE_X_ANALYTICS}
sentryDsn={process.env.NEXT_PUBLIC_SENTRY_DSN!}
extensionId={process.env.EXTENSION_ID || ''}
googleAdsId={process.env.NEXT_PUBLIC_GTM_ID}
googleAdsTrialTracking={process.env.NEXT_PUBLIC_TRACKING_TRIAL}
language={language}
transloadit={
process.env.TRANSLOADIT_AUTH && process.env.TRANSLOADIT_TEMPLATE
@ -96,6 +99,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
<HtmlComponent />
<DubAnalytics />
<FacebookComponent />
<GoogleTagManagerComponent gtmId={process.env.NEXT_PUBLIC_GTM_ID} />
<Plausible
domain={!!process.env.IS_GENERAL ? 'postiz.com' : 'gitroom.com'}
>

View file

@ -0,0 +1,411 @@
'use client';
import React, { FC, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import copy from 'copy-to-clipboard';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Button } from '@gitroom/react/form/button';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
interface ErrorRow {
id: string;
message: string;
body: string;
platform: string;
postId: string;
createdAt: string;
organization: {
id: string;
name: string;
users: { user: { id: string; email: string; name: string | null } }[];
};
post: { id: string; content: string | null };
}
interface ErrorsResponse {
items: ErrorRow[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}
const safeParse = (value: string) => {
try {
return JSON.parse(value);
} catch {
return value;
}
};
const ErrorDetailsModal: FC<{ row: ErrorRow }> = ({ row }) => {
const modal = useModals();
const toaster = useToaster();
const parsedMessage = useMemo(() => safeParse(row.message), [row.message]);
const parsedBody = useMemo(() => safeParse(row.body), [row.body]);
const copyAll = useCallback(() => {
copy(
JSON.stringify(
{ message: parsedMessage, body: parsedBody, meta: row },
null,
2
)
);
toaster.show('Debug code copied to clipboard', 'success');
}, [parsedMessage, parsedBody, row, toaster]);
return (
<div className="rounded-[4px] border border-newTableBorder bg-newBgColorInner px-[16px] pb-[16px] relative w-full max-h-[80vh] overflow-auto">
<div className="sticky top-0 bg-newBgColorInner py-[16px] flex items-center justify-between gap-[12px] z-10 border-b border-newTableBorder mb-[12px]">
<div className="text-[16px] font-[600]">Error Details</div>
<div className="flex gap-[8px] items-center">
<Button onClick={copyAll}>Copy Debug Code</Button>
<button
className="outline-none w-[28px] h-[28px] flex items-center justify-center hover:bg-tableBorder cursor-pointer rounded"
type="button"
onClick={() => modal.closeAll()}
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-[12px] text-[13px] mb-[12px]">
<div>
<div className="opacity-60">Platform</div>
<div>{row.platform}</div>
</div>
<div>
<div className="opacity-60">Created</div>
<div>{new Date(row.createdAt).toLocaleString()}</div>
</div>
<div>
<div className="opacity-60">Organization</div>
<div>
{row.organization?.name}{' '}
<span className="opacity-60">({row.organization?.id})</span>
</div>
</div>
<div>
<div className="opacity-60">Users</div>
<div className="break-all">
{row.organization?.users
?.map((u) => u.user?.email)
.filter(Boolean)
.join(', ') || '—'}
</div>
</div>
<div className="col-span-2">
<div className="opacity-60">Post ID</div>
<div>{row.postId}</div>
</div>
</div>
<div className="text-[13px] font-[600] mb-[6px]">message</div>
<pre className="text-[12px] bg-sixth p-[12px] rounded overflow-auto max-h-[40vh] whitespace-pre-wrap break-all">
{typeof parsedMessage === 'string'
? parsedMessage
: JSON.stringify(parsedMessage, null, 2)}
</pre>
<div className="text-[13px] font-[600] mb-[6px] mt-[12px]">body</div>
<pre className="text-[12px] bg-sixth p-[12px] rounded overflow-auto max-h-[40vh] whitespace-pre-wrap break-all">
{typeof parsedBody === 'string'
? parsedBody
: JSON.stringify(parsedBody, null, 2)}
</pre>
</div>
);
};
const usePlatformsList = () => {
const fetch = useFetch();
return useSWR<string[]>('/admin/errors/platforms', async (url: string) => {
const res = await fetch(url);
if (!res.ok) return [];
return res.json();
});
};
const useErrorsList = (params: {
page: number;
limit: number;
platform: string;
email: string;
unknownFirst: boolean;
}) => {
const fetch = useFetch();
const query = new URLSearchParams({
page: String(params.page),
limit: String(params.limit),
...(params.platform ? { platform: params.platform } : {}),
...(params.email ? { email: params.email } : {}),
unknownFirst: params.unknownFirst ? 'true' : 'false',
});
const key = `/admin/errors?${query.toString()}`;
return useSWR<ErrorsResponse>(key, async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
throw new Error('Failed to load errors');
}
return res.json();
});
};
export const AdminErrorsComponent: FC = () => {
const user = useUser();
const modal = useModals();
const toaster = useToaster();
const [page, setPage] = useState(0);
const [limit, setLimit] = useState(20);
const [platform, setPlatform] = useState('');
const [email, setEmail] = useState('');
const [emailInput, setEmailInput] = useState('');
const [unknownFirst, setUnknownFirst] = useState(true);
const { data: platforms } = usePlatformsList();
const { data, isLoading, error } = useErrorsList({
page,
limit,
platform,
email,
unknownFirst,
});
const onApplyEmail = useCallback(() => {
setPage(0);
setEmail(emailInput.trim());
}, [emailInput]);
const onClear = useCallback(() => {
setPage(0);
setEmail('');
setEmailInput('');
setPlatform('');
}, []);
const openDetails = useCallback(
(row: ErrorRow) => {
modal.openModal({
closeOnClickOutside: true,
withCloseButton: false,
classNames: {
modal: 'w-[100%] max-w-[1100px] text-textColor',
},
children: <ErrorDetailsModal row={row} />,
});
},
[modal]
);
const copyRow = useCallback(
(row: ErrorRow) => {
copy(
JSON.stringify(
{ message: safeParse(row.message), body: safeParse(row.body), meta: row },
null,
2
)
);
toaster.show('Debug code copied to clipboard', 'success');
},
[toaster]
);
if (!user?.isSuperAdmin) {
return (
<div className="text-textColor p-[20px]">
You do not have access to this page.
</div>
);
}
const totalPages = data ? Math.max(1, Math.ceil(data.total / limit)) : 1;
return (
<div className="flex flex-col gap-[16px] text-textColor">
<div className="flex items-center justify-between">
<div className="text-[20px] font-[600]">Errors</div>
<div className="text-[13px] opacity-70">
{data ? `${data.total} total` : ''}
</div>
</div>
<div className="flex flex-wrap gap-[12px] items-end bg-newBgColorInner border border-newTableBorder rounded-[8px] p-[12px]">
<div className="flex flex-col gap-[6px]">
<div className="text-[12px] opacity-70">Platform</div>
<select
value={platform}
onChange={(e) => {
setPage(0);
setPlatform(e.target.value);
}}
className="bg-newBgColorInner h-[38px] border border-newTableBorder rounded-[8px] px-[10px] text-[14px] text-textColor min-w-[180px]"
>
<option value="">All platforms</option>
{(platforms || []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-[6px]">
<div className="text-[12px] opacity-70">Email contains</div>
<div className="flex gap-[8px]">
<input
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onApplyEmail();
}}
placeholder="user@example.com"
className="bg-newBgColorInner h-[38px] border border-newTableBorder rounded-[8px] px-[10px] text-[14px] text-textColor min-w-[240px]"
/>
<Button onClick={onApplyEmail}>Apply</Button>
</div>
</div>
<label className="flex items-center gap-[6px] text-[13px] cursor-pointer h-[38px]">
<input
type="checkbox"
checked={unknownFirst}
onChange={(e) => {
setPage(0);
setUnknownFirst(e.target.checked);
}}
/>
Unknown Error first
</label>
<div className="flex flex-col gap-[6px]">
<div className="text-[12px] opacity-70">Per page</div>
<select
value={limit}
onChange={(e) => {
setPage(0);
setLimit(parseInt(e.target.value, 10));
}}
className="bg-newBgColorInner h-[38px] border border-newTableBorder rounded-[8px] px-[10px] text-[14px] text-textColor"
>
{[10, 20, 50, 100].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<Button secondary onClick={onClear}>
Clear filters
</Button>
</div>
{isLoading ? (
<LoadingComponent />
) : error ? (
<div className="text-red-400">Failed to load errors.</div>
) : !data || data.items.length === 0 ? (
<div className="opacity-70">No errors found.</div>
) : (
<div className="border border-newTableBorder rounded-[8px] overflow-hidden">
<div className="grid grid-cols-[170px_120px_220px_1fr_220px] gap-[12px] px-[12px] py-[10px] bg-newBgColorInner text-[12px] uppercase opacity-70 border-b border-newTableBorder">
<div>Created</div>
<div>Platform</div>
<div>User / Org</div>
<div>Message</div>
<div className="text-right">Actions</div>
</div>
{data.items.map((row) => {
const isUnknown = (row.message || '').includes('Unknown Error');
const emails =
row.organization?.users
?.map((u) => u.user?.email)
.filter(Boolean)
.join(', ') || '—';
const preview =
(row.message || '').length > 280
? row.message.slice(0, 280) + '…'
: row.message;
return (
<div
key={row.id}
className="grid grid-cols-[170px_120px_220px_1fr_220px] gap-[12px] px-[12px] py-[10px] text-[13px] border-b border-newTableBorder last:border-b-0 items-start"
>
<div className="opacity-90">
{new Date(row.createdAt).toLocaleString()}
</div>
<div>
<span
className={
isUnknown
? 'text-red-400 font-[600]'
: 'opacity-90'
}
>
{row.platform}
</span>
</div>
<div className="break-all">
<div>{emails}</div>
<div className="opacity-60 text-[12px]">
{row.organization?.name}
</div>
</div>
<div className="break-all whitespace-pre-wrap font-mono text-[12px] opacity-90">
{preview}
</div>
<div className="flex gap-[8px] justify-end">
<Button secondary onClick={() => openDetails(row)}>
View
</Button>
<Button onClick={() => copyRow(row)}>Copy</Button>
</div>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-between">
<div className="text-[13px] opacity-70">
Page {page + 1} of {totalPages}
</div>
<div className="flex gap-[8px]">
<Button
secondary
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Previous
</Button>
<Button
disabled={!data?.hasMore}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
</div>
);
};

View file

@ -33,6 +33,7 @@ import dayjs from 'dayjs';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
export const AgentChat: FC = () => {
const { backendUrl } = useVariables();
@ -162,7 +163,7 @@ const NewInput: FC<InputProps> = (props) => {
? '\n[--Media--]' +
media
.map((m) =>
m.path.indexOf('mp4') > -1
hasExtension(m.path, 'mp4')
? `Video: ${m.path}`
: `Image: ${m.path}`
)

View file

@ -26,6 +26,8 @@ import { expandPostsList, expandPosts } from '@gitroom/helpers/utils/posts.list.
extend(isoWeek);
extend(weekOfYear);
export type ListStateFilter = 'all' | 'scheduled' | 'draft' | 'published';
export const CalendarContext = createContext({
startDate: newDayjs().startOf('isoWeek').format('YYYY-MM-DD'),
endDate: newDayjs().endOf('isoWeek').format('YYYY-MM-DD'),
@ -78,6 +80,10 @@ export const CalendarContext = createContext({
setListPage: (page: number) => {
/** empty **/
},
listState: 'all' as ListStateFilter,
setListState: (state: ListStateFilter) => {
/** empty **/
},
});
export interface Integrations {
@ -86,6 +92,7 @@ export interface Integrations {
disabled?: boolean;
inBetweenSteps: boolean;
editor: 'none' | 'normal' | 'markdown' | 'html';
stripLinks?: boolean;
display: string;
identifier: string;
type: string;
@ -143,6 +150,11 @@ export const CalendarWeekProvider: FC<{
// List view state
const [listPage, setListPage] = useState(0);
const [listState, setListStateRaw] = useState<ListStateFilter>('all');
const setListState = useCallback((next: ListStateFilter) => {
setListStateRaw(next);
setListPage(0);
}, []);
// Initialize with current date range based on URL params or defaults
const initStartDate = searchParams.get('startDate');
@ -189,8 +201,9 @@ export const CalendarWeekProvider: FC<{
page: listPage.toString(),
limit: '100',
customer: filters?.customer?.toString() || '',
state: listState,
}).toString();
}, [listPage, filters.customer]);
}, [listPage, filters.customer, listState]);
const loadListData = useCallback(async () => {
const response = await fetch(`/posts/list?${listParams}`);
@ -340,6 +353,8 @@ export const CalendarWeekProvider: FC<{
listPage,
listTotalPages,
setListPage,
listState,
setListState,
}}
>
{children}

View file

@ -51,6 +51,7 @@ import { MissingReleaseModal } from '@gitroom/frontend/components/launches/missi
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import i18next from 'i18next';
import { AddEditModal } from '@gitroom/frontend/components/new-launch/add.edit.modal';
import { CreationMethodBadge } from '@gitroom/frontend/components/launches/creation.method.badge';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import copy from 'copy-to-clipboard';
@ -492,7 +493,15 @@ export const MonthView = () => {
export const ListView = () => {
const t = useT();
const user = useUser();
const { integrations, loading, listPosts } = useCalendar();
const { integrations, loading, listPosts, listState } = useCalendar();
const emptyMessage =
listState === 'scheduled'
? t('no_upcoming_posts', 'No upcoming posts scheduled')
: listState === 'draft'
? t('no_draft_posts', 'No draft posts')
: listState === 'published'
? t('no_published_posts', 'No published posts')
: t('no_posts', 'No posts');
// Use shared post actions hook
const { editPost, deletePost, copyDebugJson, openStatistics, openMissingRelease } = usePostActions();
@ -521,9 +530,7 @@ export const ListView = () => {
if (listPosts.length === 0) {
return (
<div className="flex flex-col flex-1 items-center justify-center">
<div className="text-textColor text-[16px]">
{t('no_upcoming_posts', 'No upcoming posts scheduled')}
</div>
<div className="text-textColor text-[16px]">{emptyMessage}</div>
</div>
);
}
@ -1005,6 +1012,11 @@ const CalendarItem: FC<{
missingRelease,
} = props;
const { disableXAnalytics } = useVariables();
const user = useUser();
const showCreationMethodBadge =
user?.impersonate &&
post.creationMethod &&
post.creationMethod !== 'UNKNOWN';
const preview = useCallback(() => {
window.open(`/p/` + post.id + '?share=true', '_blank');
}, [post]);
@ -1044,6 +1056,14 @@ const CalendarItem: FC<{
!
</div>
)}
{showCreationMethodBadge && (
<div className="absolute -bottom-[4px] -right-[4px] z-10">
<CreationMethodBadge
creationMethod={post.creationMethod}
ringColor="var(--new-bgColor)"
/>
</div>
)}
<div
className={clsx(
'text-white text-[11px] max-h-[24px] h-[24px] min-h-[24px] w-full rounded-tr-[10px] rounded-tl-[10px] flex items-center justify-center gap-[10px] px-[5px] bg-btnPrimary'

View file

@ -0,0 +1,50 @@
import { FC } from 'react';
import clsx from 'clsx';
type CreationMethod = 'UNKNOWN' | 'WEB' | 'API' | 'MCP' | 'AUTOPOST' | 'CLI';
interface Props {
creationMethod?: CreationMethod | string | null;
size?: 'xs' | 'sm' | 'md';
className?: string;
ringColor?: string;
}
const tooltipFor = (m: string) =>
m === 'AUTOPOST' ? 'Auto-posted by system' : `Created via ${m}`;
export const CreationMethodBadge: FC<Props> = ({
creationMethod,
size = 'xs',
className,
ringColor,
}) => {
if (!creationMethod || creationMethod === 'UNKNOWN') return null;
const sizeClasses =
size === 'xs'
? 'h-[12px] px-[4px] text-[7px]'
: size === 'md'
? 'h-[22px] px-[10px] text-[12px]'
: 'h-[18px] px-[8px] text-[10px]';
return (
<div
className={clsx(
'inline-flex items-center justify-center rounded-full text-white font-bold uppercase tracking-wide leading-none cursor-default',
sizeClasses,
creationMethod === 'WEB' && 'bg-[#6b7280]',
creationMethod === 'API' && 'bg-[#2563eb]',
creationMethod === 'MCP' && 'bg-[#9333ea]',
creationMethod === 'AUTOPOST' && 'bg-[#d97706]',
creationMethod === 'CLI' && 'bg-[#0f766e]',
className
)}
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
data-tooltip-id="tooltip"
data-tooltip-content={tooltipFor(creationMethod)}
>
{creationMethod}
</div>
);
};

View file

@ -1,6 +1,6 @@
'use client';
import { useCalendar } from '@gitroom/frontend/components/launches/calendar.context';
import { useCalendar, ListStateFilter } from '@gitroom/frontend/components/launches/calendar.context';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useCallback } from 'react';
@ -259,6 +259,21 @@ export const Filters = () => {
const isListView = calendar.display === 'list';
const setListStateFilter = useCallback(
(next: ListStateFilter) => () => {
if (calendar.listState === next) return;
calendar.setListState(next);
},
[calendar]
);
const listStateOptions: { value: ListStateFilter; label: string }[] = [
{ value: 'all', label: t('all', 'All') },
{ value: 'scheduled', label: t('scheduled', 'Scheduled') },
{ value: 'draft', label: t('draft', 'Draft') },
{ value: 'published', label: t('published', 'Published') },
];
const previousPage = useCallback(() => {
if (calendar.listPage > 0) {
calendar.setListPage(calendar.listPage - 1);
@ -393,6 +408,21 @@ export const Filters = () => {
</svg>
</div>
</div>
<div className="flex flex-row p-[4px] border border-newTableBorder rounded-[8px] text-[14px] font-[500]">
{listStateOptions.map((option) => (
<div
key={option.value}
onClick={setListStateFilter(option.value)}
className={clsx(
'pt-[6px] pb-[5px] cursor-pointer min-w-[80px] px-[12px] text-center rounded-[6px]',
calendar.listState === option.value &&
'text-textItemFocused bg-boxFocused'
)}
>
{option.label}
</div>
))}
</div>
<div className="flex-1" />
</div>
)}

View file

@ -4,6 +4,7 @@ import { EventEmitter } from 'events';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
import { useVariables } from '@gitroom/react/helpers/variable.context';
const postUrlEmitter = new EventEmitter();
@ -375,7 +376,7 @@ export const MediaComponentInner: FC<{
className="w-full px-3 py-2 bg-fifth border border-tableBorder rounded-lg text-textColor placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-forth focus:border-transparent"
/>
</div>
{media?.path.indexOf('mp4') > -1 && (
{hasExtension(media?.path, 'mp4') && (
<>
{/* Alt Text Input */}
<div>

View file

@ -147,7 +147,7 @@ export const ImportDebugPostModal: FC<{ close: () => void }> = ({ close }) => {
<div className="text-[13px] font-[600] text-textColor">
{t('debug_info', 'Debug Info')}
</div>
<div className="text-[12px] text-textColor/70 flex flex-col gap-[4px]">
<div className="text-[12px] text-textColor/70 flex flex-col gap-[4px] min-w-0 break-all">
<div>
<span className="font-[500]">
{t('provider', 'Provider')}:
@ -175,7 +175,7 @@ export const ImportDebugPostModal: FC<{ close: () => void }> = ({ close }) => {
<span className="font-[500]">
{t('error_details', 'Error Details')}:
</span>
<div className="mt-[4px] max-h-[100px] overflow-y-auto bg-newBgColor p-[8px] rounded-[4px] text-[11px] font-mono">
<div className="mt-[4px] max-h-[100px] overflow-y-auto bg-newBgColor p-[8px] rounded-[4px] text-[11px] font-mono break-all whitespace-pre-wrap">
{parsed._debug.errors.map((err, i) => (
<div key={i} className="mb-[4px]">
[{err.platform}] {err.message}

View file

@ -7,6 +7,7 @@ import clsx from 'clsx';
import SafeImage from '@gitroom/react/helpers/safe.image';
import { capitalize } from 'lodash';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { hasLinks } from '@gitroom/helpers/utils/strip.links';
const Valid: FC = () => {
return (
@ -59,15 +60,36 @@ export const InformationComponent: FC<{
totalChars: number;
totalAllowedChars: number;
isPicture: boolean;
}> = ({ totalChars, totalAllowedChars, chars, isPicture }) => {
text?: string;
}> = ({ totalChars, totalAllowedChars, chars, isPicture, text }) => {
const t = useT();
const { isGlobal, selectedIntegrations, internal } = useLaunchStore(
useShallow((state) => ({
isGlobal: state.current === 'global',
selectedIntegrations: state.selectedIntegrations,
internal: state.internal,
}))
);
const { isGlobal, selectedIntegrations, internal, currentIntegration } =
useLaunchStore(
useShallow((state) => ({
isGlobal: state.current === 'global',
selectedIntegrations: state.selectedIntegrations,
internal: state.internal,
currentIntegration: state.integrations.find(
(p) => p.id === state.current
),
}))
);
const stripLinkNames = useMemo(() => {
if (!hasLinks(text)) {
return [] as string[];
}
if (!isGlobal) {
return currentIntegration?.stripLinks ? [currentIntegration.name] : [];
}
return selectedIntegrations
.filter((p) => p.integration.stripLinks)
.map((p) => p.integration.name);
}, [text, isGlobal, currentIntegration, selectedIntegrations]);
const showStripLinkWarning = stripLinkNames.length > 0;
const isInternal = useMemo(() => {
if (!isGlobal) {
@ -83,6 +105,10 @@ export const InformationComponent: FC<{
}, [isGlobal, internal, selectedIntegrations]);
const isValid = useMemo(() => {
if (showStripLinkWarning) {
return false;
}
if (!isPicture && !totalChars) {
return false;
}
@ -108,7 +134,14 @@ export const InformationComponent: FC<{
}
return true;
}, [totalAllowedChars, totalChars, isInternal, isPicture, chars]);
}, [
totalAllowedChars,
totalChars,
isInternal,
isPicture,
chars,
showStripLinkWarning,
]);
const globalDisplayLimit = useMemo(() => {
if (!isGlobal || !selectedIntegrations.length) {
@ -230,6 +263,19 @@ export const InformationComponent: FC<{
))}
</div>
)}
{showStripLinkWarning && (
<div
className={clsx(
'text-sm text-[#FF3F3F] whitespace-nowrap',
((isGlobal && selectedIntegrations.length) ||
(!isPicture && !totalChars)) &&
'mt-[12px]'
)}
>
{t('links_will_be_removed_from', 'Links will be removed from')}:{' '}
{stripLinkNames.join(', ')}
</div>
)}
</div>
)}
</div>

View file

@ -0,0 +1,56 @@
'use client';
import Script from 'next/script';
import { FC, useEffect } from 'react';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useVariables } from '@gitroom/react/helpers/variable.context';
export const TrialTracker: FC = () => {
const user = useUser();
const { googleAdsId, googleAdsTrialTracking } = useVariables();
useEffect(() => {
if (
typeof window === 'undefined' ||
!user?.id ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
!window.gtag ||
!googleAdsId ||
!googleAdsTrialTracking
)
return;
const params = new URLSearchParams(window.location.search);
if (params.get('onboarding') !== 'true') return;
const key = `gtm_start_trial_${user?.id}`;
if (sessionStorage.getItem(key)) return;
sessionStorage.setItem(key, '1');
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
gtag('event', 'conversion', {
send_to: `${googleAdsId}/${googleAdsTrialTracking}`,
});
}, [user]);
return null;
};
export const GoogleTagManagerComponent: FC<{ gtmId?: string }> = ({
gtmId,
}) => {
if (!gtmId) {
return null;
}
return (
<>
<Script src="/g.js" strategy="afterInteractive" />
<Script id="google-ads-gtag" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gtmId}');
`}
</Script>
</>
);
};

View file

@ -411,6 +411,21 @@ const AddAnnouncement = () => {
);
};
const ViewErrors = () => {
const t = useT();
const handleClick = useCallback(() => {
window.location.href = '/admin/errors';
}, []);
return (
<div
className="px-[10px] rounded-[4px] bg-blue-700 text-white cursor-pointer whitespace-nowrap"
onClick={handleClick}
>
{t('view_errors', 'View Errors')}
</div>
);
};
const ImportDebugPost = () => {
const { openModal } = useModals();
const t = useT();
@ -418,6 +433,7 @@ const ImportDebugPost = () => {
const handleClick = useCallback(() => {
openModal({
title: t('import_debug_post', 'Import Debug Post'),
maxSize: 800,
children: (close) => <ImportDebugPostModal close={close} />,
});
}, []);
@ -527,6 +543,7 @@ export const Impersonate = () => {
</div>
<ImportDebugPost />
<AddAnnouncement />
<ViewErrors />
</div>
)}
</div>

View file

@ -31,6 +31,7 @@ interface OpenModalInterface {
modal?: string;
};
size?: string | number;
maxSize?: string | number;
height?: string | number;
id?: string;
}
@ -200,10 +201,11 @@ export const Component: FC<{
modal.size ? '' : 'min-w-[600px]',
modal.fullScreen && 'h-full'
)}
{...((!!modal.size || !!modal.height) && {
{...((!!modal.size || !!modal.height || !!modal.maxSize) && {
style: {
...(modal.size ? { width: modal.size } : {}),
...(modal.height ? { height: modal.height } : {}),
...(modal.maxSize ? { maxWidth: modal.maxSize } : {}),
},
})}
onClick={(e) => e.stopPropagation()}

View file

@ -3,8 +3,10 @@ import dayjs, { ConfigType } from 'dayjs';
import { FC, useEffect } from 'react';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(timezone);
dayjs.extend(utc);
dayjs.extend(relativeTime);
const { utc: originalUtc } = dayjs;

View file

@ -14,6 +14,7 @@ import React, {
import { Button } from '@gitroom/react/form/button';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
import { Media } from '@prisma/client';
import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory';
import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values';
@ -351,7 +352,7 @@ export const MediaBox: FC<{
top: 10,
children: (
<div className="w-full h-full p-[50px]">
{media.path.indexOf('mp4') > -1 ? (
{hasExtension(media.path, 'mp4') ? (
<VideoFrame
autoplay={true}
url={mediaDirectory.set(media.path)}
@ -525,9 +526,9 @@ export const MediaBox: FC<{
{data?.results
?.filter((f: any) => {
if (type === 'video') {
return f.path.indexOf('mp4') > -1;
return hasExtension(f.path, 'mp4');
} else if (type === 'image') {
return f.path.indexOf('mp4') === -1;
return !hasExtension(f.path, 'mp4');
}
return true;
})
@ -579,7 +580,7 @@ export const MediaBox: FC<{
</svg>
</div>
</div>
{media.path.indexOf('mp4') > -1 ? (
{hasExtension(media.path, 'mp4') ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<img
@ -803,7 +804,7 @@ export const MultiMediaComponent: FC<{
>
<MediaSettingsIcon className="cursor-pointer relative z-[200]" />
</div>
{media?.path?.indexOf('mp4') > -1 ? (
{hasExtension(media?.path, 'mp4') ? (
<VideoFrame url={mediaDirectory.set(media?.path)} />
) : (
<img

View file

@ -772,6 +772,7 @@ export const Editor: FC<{
chars={chars}
totalChars={valueWithoutHtml.length}
totalAllowedChars={props.totalChars}
text={valueWithoutHtml}
/>
}
toolBar={

View file

@ -33,6 +33,7 @@ import { SelectCustomer } from '@gitroom/frontend/components/launches/select.cus
import { CopilotPopup } from '@copilotkit/react-ui';
import { DummyCodeComponent } from '@gitroom/frontend/components/new-launch/dummy.code.component';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { CreationMethodBadge } from '@gitroom/frontend/components/launches/creation.method.badge';
import {
SettingsIcon,
ChevronDownIcon,
@ -341,9 +342,12 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
await fetch('/posts/should-shortlink', {
method: 'POST',
body: JSON.stringify({
messages: checkAllValid.flatMap((p: any) =>
p.values.flatMap((a: any) => a.content)
),
messages: checkAllValid
// platforms that remove links won't keep shortlinks either
.filter((p: any) => !p?.integration?.stripLinks)
.flatMap((p: any) =>
p.values.flatMap((a: any) => a.content)
),
}),
})
).json();
@ -447,8 +451,12 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
<div className="flex flex-1 bg-newBgColorInner rounded-[20px] flex-col">
<div className="flex-1 flex">
<div className="flex flex-col flex-1 border-e border-newBorder">
<div className="bg-newBgColor h-[65px] rounded-s-[20px] !rounded-b-[0] flex items-center px-[20px] text-[20px] font-[600]">
<div className="bg-newBgColor h-[65px] rounded-s-[20px] !rounded-b-[0] flex items-center gap-[12px] px-[20px] text-[20px] font-[600]">
{t('create_post_title', 'Create Post')}
<CreationMethodBadge
creationMethod={existingData?.posts?.[0]?.creationMethod}
size="sm"
/>
</div>
<div className="flex-1 flex flex-col gap-[16px]">
<div

View file

@ -35,13 +35,18 @@ export default withProvider({
CustomPreviewComponent: PinterestPreview,
dto: PinterestSettingsDto,
checkValidity: async ([firstItem, ...otherItems] = []) => {
const isMp4 = firstItem?.find((item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1);
const isMp4 = firstItem?.find(
(item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1
);
const isPicture = firstItem?.find(
(item) => (item?.path?.indexOf?.('mp4') ?? -1) === -1
);
if ((firstItem?.length ?? 0) === 0) {
return 'Requires at least one media';
}
if ((firstItem?.length ?? 0) > 5) {
return 'You can only have up to 5 media items';
}
if (isMp4 && firstItem?.length !== 2 && !isPicture) {
return 'If posting a video you have to also include a cover image as second media';
}

View file

@ -29,12 +29,29 @@ const TikTokSettings: FC<{
return value?.[0]?.image?.some((p) => (p?.path?.indexOf?.('mp4') ?? -1) === -1);
}, [value]);
const hasMedia = (value?.[0]?.image?.length ?? 0) > 0;
const isVideo = hasMedia && !isTitle;
const disclose = watch('disclose');
const brand_organic_toggle = watch('brand_organic_toggle');
const brand_content_toggle = watch('brand_content_toggle');
const content_posting_method = watch('content_posting_method');
const isUploadMode = content_posting_method === 'UPLOAD';
const tiktokRestrictionNotice = useMemo(() => {
if (!hasMedia || !isVideo) return null;
if (!isUploadMode) {
return t(
'tiktok_restriction_direct_video',
'TikTok restriction: For direct post with video, your post content is used as the title. A separate title field is not available.'
);
}
return t(
'tiktok_restriction_upload_video',
'TikTok restriction: For upload-only video, TikTok does not accept a title or message. The content will default to "#Postiz" and you can edit it inside the TikTok app before publishing.'
);
}, [hasMedia, isUploadMode, isVideo, t]);
const privacyLevel = [
{
value: 'PUBLIC_TO_EVERYONE',
@ -83,6 +100,25 @@ const TikTokSettings: FC<{
return (
<div className="flex flex-col">
{/*<CheckTikTokValidity picture={props?.values?.[0]?.image?.[0]?.path} />*/}
{tiktokRestrictionNotice && (
<div className="bg-tableBorder p-[10px] mb-[18px] rounded-[10px] flex gap-[10px] items-start text-[13px] text-balance">
<div className="shrink-0 mt-[2px]">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.201 17.6335L14.0026 3.39569C13.7977 3.04687 13.5052 2.75764 13.1541 2.55668C12.803 2.35572 12.4055 2.25 12.001 2.25C11.5965 2.25 11.199 2.35572 10.8479 2.55668C10.4968 2.75764 10.2043 3.04687 9.99944 3.39569L1.80101 17.6335C1.60388 17.9709 1.5 18.3546 1.5 18.7454C1.5 19.1361 1.60388 19.5199 1.80101 19.8572C2.00325 20.2082 2.29523 20.499 2.64697 20.6998C2.99871 20.9006 3.39755 21.0043 3.80257 21.0001H20.1994C20.6041 21.0039 21.0026 20.9001 21.354 20.6993C21.7054 20.4985 21.997 20.2079 22.1991 19.8572C22.3965 19.52 22.5007 19.1364 22.5011 18.7456C22.5014 18.3549 22.3978 17.9711 22.201 17.6335ZM11.251 9.75006C11.251 9.55115 11.33 9.36038 11.4707 9.21973C11.6113 9.07908 11.8021 9.00006 12.001 9.00006C12.1999 9.00006 12.3907 9.07908 12.5313 9.21973C12.672 9.36038 12.751 9.55115 12.751 9.75006V13.5001C12.751 13.699 12.672 13.8897 12.5313 14.0304C12.3907 14.171 12.1999 14.2501 12.001 14.2501C11.8021 14.2501 11.6113 14.171 11.4707 14.0304C11.33 13.8897 11.251 13.699 11.251 13.5001V9.75006ZM12.001 18.0001C11.7785 18.0001 11.561 17.9341 11.376 17.8105C11.191 17.6868 11.0468 17.5111 10.9616 17.3056C10.8765 17.1 10.8542 16.8738 10.8976 16.6556C10.941 16.4374 11.0482 16.2369 11.2055 16.0796C11.3628 15.9222 11.5633 15.8151 11.7815 15.7717C11.9998 15.7283 12.226 15.7505 12.4315 15.8357C12.6371 15.9208 12.8128 16.065 12.9364 16.25C13.06 16.4351 13.126 16.6526 13.126 16.8751C13.126 17.1734 13.0075 17.4596 12.7965 17.6706C12.5855 17.8815 12.2994 18.0001 12.001 18.0001Z"
fill="currentColor"
/>
</svg>
</div>
<div>{tiktokRestrictionNotice}</div>
</div>
)}
{isTitle && <Input label="Title" {...register('title')} maxLength={89} />}
<Select
label={t('label_who_can_see_this_video', 'Who can see this video?')}

View file

@ -92,16 +92,9 @@ export default withProvider({
const premium =
additionalSettings?.find((p: any) => p?.title === 'Verified')?.value ||
false;
if (posts?.some((p) => (p?.length ?? 0) > 4)) {
return 'There can be maximum 4 pictures in a post.';
}
if (
posts?.some(
(p) => p?.some((m) => (m?.path?.indexOf?.('mp4') ?? -1) > -1) && (p?.length ?? 0) > 1
)
) {
return 'There can be maximum 1 video in a post.';
}
// if (posts?.some((p) => (p?.length ?? 0) > 4)) {
// return 'There can be maximum 4 pictures in a post.';
// }
for (const load of posts?.flatMap((p) => p?.flatMap((a) => a?.path)) ?? []) {
if ((load?.indexOf?.('mp4') ?? -1) > -1) {
const isValid = await checkVideoDuration(load, premium);

View file

@ -41,6 +41,7 @@ import { StreakComponent } from '@gitroom/frontend/components/layout/streak.comp
import { PreConditionComponent } from '@gitroom/frontend/components/layout/pre-condition.component';
import { AttachToFeedbackIcon } from '@gitroom/frontend/components/new-layout/sentry.feedback.component';
import { FirstBillingComponent } from '@gitroom/frontend/components/billing/first.billing.component';
import { TrialTracker } from '@gitroom/frontend/components/layout/gtm.component';
const jakartaSans = Plus_Jakarta_Sans({
weight: ['600', '500', '700'],
@ -78,6 +79,7 @@ export const LayoutComponent = ({ children }: { children: ReactNode }) => {
<MantineWrapper>
<ToolTip />
<Toaster />
<TrialTracker />
<CheckPayment check={searchParams.get('check') || ''} mutate={mutate}>
<ShowMediaBoxModal />
<ShowLinkedinCompany />

View file

@ -4,6 +4,7 @@ import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { FC, useCallback, useState } from 'react';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useClickAway } from '@uidotdev/usehooks';
import ReactLoading from '@gitroom/frontend/components/layout/loading';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
@ -26,16 +27,29 @@ export const ShowNotification: FC<{
const [newNotification] = useState(
new Date(notification.createdAt) > new Date(props.lastReadNotification)
);
const createdAt = dayjs(notification.createdAt);
const isWithin24h = dayjs().diff(createdAt, 'hour') < 24;
const fullDate = createdAt.format('MMM D, YYYY h:mm A');
return (
<div
className={clsx(
`text-textColor px-[16px] py-[10px] border-b border-tableBorder last:border-b-0 transition-colors overflow-hidden text-ellipsis`,
`text-textColor px-[16px] py-[10px] border-b border-tableBorder last:border-b-0 transition-colors`,
newNotification && 'font-bold bg-seventh animate-newMessages'
)}
dangerouslySetInnerHTML={{
__html: replaceLinks(notification.content),
}}
/>
>
<div
className="break-words"
dangerouslySetInnerHTML={{
__html: replaceLinks(notification.content),
}}
/>
<div
className="text-[11px] mt-[4px] opacity-60 font-normal"
title={isWithin24h ? fullDate : undefined}
>
{isWithin24h ? createdAt.fromNow() : fullDate}
</div>
</div>
);
};
export const NotificationOpenComponent = () => {
@ -57,7 +71,7 @@ export const NotificationOpenComponent = () => {
{t('notifications', 'Notifications')}
</div>
<div className="flex flex-col">
<div className="flex flex-col max-h-[400px] overflow-y-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor">
{isLoading && (
<div className="flex-1 flex justify-center pt-12">
<ReactLoading type="spin" color="#fff" width={36} height={36} />

View file

@ -8,6 +8,7 @@ import { Toaster } from '@gitroom/react/toaster/toaster';
import { MantineWrapper } from '@gitroom/react/helpers/mantine.wrapper';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { CopilotKit } from '@copilotkit/react-core';
import { ToolTip } from '@gitroom/frontend/components/layout/top.tip';
export const PreviewWrapper = ({ children }: { children: ReactNode }) => {
const fetch = useFetch();
const { backendUrl } = useVariables();
@ -30,6 +31,7 @@ export const PreviewWrapper = ({ children }: { children: ReactNode }) => {
>
<MantineWrapper>
<Toaster />
<ToolTip />
{children}
</MantineWrapper>
</CopilotKit>

View file

@ -4,6 +4,7 @@ import { useFormContext } from 'react-hook-form';
import { useVideo } from '@gitroom/frontend/components/videos/video.context.wrapper';
import { Textarea } from '@gitroom/react/form/textarea';
import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
export interface Voice {
id: string;
@ -46,7 +47,7 @@ const VEO3Settings: FC = () => {
setValue(
'images',
val.target.value
.filter((f) => f.path.indexOf('mp4') === -1)
.filter((f) => !hasExtension(f.path, 'mp4'))
.slice(0, 3)
)
}

View file

@ -24,6 +24,33 @@ import {
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
// Drops fields the workflow and downstream activities never read — biggest wins are `error` (grows per retry) and `childrenPost` (Prisma side-loads it on every recursive row).
function slimPost(post: any) {
if (!post) return post;
const {
error,
childrenPost,
tags,
description,
title,
submittedForOrderId,
submittedForOrganizationId,
submittedForOrder,
submittedForOrganization,
lastMessageId,
parentPostId,
approvedSubmitForOrder,
deletedAt,
createdAt,
updatedAt,
payoutProblems,
comments,
errors,
...rest
} = post;
return rest;
}
@Injectable()
@Activity()
export class PostActivity {
@ -49,7 +76,7 @@ export class PostActivity {
for (const post of list) {
await this._temporalService.client
.getRawClient()
.workflow.signalWithStart('postWorkflowV102', {
.workflow.signalWithStart('postWorkflowV105', {
workflowId: `post_${post.id}`,
taskQueue: 'main',
signal: 'poke',
@ -80,13 +107,28 @@ export class PostActivity {
@ActivityMethod()
async updatePost(id: string, postId: string, releaseURL: string) {
return this._postService.updatePost(id, postId, releaseURL);
await this._postService.updatePost(id, postId, releaseURL);
}
@ActivityMethod()
async getPost(orgId: string, postId: string) {
if (process.env.STRIPE_SECRET_KEY) {
const subscription = await this._subscriptionService.getSubscription(
orgId
);
if (!subscription) {
return false;
}
}
return this._postService.getPostById(postId, orgId);
}
@ActivityMethod()
async getPostsList(orgId: string, postId: string) {
if (process.env.STRIPE_SECRET_KEY) {
const subscription = await this._subscriptionService.getSubscription(orgId);
const subscription = await this._subscriptionService.getSubscription(
orgId
);
if (!subscription) {
return [];
}
@ -101,7 +143,7 @@ export class PostActivity {
return [];
}
return getPosts;
return getPosts.map(slimPost);
}
@ActivityMethod()
@ -159,6 +201,16 @@ export class PostActivity {
@ActivityMethod()
async postSocial(integration: Integration, posts: Post[]) {
if (process.env.STRIPE_SECRET_KEY) {
const subscription = await this._subscriptionService.getSubscription(
integration.organizationId
);
if (!subscription) {
throw new Error('No active subscription found for this organization.');
}
}
const getIntegration = this._integrationManager.getSocialIntegration(
integration.providerIdentifier
);
@ -220,7 +272,7 @@ export class PostActivity {
digest = false,
type: NotificationType = 'success'
) {
return this._notificationService.inAppNotification(
await this._notificationService.inAppNotification(
orgId,
subject,
message,
@ -241,7 +293,7 @@ export class PostActivity {
@ActivityMethod()
async changeState(id: string, state: State, err?: any, body?: any) {
return this._postService.changeState(id, state, err, body);
await this._postService.changeState(id, state, err, body);
}
@ActivityMethod()
@ -266,7 +318,7 @@ export class PostActivity {
);
const post = await this._postService.getPostByForWebhookId(postId);
return Promise.all(
await Promise.all(
webhooks.map(async (webhook) => {
try {
await fetch(webhook.url, {
@ -303,7 +355,7 @@ export class PostActivity {
delay: number;
information: any;
}) {
return this._integrationService.processInternalPlug(data);
await this._integrationService.processInternalPlug(data);
}
@ActivityMethod()
@ -357,10 +409,7 @@ export class PostActivity {
return refresh;
} catch (err) {
await this._refreshIntegrationService.setBetweenSteps(
integration,
cause
);
await this._refreshIntegrationService.setBetweenSteps(integration, cause);
return false;
}
}

View file

@ -1,5 +1,8 @@
export * from './post-workflows/post.workflow.v1.0.1';
export * from './post-workflows/post.workflow.v1.0.2';
export * from './post-workflows/post.workflow.v1.0.3';
export * from './post-workflows/post.workflow.v1.0.4';
export * from './post-workflows/post.workflow.v1.0.5';
export * from './autopost.workflow';
export * from './digest.email.workflow';
export * from './missing.post.workflow';

View file

@ -0,0 +1,430 @@
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 { TypedSearchAttributes } from '@temporalio/common';
import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
const proxyTaskQueue = (taskQueue: string) => {
return proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
taskQueue,
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
};
const {
getPostsList,
inAppNotification,
changeState,
updatePost,
sendWebhooks,
isCommentable,
} = proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
const poke = defineSignal('poke');
const iterate = Array.from({ length: 5 });
export async function postWorkflowV103({
taskQueue,
postId,
organizationId,
postNow = false,
}: {
taskQueue: string;
postId: string;
organizationId: string;
postNow?: boolean;
}) {
// Dynamic task queue, for concurrency
const {
postSocial,
postComment,
getIntegrationById,
refreshTokenWithCause,
internalPlugs,
globalPlugs,
processInternalPlug,
processPlug,
} = proxyTaskQueue(taskQueue);
let poked = false;
setHandler(poke, () => {
poked = true;
});
const startTime = new Date();
// get all the posts and comments to post
const postsListBefore = await getPostsList(organizationId, postId);
const [post] = postsListBefore;
// in case doesn't exists for some reason, fail it
if (!post || (!postNow && post.state !== 'QUEUE')) {
await changeState(
postsListBefore[0].id,
'ERROR',
'Already posted',
postsListBefore
);
return;
}
// if it's a repeatable post, we should ignore this.
if (!postNow) {
await sleep(
dayjs(post.publishDate).isBefore(dayjs())
? 0
: dayjs(post.publishDate).diff(dayjs(), 'millisecond')
);
}
// 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[] = [];
// iterate over the posts
for (let i = 0; i < postsList.length; i++) {
const before = postsResults.length;
// this is a small trick to repeat an action in case of token refresh
for (const _ of iterate) {
try {
// first post the main post
if (i === 0) {
postsResults.push(
...(await postSocial(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)));
}
postsResults.push(
...(await postComment(
postsResults[0].postId,
postsResults.length === 1
? undefined
: postsResults[i - 1].postId,
post.integration,
[postsList[i]]
))
);
}
// mark post as successful
await updatePost(
postsList[i].id,
postsResults[i].postId,
postsResults[i].releaseURL
);
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) {
// if token refresh is needed, do it and repeat
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
post.integration,
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
await changeState(postsList[0].id, 'ERROR', err, postsList);
return false;
}
post.integration.token = refresh.accessToken;
continue;
}
// for other errors, change state and inform the user if needed
await changeState(postsList[0].id, 'ERROR', err, postsList);
// specific case for bad body errors
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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
}${err?.cause?.message ? `: ${err?.cause?.message}` : ``}`,
true,
false,
'fail'
);
return false;
}
}
}
if (postsResults.length === before) {
// all retries exhausted without success
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) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
await getIntegrationById(organizationId, todo.integration),
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
break;
}
continue;
}
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
post.integration,
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
break;
}
continue;
}
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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(postWorkflowV103, {
parentClosePolicy: 'ABANDON',
args: [
{
taskQueue,
postId,
organizationId,
postNow: true,
},
],
workflowId: `post_${post.id}_${makeId(10)}`,
typedSearchAttributes: new TypedSearchAttributes([
{
key: postIdSearchParam,
value: postId,
},
]),
});
}
}
}

View file

@ -0,0 +1,439 @@
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 { TypedSearchAttributes } from '@temporalio/common';
import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
const proxyTaskQueue = (taskQueue: string) => {
return proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
taskQueue,
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
};
const {
getPostsList,
inAppNotification,
changeState,
updatePost,
sendWebhooks,
isCommentable,
} = proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
const poke = defineSignal('poke');
const iterate = Array.from({ length: 5 });
export async function postWorkflowV104({
taskQueue,
postId,
organizationId,
postNow = false,
}: {
taskQueue: string;
postId: string;
organizationId: string;
postNow?: boolean;
}) {
// Dynamic task queue, for concurrency
const {
postSocial,
postComment,
getIntegrationById,
refreshTokenWithCause,
internalPlugs,
globalPlugs,
processInternalPlug,
processPlug,
} = proxyTaskQueue(taskQueue);
let poked = false;
setHandler(poke, () => {
poked = true;
});
const startTime = new Date();
// get all the posts and comments to post
const postsListBefore = await getPostsList(organizationId, postId);
const [post] = postsListBefore;
// in case doesn't exists for some reason, fail it
if (!post) {
await changeState(
postId,
'ERROR',
'No Post'
);
return;
}
if (!postNow && post.state !== 'QUEUE') {
await changeState(
postsListBefore[0].id,
'ERROR',
'Already posted',
postsListBefore
);
return;
}
// if it's a repeatable post, we should ignore this.
if (!postNow) {
await sleep(
dayjs(post.publishDate).isBefore(dayjs())
? 0
: dayjs(post.publishDate).diff(dayjs(), 'millisecond')
);
}
// 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[] = [];
// iterate over the posts
for (let i = 0; i < postsList.length; i++) {
const before = postsResults.length;
// this is a small trick to repeat an action in case of token refresh
for (const _ of iterate) {
try {
// first post the main post
if (i === 0) {
postsResults.push(
...(await postSocial(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)));
}
postsResults.push(
...(await postComment(
postsResults[0].postId,
postsResults.length === 1
? undefined
: postsResults[i - 1].postId,
post.integration,
[postsList[i]]
))
);
}
// mark post as successful
await updatePost(
postsList[i].id,
postsResults[i].postId,
postsResults[i].releaseURL
);
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) {
// if token refresh is needed, do it and repeat
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
post.integration,
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
await changeState(postsList[0].id, 'ERROR', err, postsList);
return false;
}
post.integration.token = refresh.accessToken;
continue;
}
// for other errors, change state and inform the user if needed
await changeState(postsList[0].id, 'ERROR', err, postsList);
// specific case for bad body errors
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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
}${err?.cause?.message ? `: ${err?.cause?.message}` : ``}`,
true,
false,
'fail'
);
return false;
}
}
}
if (postsResults.length === before) {
// all retries exhausted without success
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) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
await getIntegrationById(organizationId, todo.integration),
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
break;
}
continue;
}
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
post.integration,
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
break;
}
continue;
}
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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(postWorkflowV104, {
parentClosePolicy: 'ABANDON',
args: [
{
taskQueue,
postId,
organizationId,
postNow: true,
},
],
workflowId: `post_${post.id}_${makeId(10)}`,
typedSearchAttributes: new TypedSearchAttributes([
{
key: postIdSearchParam,
value: postId,
},
]),
});
}
}
}

View file

@ -0,0 +1,438 @@
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 { TypedSearchAttributes } from '@temporalio/common';
import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
const proxyTaskQueue = (taskQueue: string) => {
return proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
taskQueue,
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
};
const {
getPostsList,
getPost,
inAppNotification,
changeState,
updatePost,
sendWebhooks,
isCommentable,
} = proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
const poke = defineSignal('poke');
const iterate = Array.from({ length: 5 });
export async function postWorkflowV105({
taskQueue,
postId,
organizationId,
postNow = false,
}: {
taskQueue: string;
postId: string;
organizationId: string;
postNow?: boolean;
}) {
// Dynamic task queue, for concurrency
const {
postSocial,
postComment,
getIntegrationById,
refreshTokenWithCause,
internalPlugs,
globalPlugs,
processInternalPlug,
processPlug,
} = proxyTaskQueue(taskQueue);
let poked = false;
setHandler(poke, () => {
poked = true;
});
const startTime = new Date();
// 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;
}
// if it's a repeatable post, we should ignore this.
if (!postNow) {
await sleep(
dayjs(firstPost.publishDate).isBefore(dayjs())
? 0
: dayjs(firstPost.publishDate).diff(dayjs(), 'millisecond')
);
}
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[] = [];
// iterate over the posts
for (let i = 0; i < postsList.length; i++) {
const before = postsResults.length;
// this is a small trick to repeat an action in case of token refresh
for (const _ of iterate) {
try {
// first post the main post
if (i === 0) {
postsResults.push(
...(await postSocial(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)));
}
postsResults.push(
...(await postComment(
postsResults[0].postId,
postsResults.length === 1
? undefined
: postsResults[i - 1].postId,
post.integration,
[postsList[i]]
))
);
}
// mark post as successful
await updatePost(
postsList[i].id,
postsResults[i].postId,
postsResults[i].releaseURL
);
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) {
// if token refresh is needed, do it and repeat
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
post.integration,
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
await changeState(postsList[0].id, 'ERROR', err, postsList);
return false;
}
post.integration.token = refresh.accessToken;
continue;
}
// for other errors, change state and inform the user if needed
await changeState(postsList[0].id, 'ERROR', err, postsList);
// specific case for bad body errors
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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
}${err?.cause?.message ? `: ${err?.cause?.message}` : ``}`,
true,
false,
'fail'
);
return false;
}
}
}
if (postsResults.length === before) {
// all retries exhausted without success
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) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
await getIntegrationById(organizationId, todo.integration),
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
break;
}
continue;
}
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshTokenWithCause(
post.integration,
err?.cause?.message || ''
);
if (!refresh || !refresh.accessToken) {
break;
}
continue;
}
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.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(postWorkflowV105, {
parentClosePolicy: 'ABANDON',
args: [
{
taskQueue,
postId,
organizationId,
postNow: true,
},
],
workflowId: `post_${post.id}_${makeId(10)}`,
typedSearchAttributes: new TypedSearchAttributes([
{
key: postIdSearchParam,
value: postId,
},
]),
});
}
}
}

View file

@ -0,0 +1,10 @@
export const hasExtension = (
path: string | undefined | null,
extension: string
): boolean => {
if (!path) {
return false;
}
const ext = extension.startsWith('.') ? extension : `.${extension}`;
return path.toLowerCase().indexOf(ext.toLowerCase()) > -1;
};

View file

@ -25,6 +25,7 @@ const POST_ITEM_KEYS: Record<string, string> = {
integration: 'n',
intervalInDays: 'iv',
actualDate: 'ad',
creationMethod: 'cm',
};
const INTEGRATION_KEYS: Record<string, string> = {

View file

@ -0,0 +1,17 @@
// Keep this in sync with the URL detection used by the short linking service
const urlRegex = () =>
/(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*))/gm;
export function hasLinks(text?: string | null): boolean {
return !!(text || '').match(urlRegex());
}
export function stripLinks(text?: string | null): string {
return (text || '')
.replace(urlRegex(), '')
// collapse the whitespace / empty anchor leftovers the removed link left behind
.replace(/<a\b[^>]*>\s*<\/a>/gi, '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/ +\n/g, '\n')
.trim();
}

View file

@ -223,7 +223,7 @@ If the tools return errors, you would need to rerun it with the right parameters
})),
},
],
});
}, 'MCP');
finalOutput.push(...output);
}

View file

@ -303,7 +303,7 @@ export class AutopostService {
},
],
})),
});
}, 'AUTOPOST');
}
async updateUrl(state: WorkflowChannelsState) {

View file

@ -40,6 +40,8 @@ import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { AnnouncementsRepository } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.repository';
import { AnnouncementsService } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.service';
import { ErrorsRepository } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.repository';
import { ErrorsService } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.service';
@Global()
@Module({
@ -89,6 +91,8 @@ import { AnnouncementsService } from '@gitroom/nestjs-libraries/database/prisma/
VideoManager,
AnnouncementsRepository,
AnnouncementsService,
ErrorsRepository,
ErrorsService,
],
get exports() {
return this.providers;

View file

@ -0,0 +1,143 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
const UNKNOWN_TOKEN = 'An unknown error occurred';
interface ListErrorsParams {
page?: number;
limit?: number;
platform?: string;
email?: string;
unknownFirst?: boolean;
}
@Injectable()
export class ErrorsRepository {
constructor(private _errors: PrismaRepository<'errors'>) {}
private buildWhere(params: ListErrorsParams) {
const where: any = {};
if (params.platform) {
where.platform = params.platform;
}
if (params.email) {
where.organization = {
users: {
some: {
user: {
email: { contains: params.email, mode: 'insensitive' },
},
},
},
};
}
return where;
}
private get include() {
return {
organization: {
select: {
id: true,
name: true,
users: {
select: {
user: { select: { id: true, email: true, name: true } },
},
},
},
},
post: { select: { id: true, content: true } },
} as const;
}
async listPlatforms() {
const rows = await this._errors.model.errors.findMany({
distinct: ['platform'],
select: { platform: true },
orderBy: { platform: 'asc' },
});
return rows.map((r) => r.platform);
}
async listErrors(params: ListErrorsParams) {
const page = Math.max(0, params.page || 0);
const limit = Math.min(Math.max(1, params.limit || 20), 100);
const skip = page * limit;
const where = this.buildWhere(params);
const include = this.include;
if (!params.unknownFirst) {
const [items, total] = await Promise.all([
this._errors.model.errors.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include,
}),
this._errors.model.errors.count({ where }),
]);
return {
items,
total,
page,
limit,
hasMore: skip + items.length < total,
};
}
const unknownWhere = { ...where, message: { contains: UNKNOWN_TOKEN } };
const knownWhere = {
...where,
NOT: { message: { contains: UNKNOWN_TOKEN } },
};
const [unknownTotal, knownTotal] = await Promise.all([
this._errors.model.errors.count({ where: unknownWhere }),
this._errors.model.errors.count({ where: knownWhere }),
]);
let unknownItems: any[] = [];
let knownItems: any[] = [];
if (skip < unknownTotal) {
const takeUnknown = Math.min(unknownTotal - skip, limit);
unknownItems = await this._errors.model.errors.findMany({
where: unknownWhere,
orderBy: { createdAt: 'desc' },
skip,
take: takeUnknown,
include,
});
const remaining = limit - unknownItems.length;
if (remaining > 0) {
knownItems = await this._errors.model.errors.findMany({
where: knownWhere,
orderBy: { createdAt: 'desc' },
skip: 0,
take: remaining,
include,
});
}
} else {
knownItems = await this._errors.model.errors.findMany({
where: knownWhere,
orderBy: { createdAt: 'desc' },
skip: skip - unknownTotal,
take: limit,
include,
});
}
const items = [...unknownItems, ...knownItems];
const total = unknownTotal + knownTotal;
return {
items,
total,
page,
limit,
hasMore: skip + items.length < total,
};
}
}

View file

@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ErrorsRepository } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.repository';
@Injectable()
export class ErrorsService {
constructor(private _errorsRepository: ErrorsRepository) {}
listErrors(params: {
page?: number;
limit?: number;
platform?: string;
email?: string;
unknownFirst?: boolean;
}) {
return this._errorsRepository.listErrors(params);
}
listPlatforms() {
return this._errorsRepository.listPlatforms();
}
}

View file

@ -1,7 +1,12 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Post as PostBody } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
import { APPROVED_SUBMIT_FOR_ORDER, Post, State } from '@prisma/client';
import {
APPROVED_SUBMIT_FOR_ORDER,
CreationMethod,
Post,
State,
} from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { GetPostsListDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.list.dto';
import dayjs from 'dayjs';
@ -35,10 +40,11 @@ export class PostsRepository {
refreshNeeded: false,
inBetweenSteps: false,
disabled: false,
deletedAt: null,
},
publishDate: {
gte: dayjs.utc().subtract(2, 'hour').toDate(),
lt: dayjs.utc().add(2, 'hour').toDate(),
gte: dayjs.utc().subtract(2, 'day').toDate(),
lt: dayjs.utc().toDate(),
},
state: 'QUEUE',
deletedAt: null,
@ -173,6 +179,7 @@ export class PostsRepository {
state: true,
intervalInDays: true,
group: true,
creationMethod: true,
tags: {
select: {
tag: true,
@ -217,6 +224,26 @@ export class PostsRepository {
const limit = query.limit || 20;
const skip = page * limit;
const stateFilter = query.state || 'all';
const stateAndDate =
stateFilter === 'scheduled'
? {
state: State.QUEUE,
publishDate: { gte: dayjs.utc().toDate() },
}
: stateFilter === 'draft'
? { state: State.DRAFT }
: stateFilter === 'published'
? { state: State.PUBLISHED }
: {
state: {
in: [State.QUEUE, State.DRAFT, State.PUBLISHED, State.ERROR],
},
};
const orderDirection: 'asc' | 'desc' =
stateFilter === 'published' ? 'desc' : 'asc';
const where = {
AND: [
{
@ -226,12 +253,8 @@ export class PostsRepository {
},
],
},
{
publishDate: {
gte: dayjs.utc().toDate(),
},
},
],
...stateAndDate,
deletedAt: null as Date | null,
parentPostId: null as string | null,
intervalInDays: null as number | null,
@ -250,7 +273,7 @@ export class PostsRepository {
skip,
take: limit,
orderBy: {
publishDate: 'asc',
publishDate: orderDirection,
},
select: {
id: true,
@ -260,6 +283,7 @@ export class PostsRepository {
releaseId: true,
state: true,
group: true,
creationMethod: true,
tags: {
select: {
tag: true,
@ -483,6 +507,7 @@ export class PostsRepository {
date: string,
body: PostBody,
tags: { value: string; label: string }[],
creationMethod: CreationMethod,
inter?: number
) {
const posts: Post[] = [];
@ -517,6 +542,7 @@ export class PostsRepository {
group: uuid,
intervalInDays: inter ? +inter : null,
approvedSubmitForOrder: APPROVED_SUBMIT_FOR_ORDER.NO,
...(type === 'create' ? { creationMethod } : {}),
...(state === 'update'
? {}
: {

View file

@ -7,7 +7,14 @@ import { PostsRepository } from '@gitroom/nestjs-libraries/database/prisma/posts
import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto';
import dayjs from 'dayjs';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { Integration, Post, Media, From, State } from '@prisma/client';
import {
Integration,
Post,
Media,
From,
CreationMethod,
State,
} from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { GetPostsListDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.list.dto';
import { shuffle } from 'lodash';
@ -18,7 +25,10 @@ import utc from 'dayjs/plugin/utc';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service';
import { CreateTagDto } from '@gitroom/nestjs-libraries/dtos/posts/create.tag.dto';
import { minifyPostsList, minifyPosts } from '@gitroom/helpers/utils/posts.list.minify';
import {
minifyPostsList,
minifyPosts,
} from '@gitroom/helpers/utils/posts.list.minify';
import axios from 'axios';
import sharp from 'sharp';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
@ -37,6 +47,8 @@ import { timer } from '@gitroom/helpers/utils/timer';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
import { stripLinks } from '@gitroom/helpers/utils/strip.links';
type PostWithConditionals = Post & {
integration?: Integration;
@ -125,6 +137,10 @@ export class PostsService {
return [];
}
async getPostById(postId: string, orgId: string) {
return this._postRepository.getPostById(postId, orgId);
}
async updateReleaseId(orgId: string, postId: string, releaseId: string) {
return this._postRepository.updateReleaseId(postId, orgId, releaseId);
}
@ -359,7 +375,7 @@ export class PostsService {
return m;
}
if (m.path.indexOf('.png') > -1) {
if (hasExtension(m.path, 'png')) {
imageUpdateNeeded = true;
const response = await axios.get(m.url, {
responseType: 'arraybuffer',
@ -706,7 +722,7 @@ export class PostsService {
try {
await this._temporalService.client
.getRawClient()
?.workflow.start('postWorkflowV102', {
?.workflow.start('postWorkflowV105', {
workflowId: `post_${postId}`,
taskQueue: 'main',
workflowIdConflictPolicy: 'TERMINATE_EXISTING',
@ -731,17 +747,31 @@ export class PostsService {
} catch (err) {}
}
async createPost(orgId: string, body: CreatePostDto): Promise<any[]> {
async createPost(
orgId: string,
body: CreatePostDto,
creationMethod: CreationMethod
): Promise<any[]> {
const postList = [];
for (const post of body.posts) {
const provider = this._integrationManager.getSocialIntegration(
(post.settings as any)?.__type
);
const removeLinks = !!provider?.stripLinks?.();
const messages = (post.value || []).map((p) => p.content);
const updateContent = !body.shortLink
? messages
: await this._shortLinkService.convertTextToShortLinks(orgId, messages);
// No point shortlinking links on platforms that strip them out anyway
const updateContent =
!body.shortLink || removeLinks
? messages
: await this._shortLinkService.convertTextToShortLinks(
orgId,
messages
);
post.value = (post.value || []).map((p, i) => ({
...p,
content: updateContent[i],
content: removeLinks ? stripLinks(updateContent[i]) : updateContent[i],
}));
const { posts } = await this._postRepository.createOrUpdatePost(
@ -750,6 +780,7 @@ export class PostsService {
body.type === 'now' ? dayjs().format('YYYY-MM-DDTHH:mm:00') : body.date,
post,
body.tags,
creationMethod,
body.inter
);
@ -830,7 +861,9 @@ export class PostsService {
if (action === 'schedule') {
try {
await this.startWorkflow(
getPostById.integration.providerIdentifier.split('-')[0].toLowerCase(),
getPostById.integration.providerIdentifier
.split('-')[0]
.toLowerCase(),
getPostById.id,
orgId,
getPostById.state === 'DRAFT' ? 'DRAFT' : 'QUEUE'
@ -881,43 +914,47 @@ export class PostsService {
const group = makeId(10);
const randomDate = findTime();
await this.createPost(orgId, {
type: 'draft',
date: randomDate,
order: '',
shortLink: false,
tags: [],
posts: [
{
group,
integration: {
id: integration.id,
},
settings: {
__type: integration.providerIdentifier as any,
title: '',
tags: [],
subreddit: [],
},
value: [
...toPost.list.map((l) => ({
id: '',
content: l.post,
delay: 0,
image: [],
})),
{
id: '',
delay: 0,
content: `Check out the full story here:\n${
body.postId || body.url
}`,
image: [],
await this.createPost(
orgId,
{
type: 'draft',
date: randomDate,
order: '',
shortLink: false,
tags: [],
posts: [
{
group,
integration: {
id: integration.id,
},
],
},
],
});
settings: {
__type: integration.providerIdentifier as any,
title: '',
tags: [],
subreddit: [],
},
value: [
...toPost.list.map((l) => ({
id: '',
content: l.post,
delay: 0,
image: [],
})),
{
id: '',
delay: 0,
content: `Check out the full story here:\n${
body.postId || body.url
}`,
image: [],
},
],
},
],
},
'WEB'
);
}
}
}

View file

@ -409,6 +409,7 @@ model Post {
submittedForOrderId String?
submittedForOrganizationId String?
approvedSubmitForOrder APPROVED_SUBMIT_FOR_ORDER @default(NO)
creationMethod CreationMethod @default(UNKNOWN)
lastMessageId String?
intervalInDays Int?
error String?
@ -436,6 +437,7 @@ model Post {
@@index([submittedForOrderId])
@@index([intervalInDays])
@@index([approvedSubmitForOrder])
@@index([creationMethod])
@@index([lastMessageId])
@@index([createdAt])
@@index([updatedAt])
@ -938,6 +940,15 @@ enum APPROVED_SUBMIT_FOR_ORDER {
YES
}
enum CreationMethod {
UNKNOWN
WEB
MCP
API
AUTOPOST
CLI
}
enum ShortLinkPreference {
ASK
YES

View file

@ -4,9 +4,12 @@ import {
IsNumber,
Min,
Max,
IsIn,
} from 'class-validator';
import { Transform } from 'class-transformer';
export type PostListStateFilter = 'all' | 'scheduled' | 'draft' | 'published';
export class GetPostsListDto {
@IsOptional()
@IsNumber()
@ -24,4 +27,8 @@ export class GetPostsListDto {
@IsOptional()
@IsString()
customer?: string;
@IsOptional()
@IsIn(['all', 'scheduled', 'draft', 'published'])
state?: PostListStateFilter = 'all';
}

View file

@ -27,6 +27,7 @@ import { timer } from '@gitroom/helpers/utils/timer';
import axios from 'axios';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
async function reduceImageBySize(url: string, maxSizeKB = 976) {
try {
@ -266,9 +267,9 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
): Promise<{ embed: any; images: any[] }> {
// Separate images and videos
const imageMedia =
post.media?.filter((p) => p.path.indexOf('mp4') === -1) || [];
post.media?.filter((p) => !hasExtension(p.path, 'mp4')) || [];
const videoMedia =
post.media?.filter((p) => p.path.indexOf('mp4') !== -1) || [];
post.media?.filter((p) => hasExtension(p.path, 'mp4')) || [];
// Upload images
const images = await Promise.all(

View file

@ -42,7 +42,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
).json();
const { application } = await (
await fetch('https://discord.com/api/oauth2/@me', {
await this.fetch('https://discord.com/api/oauth2/@me', {
headers: {
Authorization: `Bearer ${access_token}`,
},
@ -99,7 +99,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
this.checkScopes(this.scopes, scope.split(' '));
const { application } = await (
await fetch('https://discord.com/api/oauth2/@me', {
await this.fetch('https://discord.com/api/oauth2/@me', {
headers: {
Authorization: `Bearer ${access_token}`,
},
@ -120,7 +120,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
@Tool({ description: 'Channels', dataSchema: [] })
async channels(accessToken: string, params: any, id: string) {
const list = await (
await fetch(`https://discord.com/api/guilds/${id}/channels`, {
await this.fetch(`https://discord.com/api/guilds/${id}/channels`, {
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
},
@ -171,7 +171,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
}
const data = await (
await fetch(`https://discord.com/api/channels/${channel}/messages`, {
await this.fetch(`https://discord.com/api/channels/${channel}/messages`, {
method: 'POST',
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
@ -208,7 +208,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
// Create thread if this is the first comment
if (!lastCommentId) {
const { id: threadId } = await (
await fetch(
await this.fetch(
`https://discord.com/api/channels/${channel}/messages/${postId}/threads`,
{
method: 'POST',
@ -224,9 +224,6 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
)
).json();
threadChannel = threadId;
} else {
// Extract thread channel from the last comment's URL or use channel directly
threadChannel = channel;
}
const form = new FormData();
@ -234,7 +231,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
'payload_json',
JSON.stringify({
content: commentPost.message.replace(/\[\[\[(@.*?)]]]/g, (match, p1) => {
return `<${p1}>`;
return `<${p1}>`;
}),
attachments: commentPost.media?.map((p, index) => ({
id: index,
@ -257,7 +254,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
}
const data = await (
await fetch(
await this.fetch(
`https://discord.com/api/channels/${threadChannel}/messages`,
{
method: 'POST',
@ -281,7 +278,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
async changeNickname(id: string, accessToken: string, name: string) {
await (
await fetch(`https://discord.com/api/guilds/${id}/members/@me`, {
await this.fetch(`https://discord.com/api/guilds/${id}/members/@me`, {
method: 'PATCH',
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
@ -305,7 +302,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
integration: Integration
) {
const allRoles = await (
await fetch(`https://discord.com/api/guilds/${id}/roles`, {
await this.fetch(`https://discord.com/api/guilds/${id}/roles`, {
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
'Content-Type': 'application/json',
@ -320,7 +317,7 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
.filter((f: any) => f.name !== '@everyone' && f.name !== '@here');
const list = await (
await fetch(
await this.fetch(
`https://discord.com/api/guilds/${id}/members/search?query=${data.query}`,
{
headers: {
@ -368,4 +365,47 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
}
return `[[[@${idOrHandle.replace('@', '')}]]]`;
}
override handleErrors(
body: string
):
| { type: 'refresh-token' | 'bad-body' | 'retry'; value: string }
| undefined {
if (body.includes('50001')) {
return {
type: 'bad-body',
value: "Bot doesn't have access to this channel",
};
}
if (body.includes('50013')) {
return {
type: 'bad-body',
value: 'Bot lacks permission to send messages in this channel',
};
}
if (body.includes('10003')) {
return {
type: 'bad-body',
value: 'Channel no longer exists',
};
}
if (body.includes('40005')) {
return {
type: 'bad-body',
value: "Attachment exceeds Discord's size limit",
};
}
if (body.includes('20028')) {
return {
type: 'retry',
value: 'Rate limited by Discord',
};
}
return undefined;
}
}

View file

@ -11,6 +11,7 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
import { FacebookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/facebook.dto';
import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto';
import { Integration } from '@prisma/client';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
export class FacebookProvider extends SocialAbstract implements SocialProvider {
identifier = 'facebook';
@ -415,7 +416,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
let finalId = '';
let finalUrl = '';
if ((firstPost?.media?.[0]?.path?.indexOf('mp4') || -2) > -1) {
if (hasExtension(firstPost?.media?.[0]?.path, 'mp4')) {
const {
id: videoId,
permalink_url,

View file

@ -12,6 +12,7 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto';
import { Integration } from '@prisma/client';
import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
@Rules(
"Instagram should have at least one attachment, if it's a story, it can have only one picture"
@ -544,22 +545,21 @@ export class InstagramProvider
(firstPost?.media?.length || 0) > 1 && !isStory
? `&is_carousel_item=true`
: ``;
const mediaType =
m.path.indexOf('.mp4') > -1
? firstPost?.media?.length === 1
? isStory
? `video_url=${m.path}&media_type=STORIES`
: `video_url=${m.path}&media_type=REELS&thumb_offset=${
m?.thumbnailTimestamp || 0
}`
: isStory
const mediaType = hasExtension(m.path, 'mp4')
? firstPost?.media?.length === 1
? isStory
? `video_url=${m.path}&media_type=STORIES`
: `video_url=${m.path}&media_type=VIDEO&thumb_offset=${
: `video_url=${m.path}&media_type=REELS&thumb_offset=${
m?.thumbnailTimestamp || 0
}`
: isStory
? `image_url=${m.path}&media_type=STORIES`
: `image_url=${m.path}`;
? `video_url=${m.path}&media_type=STORIES`
: `video_url=${m.path}&media_type=VIDEO&thumb_offset=${
m?.thumbnailTimestamp || 0
}`
: isStory
? `image_url=${m.path}&media_type=STORIES`
: `image_url=${m.path}`;
const trialParams = isTrialReel
? `&trial_params=${encodeURIComponent(

View file

@ -8,6 +8,7 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import sharp from 'sharp';
import { lookup } from 'mime-types';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Integration } from '@prisma/client';
import { PostPlug } from '@gitroom/helpers/decorators/post.plug';
@ -234,8 +235,8 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
type = 'personal' as 'company' | 'personal'
) {
// Determine the appropriate endpoint based on file type
const isVideo = fileName.indexOf('mp4') > -1;
const isPdf = fileName.toLowerCase().indexOf('pdf') > -1;
const isVideo = hasExtension(fileName, 'mp4');
const isPdf = hasExtension(fileName, 'pdf');
let endpoint: string;
if (isVideo) {
@ -477,15 +478,14 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
}
private async prepareMediaBuffer(mediaUrl: string): Promise<Buffer> {
const isVideo = mediaUrl.indexOf('mp4') > -1;
const isVideo = hasExtension(mediaUrl, 'mp4');
const isGif = lookup(mediaUrl) === 'image/gif';
if (isVideo) {
if (isVideo || isGif) {
return Buffer.from(await readOrFetch(mediaUrl));
}
return await sharp(await readOrFetch(mediaUrl), {
animated: lookup(mediaUrl) === 'image/gif',
})
return await sharp(await readOrFetch(mediaUrl), { animated: false })
.toFormat('jpeg')
.resize({ width: 1000 })
.toBuffer();

View file

@ -10,6 +10,7 @@ import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { MeweDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/mewe.dto';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
export class MeweProvider extends SocialAbstract implements SocialProvider {
identifier = 'mewe';
@ -244,7 +245,7 @@ export class MeweProvider extends SocialAbstract implements SocialProvider {
// Upload photos if present (exclude videos)
const imageMedia =
firstPost.media?.filter((m) => !m.path || m.path.indexOf('mp4') === -1) ||
firstPost.media?.filter((m) => !m.path || !hasExtension(m.path, 'mp4')) ||
[];
const uploadedPhotoIds: string[] = [];

View file

@ -10,13 +10,17 @@ import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provi
import axios from 'axios';
import FormData from 'form-data';
import { timer } from '@gitroom/helpers/utils/timer';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import {
BadBody,
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
@Rules(
'Pinterest requires at least one media, if posting a video, you must have two attachment, one for video, one for the cover picture, When posting a video, there can be only one'
'Pinterest requires at least one media, if posting a video, you must have two attachment, one for video, one for the cover picture, When posting a video, there can be only one, if posting images, there can be maximum 5'
)
export class PinterestProvider
extends SocialAbstract
@ -47,6 +51,12 @@ export class PinterestProvider
value: string;
}
| undefined {
if (body.indexOf('constraint: maxItems=5') > -1) {
return {
type: 'bad-body' as const,
value: 'You can upload a maximum of 5 images per post on Pinterest.',
};
}
if (body.indexOf('cover_image_url or cover_image_content_type') > -1) {
return {
type: 'bad-body' as const,
@ -181,11 +191,11 @@ export class PinterestProvider
postDetails: PostDetails<PinterestSettingsDto>[]
): Promise<PostResponse[]> {
let mediaId = '';
const findMp4 = postDetails?.[0]?.media?.find(
(p) => (p.path?.indexOf('mp4') || -1) > -1
const findMp4 = postDetails?.[0]?.media?.find((p) =>
hasExtension(p.path, 'mp4')
);
const picture = postDetails?.[0]?.media?.find(
(p) => (p.path?.indexOf('mp4') || -1) === -1
(p) => !hasExtension(p.path, 'mp4')
);
if (findMp4) {
@ -236,6 +246,15 @@ export class PinterestProvider
)
).json();
if (mediafile.status === 'failed') {
throw new BadBody(
'pinterest',
JSON.stringify({}),
{} as any,
'The file is corrupted and cannot be uploaded'
);
}
await timer(30000);
statusCode = mediafile.status;
}
@ -279,7 +298,9 @@ export class PinterestProvider
}
: {
source_type: 'multiple_image_urls',
items: mapImages,
items: mapImages.map((m) => ({
url: m.path,
})),
},
}),
})
@ -414,7 +435,9 @@ export class PinterestProvider
result.push({
label: 'Outbound Clicks',
percentageChange: 0,
data: [{ total: String(lifetimeMetrics.OUTBOUND_CLICK), date: today }],
data: [
{ total: String(lifetimeMetrics.OUTBOUND_CLICK), date: today },
],
});
}

View file

@ -14,6 +14,7 @@ import axios from 'axios';
import WebSocket from 'ws';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
import { Integration } from '@prisma/client';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
// @ts-ignore
global.WebSocket = WebSocket;
@ -186,7 +187,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
for (const firstPostSettings of post.settings.subreddit) {
const kind =
firstPostSettings.value.type === 'media'
? post.media[0].path.indexOf('mp4') > -1
? hasExtension(post.media[0].path, 'mp4')
? 'video'
: 'image'
: firstPostSettings.value.type;
@ -211,7 +212,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
accessToken,
post.media[0].path
),
...(post.media[0].path.indexOf('mp4') > -1
...(hasExtension(post.media[0].path, 'mp4')
? {
video_poster_url: await this.uploadFileToReddit(
accessToken,

View file

@ -141,6 +141,7 @@ export interface SocialProvider
identifier: string;
refreshWait?: boolean;
convertToJPEG?: boolean;
stripLinks?: () => boolean;
refreshCron?: boolean;
dto?: any;
maxLength: (additionalSettings?: any) => number;

View file

@ -13,6 +13,7 @@ import { capitalize, chunk } from 'lodash';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { Integration } from '@prisma/client';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
export class ThreadsProvider extends SocialAbstract implements SocialProvider {
identifier = 'threads';
@ -44,6 +45,12 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
return { type: 'refresh-token', value: 'Threads access token expired' };
}
if (body.includes('The media could not be fetched from this URI')) {
return {
type: 'bad-body',
value: 'One of the media URLs is invalid or inaccessible, make sure it\'s being uploaded to Postiz first',
};
}
if (body.includes('text must be at most 500 characters')) {
return {
type: 'bad-body',
@ -188,8 +195,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
isCarouselItem = false,
replyToId?: string
): Promise<string> {
const mediaType =
media.path.indexOf('.mp4') > -1 ? 'video_url' : 'image_url';
const mediaType = hasExtension(media.path, 'mp4')
? 'video_url'
: 'image_url';
const mediaParams = new URLSearchParams({
...(mediaType === 'video_url' ? { video_url: media.path } : {}),
...(mediaType === 'image_url' ? { image_url: media.path } : {}),

View file

@ -12,6 +12,7 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto';
import { timer } from '@gitroom/helpers/utils/timer';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
import { Integration } from '@prisma/client';
import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
@ -146,7 +147,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
return {
type: 'bad-body' as const,
value:
'TikTok limit the maximum of pending posts to 5, TikTok limits you for now, please check your TikTok inbox at your TikTok mobile app and try again later',
'TikTok limits pending posts to 5 within any 24-hour period. Please check your TikTok inbox in the TikTok mobile app and try again after 24 hours.',
};
}
@ -184,7 +185,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
if (body.indexOf('url_ownership_unverified') > -1) {
return {
type: 'bad-body' as const,
value: 'URL ownership not verified, please verify domain ownership',
value: 'You have to upload the picture/video to Postiz when sending a URL',
};
}
@ -457,7 +458,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
}
private buildTikokPostInfoBody(firstPost: PostDetails<TikTokDto>) {
const isPhoto = (firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) === -1;
const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4');
const method = firstPost?.settings?.content_posting_method;
if (method === 'DIRECT_POST') {
@ -507,7 +508,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
}
private buildTikokSourceInfoBody(firstPost: PostDetails<TikTokDto>) {
const isPhoto = (firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) === -1;
const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4');
if (isPhoto) {
return {
@ -545,7 +546,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
integration: Integration
): Promise<PostResponse[]> {
const [firstPost] = postDetails;
const isPhoto = (firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) === -1;
const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4');
console.log({
...this.buildTikokPostInfoBody(firstPost),
@ -557,7 +558,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
await this.fetch(
`https://open.tiktokapis.com/v2/post/publish${this.postingMethod(
firstPost.settings.content_posting_method,
(firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) === -1
!hasExtension(firstPost?.media?.[0]?.path, 'mp4')
)}`,
{
method: 'POST',

View file

@ -12,6 +12,7 @@ import axios from 'axios';
import FormDataNew from 'form-data';
import mime from 'mime-types';
import { Integration } from '@prisma/client';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
export class VkProvider extends SocialAbstract implements SocialProvider {
override maxConcurrentJob = 2; // VK has moderate API limits
@ -168,7 +169,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
(post?.media || []).map(async (media) => {
const all = await (
await this.fetch(
media.path.indexOf('mp4') > -1
hasExtension(media.path, 'mp4')
? `https://api.vk.com/method/video.save?access_token=${accessToken}&v=5.251`
: `https://api.vk.com/method/photos.getWallUploadServer?owner_id=${userId}&access_token=${accessToken}&v=5.251`
)
@ -193,7 +194,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider {
})
).data;
if (media.path.indexOf('mp4') > -1) {
if (hasExtension(media.path, 'mp4')) {
return {
id: all.response.video_id,
type: 'video',

View file

@ -1,4 +1,5 @@
import { TweetV2, TwitterApi } from 'twitter-api-v2';
import { createHmac, randomBytes } from 'crypto';
import {
AnalyticsData,
AuthTokenDetails,
@ -17,8 +18,10 @@ import { PostPlug } from '@gitroom/helpers/decorators/post.plug';
import dayjs from 'dayjs';
import { uniqBy } from 'lodash';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { stripLinks as removeLinks } from '@gitroom/helpers/utils/strip.links';
import { XDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/x.dto';
import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
@Rules(
'X can have maximum 4 pictures, or maximum one video, it can also be without attachments'
@ -28,6 +31,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
name = 'X';
isBetweenSteps = false;
scopes = [] as string[];
stripLinks = () => !!process.env.STRIP_LINKS_FROM_X_POSTS;
override maxConcurrentJob = 1; // X has strict rate limits (300 posts per 3 hours)
toolTip =
'You will be logged in into your current account, if you would like a different account, change it first on X';
@ -45,6 +49,24 @@ export class XProvider extends SocialAbstract implements SocialProvider {
value: string;
}
| undefined {
if (body.includes('You are not permitted to perform this action')) {
return {
type: 'bad-body',
value: 'There is a problem posting, please edit your post and check character count and media attachments',
}
}
if (body.includes('maximum of one cashtag')) {
return {
type: 'bad-body',
value: 'There can be maximum of one cashtag ($SYMBOL) per post',
};
}
if (body.includes('maximum of 4 items')) {
return {
type: 'bad-body',
value: 'There must be a maximum of 4 items per post',
};
}
if (body.includes('Unsupported Authentication')) {
return {
type: 'refresh-token',
@ -208,8 +230,9 @@ export class XProvider extends SocialAbstract implements SocialProvider {
) {
await timer(2000);
const plugText = stripHtmlValidation('normal', fields.post, true);
await client.v2.tweet({
text: stripHtmlValidation('normal', fields.post, true),
text: this.stripLinks() ? removeLinks(plugText) : plugText,
reply: { in_reply_to_tweet_id: id },
});
return true;
@ -308,6 +331,54 @@ export class XProvider extends SocialAbstract implements SocialProvider {
});
}
private signOAuth1(
method: string,
url: string,
accessToken: string,
accessSecret: string
): string {
const pct = (s: string) =>
encodeURIComponent(s)
.replace(/!/g, '%21')
.replace(/\*/g, '%2A')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29');
const params: Record<string, string> = {
oauth_consumer_key: process.env.X_API_KEY!,
oauth_nonce: randomBytes(16).toString('hex'),
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: String(Math.floor(Date.now() / 1000)),
oauth_token: accessToken,
oauth_version: '1.0',
};
const paramString = Object.keys(params)
.sort()
.map((k) => `${pct(k)}=${pct(params[k])}`)
.join('&');
const baseString = [
method.toUpperCase(),
pct(url.split('?')[0]),
pct(paramString),
].join('&');
const signingKey = `${pct(process.env.X_API_SECRET!)}&${pct(accessSecret)}`;
params.oauth_signature = createHmac('sha1', signingKey)
.update(baseString)
.digest('base64');
return (
'OAuth ' +
Object.keys(params)
.sort()
.map((k) => `${pct(k)}="${pct(params[k])}"`)
.join(', ')
);
}
private async uploadMedia(
client: TwitterApi,
postDetails: PostDetails<any>[]
@ -320,7 +391,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
id: await this.runInConcurrent(
async () =>
client.v2.uploadMedia(
m.path.indexOf('mp4') > -1
hasExtension(m.path, 'mp4')
? Buffer.from(await readOrFetch(m.path))
: await sharp(await readOrFetch(m.path), {
animated: lookup(m.path) === 'image/gif',
@ -370,6 +441,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
paid_partnership?: boolean;
}>[]
): Promise<PostResponse[]> {
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
const client = await this.getClient(accessToken);
const {
data: { username },
@ -386,30 +458,45 @@ export class XProvider extends SocialAbstract implements SocialProvider {
const media_ids = (uploadAll[firstPost.id] || []).filter((f) => f);
// @ts-ignore
const { data }: { data: { id: string } } = await this.runInConcurrent(
async () =>
// @ts-ignore
client.v2.tweet({
...(!firstPost?.settings?.who_can_reply_post ||
firstPost?.settings?.who_can_reply_post === 'everyone'
? {}
: {
reply_settings: firstPost?.settings?.who_can_reply_post,
}),
...(firstPost?.settings?.community
? {
share_with_followers: true,
community_id:
firstPost?.settings?.community?.split('/').pop() || '',
}
: {}),
text: firstPost.message,
...(media_ids.length ? { media: { media_ids } } : {}),
made_with_ai: !!firstPost?.settings?.made_with_ai,
paid_partnership: !!firstPost?.settings?.paid_partnership,
})
);
const tweetUrl = 'https://api.x.com/2/tweets';
const tweetBody = {
...(!firstPost?.settings?.who_can_reply_post ||
firstPost?.settings?.who_can_reply_post === 'everyone'
? {}
: {
reply_settings: firstPost?.settings?.who_can_reply_post,
}),
...(firstPost?.settings?.community
? {
share_with_followers: true,
community_id:
firstPost?.settings?.community?.split('/').pop() || '',
}
: {}),
text: this.stripLinks()
? removeLinks(firstPost.message)
: firstPost.message,
...(media_ids.length ? { media: { media_ids } } : {}),
made_with_ai: !!firstPost?.settings?.made_with_ai,
paid_partnership: !!firstPost?.settings?.paid_partnership,
};
const tweetResponse = await this.fetch(tweetUrl, {
method: 'POST',
headers: {
Authorization: this.signOAuth1(
'POST',
tweetUrl,
accessTokenSplit,
accessSecretSplit
),
'Content-Type': 'application/json',
},
body: JSON.stringify(tweetBody),
});
const { data } = (await tweetResponse.json()) as {
data: { id: string };
};
return [
{
@ -434,6 +521,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}>[],
integration: Integration
): Promise<PostResponse[]> {
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
const client = await this.getClient(accessToken);
const {
data: { username },
@ -452,18 +540,33 @@ export class XProvider extends SocialAbstract implements SocialProvider {
const replyToId = lastCommentId || postId;
// @ts-ignore
const { data }: { data: { id: string } } = await this.runInConcurrent(
async () =>
// @ts-ignore
client.v2.tweet({
text: commentPost.message,
...(media_ids.length ? { media: { media_ids } } : {}),
reply: { in_reply_to_tweet_id: replyToId },
made_with_ai: !!commentPost?.settings?.made_with_ai,
paid_partnership: !!commentPost?.settings?.paid_partnership,
})
);
const tweetUrl = 'https://api.x.com/2/tweets';
const tweetBody = {
text: this.stripLinks()
? removeLinks(commentPost.message)
: commentPost.message,
...(media_ids.length ? { media: { media_ids } } : {}),
reply: { in_reply_to_tweet_id: replyToId },
made_with_ai: !!commentPost?.settings?.made_with_ai,
paid_partnership: !!commentPost?.settings?.paid_partnership,
};
const tweetResponse = await this.fetch(tweetUrl, {
method: 'POST',
headers: {
Authorization: this.signOAuth1(
'POST',
tweetUrl,
accessTokenSplit,
accessSecretSplit
),
'Content-Type': 'application/json',
},
body: JSON.stringify(tweetBody),
});
const { data } = (await tweetResponse.json()) as {
data: { id: string };
};
return [
{

View file

@ -71,10 +71,7 @@ export class StripeService {
currency: 'usd',
payment_method: latestMethod.id,
customer: event.data.object.customer as string,
automatic_payment_methods: {
allow_redirects: 'never',
enabled: true,
},
off_session: true,
capture_method: 'manual', // Authorize without capturing
confirm: true, // Confirm the PaymentIntent
});

View file

@ -29,6 +29,8 @@ interface VariableContextInterface {
transloadit: string[];
sentryDsn: string;
extensionId: string;
googleAdsId?: string;
googleAdsTrialTracking?: string;
}
const VariableContext = createContext({
stripeClient: '',
@ -36,6 +38,8 @@ const VariableContext = createContext({
isGeneral: true,
genericOauth: false,
oauthLogoUrl: '',
googleAdsId: '',
googleAdsTrialTracking: '',
oauthDisplayName: '',
mcpUrl: '',
cloudflareUrl: '',

View file

@ -1,5 +1,6 @@
import { FC } from 'react';
import { clsx } from 'clsx';
import { hasExtension } from '@gitroom/helpers/utils/has.extension';
export const VideoOrImage: FC<{
src: string;
autoplay: boolean;
@ -8,7 +9,7 @@ export const VideoOrImage: FC<{
videoClassName?: string;
}> = (props) => {
const { src, autoplay, isContain, imageClassName, videoClassName } = props;
if (src?.indexOf('mp4') > -1) {
if (hasExtension(src, 'mp4')) {
return (
<video
src={src}

View file

@ -68,14 +68,14 @@
"@meronex/icons": "^4.0.0",
"@modelcontextprotocol/sdk": "^1.22.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs/cli": "10.0.2",
"@nestjs/common": "^10.0.2",
"@nestjs/core": "^10.0.2",
"@nestjs/microservices": "^10.3.1",
"@nestjs/platform-express": "^10.0.2",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^6.3.0",
"@nestjs/cli": "^11.0.21",
"@nestjs/common": "^11.1.21",
"@nestjs/core": "^11.1.21",
"@nestjs/microservices": "^11.1.21",
"@nestjs/platform-express": "^11.1.21",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.3",
"@nestjs/throttler": "^6.5.0",
"@neynar/nodejs-sdk": "^3.112.0",
"@neynar/react": "^1.2.22",
"@pigment-css/react": "^0.0.30",
@ -189,7 +189,7 @@
"nestjs-command": "^3.1.4",
"nestjs-real-ip": "^3.0.1",
"nestjs-temporal-core": "^3.2.0",
"next": "16.2.1",
"next": "16.2.6",
"next-plausible": "^3.12.0",
"node-fetch": "^3.3.2",
"node-telegram-bot-api": "^0.66.0",
@ -217,7 +217,7 @@
"react-use-cookie": "^1.6.1",
"react-use-keypress": "^1.3.1",
"redis": "^4.6.12",
"reflect-metadata": "^0.1.13",
"reflect-metadata": "^0.2.2",
"remove-markdown": "^0.5.0",
"resend": "^3.2.0",
"rss-parser": "^3.13.0",
@ -256,8 +256,8 @@
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.32",
"@nestjs/schematics": "^10.0.1",
"@nestjs/testing": "^10.0.2",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.21",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
"@svgr/webpack": "^8.0.1",
"@swc-node/register": "1.9.2",
@ -286,7 +286,7 @@
"babel-jest": "29.7.0",
"cross-env": "^10.0.0",
"eslint": "8.57.0",
"eslint-config-next": "16.2.1",
"eslint-config-next": "16.2.6",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-jsx-a11y": "6.7.1",
@ -321,7 +321,7 @@
},
"pnpm": {
"overrides": {
"next": "16.2.1",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"@types/react": "19.1.8",

1407
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff