Compare commits
104 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09088a5391 | ||
|
|
faeb89853b | ||
|
|
6fc51da7e3 | ||
|
|
415c9c4ba8 | ||
|
|
e19c855da6 | ||
|
|
7e0bb7075e | ||
|
|
0b3328daeb | ||
|
|
4811741e63 | ||
|
|
7dda2812d7 | ||
|
|
2316a45388 | ||
|
|
38b0ac8c70 | ||
|
|
17fa64726c | ||
|
|
03ddef66e2 | ||
|
|
0dce16029e | ||
|
|
03aa6b13dd | ||
|
|
630602858e | ||
|
|
715d3e40fd | ||
|
|
e63d6d2cf2 | ||
|
|
f2ebadab9e | ||
|
|
1677714670 | ||
|
|
b4635f026b | ||
|
|
5f2f5581b2 | ||
|
|
7cc3d9bd78 | ||
|
|
d2c1eabc8b | ||
|
|
4ee5231cb2 | ||
|
|
16abf0dc9a | ||
|
|
86368d7b7b | ||
|
|
e986d9e493 | ||
|
|
aa0c16b648 | ||
|
|
510f396389 | ||
|
|
80b6bdcabe | ||
|
|
e153ab0a9b | ||
|
|
cf0ab36a23 | ||
|
|
009bd36528 | ||
|
|
905392513f | ||
|
|
7be292094a | ||
|
|
6e55eb3b92 | ||
|
|
39f2a176e1 | ||
|
|
638b071283 | ||
|
|
060c77a68c | ||
|
|
d4405906bd | ||
|
|
c8f1074f48 | ||
|
|
dcb1b0188a | ||
|
|
22f436e72e | ||
|
|
53f0967e67 | ||
|
|
18a1a80871 | ||
|
|
a6967c8519 | ||
|
|
7e92764ad2 | ||
|
|
1bf32426c7 | ||
|
|
779764aa5d | ||
|
|
232ebb2528 | ||
|
|
d056225053 | ||
|
|
e419e05f09 | ||
|
|
47ce014204 | ||
|
|
9d14b0262d | ||
|
|
971042a074 | ||
|
|
c3976e554f | ||
|
|
ef111eb1c4 | ||
|
|
3ee35a7348 | ||
|
|
d6bc6eb0ff | ||
|
|
0d98fc02fb | ||
|
|
7264c00298 | ||
|
|
bb7cd46a4f | ||
|
|
7236213ea4 | ||
|
|
4bdcfec3d7 | ||
|
|
cdcf63bf6b | ||
|
|
fd6553196b | ||
|
|
90b2581048 | ||
|
|
4e7864c929 | ||
|
|
b91ffdc9c3 | ||
|
|
d75662b56a | ||
|
|
876be6f8c6 | ||
|
|
071143dcb0 | ||
|
|
da448012dd | ||
|
|
e51cae1614 | ||
|
|
fa5d7f4c40 | ||
|
|
0b554e6844 | ||
|
|
8951289426 | ||
|
|
ec4759e934 | ||
|
|
c61e061145 | ||
|
|
8cfb634b66 | ||
|
|
55a542485a | ||
|
|
0eddfb3304 | ||
|
|
88006a7614 | ||
|
|
45e55c545d | ||
|
|
0a8fa5bff6 | ||
|
|
65d23707ab | ||
|
|
846954f059 | ||
|
|
c79965718f | ||
|
|
027c9caa96 | ||
|
|
8a7e8eb8f3 | ||
|
|
71b2e2e793 | ||
|
|
2ae293916b | ||
|
|
e5947034ab | ||
|
|
0ecca5298d | ||
|
|
7d9b99abf3 | ||
|
|
45bdf128e9 | ||
|
|
4e277ed32d | ||
|
|
5257f2fabe | ||
|
|
386fc7b049 | ||
|
|
ec8c0f6fb9 | ||
|
|
eb0334d96d | ||
|
|
288a4d428b | ||
|
|
1145e51ea6 |
119 changed files with 5133 additions and 1349 deletions
|
|
@ -40,6 +40,7 @@ STORAGE_PROVIDER="local"
|
|||
#NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY=""
|
||||
|
||||
# Social Media API Settings
|
||||
X_URL=""
|
||||
X_API_KEY=""
|
||||
X_API_SECRET=""
|
||||
LINKEDIN_CLIENT_ID=""
|
||||
|
|
|
|||
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
.github/workflows/build.yml
vendored
22
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
38
.github/workflows/pr-docker-build.yml
vendored
38
.github/workflows/pr-docker-build.yml
vendored
|
|
@ -1,38 +0,0 @@
|
|||
name: Build and Publish PR Docker Image
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment:
|
||||
name: build-pr
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set image tag
|
||||
id: vars
|
||||
run: echo "IMAGE_TAG=ghcr.io/gitroomhq/postiz-app-pr:${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Docker image from Dockerfile.dev
|
||||
run: docker build -f Dockerfile.dev -t $IMAGE_TAG .
|
||||
|
||||
- name: Push Docker image to GHCR
|
||||
run: docker push $IMAGE_TAG
|
||||
52
.github/workflows/pr-quality.yml
vendored
52
.github/workflows/pr-quality.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
41
SECURITY.md
41
SECURITY.md
|
|
@ -4,6 +4,24 @@
|
|||
|
||||
The Postiz app is committed to ensuring the security and integrity of our users' data. This security policy outlines our procedures for handling security vulnerabilities and our disclosure policy.
|
||||
|
||||
## Scope
|
||||
|
||||
We, at Postiz (gitroomhq), cover the following scopes for vulnerability disclosures:
|
||||
|
||||
- The core repository for `postiz-app` (github.com/gitroomhq/postiz-app)
|
||||
- All `gitroomhq` repositories that are official components, tooling, or integrations of Postiz
|
||||
- Official Postiz container images published under `gitroomhq` on GHCR
|
||||
- Official Postiz CLI tools and NPM packages (NPM org: @postiz)
|
||||
- Postiz-Cloud related infrastructure & services. (API, Frontend, Configurations etc.)
|
||||
- Plugins for Postiz maintained within the `gitroomhq` organization
|
||||
|
||||
Vulnerabilities in third-party dependencies or user-hosted infrastructure are outside of this scope.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
This project currently only supports the latest release. We recommend that users always use the latest version of the Postiz app to ensure they have the latest security patches.
|
||||
*CVE IDs will only be assigned to vulnerabilities affecting currently supported versions.*
|
||||
|
||||
## Reporting Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability in the Postiz app, please report it through the [GitHub Security Advisory system](https://github.com/gitroomhq/postiz-app/security/advisories/new).
|
||||
|
|
@ -11,26 +29,23 @@ If you discover a security vulnerability in the Postiz app, please report it thr
|
|||
When reporting a security vulnerability, please provide as much detail as possible, including:
|
||||
|
||||
- A clear description of the vulnerability
|
||||
- Proof of Concept
|
||||
- Proof of concept (PoC), where possible
|
||||
- Steps to reproduce the vulnerability
|
||||
- Any relevant code or configuration files
|
||||
|
||||
If the report has immidiate urgency, please contact one (or more) of the maintainers via email:
|
||||
If the report has immediate urgency, please contact one (or more) of the maintainers via email:
|
||||
|
||||
- @egelhaus ([E-Mail](mailto:egelhaus@ennogelhaus.de))
|
||||
- @nevo-david ([E-Mail](mailto:nevo@postiz.com))
|
||||
|
||||
### AI Reports
|
||||
We do not evaluate or support security reports generated by LLMs (Large-Language Models / AI). Any report that seems to be generated by AI will be instantly closed on sight by one of our maintainers.
|
||||
However, if the AI report has been closely evaluated by human oversight, and provides a PoC (Proof of Concept) and a reproduction guide, with potential Impact for Postiz, we may evaluate your report like human-generated reports
|
||||
|
||||
## Supported Versions
|
||||
Reports that appear to be LLM-generated without meaningful human analysis — typically lacking a working proof of concept, reproducible steps, or accurate impact assessment — will be closed without detailed response.
|
||||
|
||||
This project currently only supports the latest release. We recommend that users always use the latest version of the Postiz app to ensure they have the latest security patches.
|
||||
Reports that include AI-assisted analysis are welcome provided they have been validated by the reporter and include a proof of concept, reproduction steps, and impact assessment.
|
||||
|
||||
## Disclosure Guidelines
|
||||
|
||||
We follow a private disclosure policy. If you discover a security vulnerability, please report it to us privately via email to one of the maintainers listed above. We will respond promptly to reports of vulnerabilities and work to resolve them as quickly as possible.
|
||||
We follow a private disclosure policy. If you discover a security vulnerability, please report it to us privately via GitHub Security Advisories, and if immediate urgency, via email as listed above. We will respond promptly to reports of vulnerabilities and work to resolve them as quickly as possible.
|
||||
|
||||
We will not publicly disclose security vulnerabilities until a patch or fix is available to prevent malicious actors from exploiting the vulnerability before a fix is released.
|
||||
|
||||
|
|
@ -42,3 +57,13 @@ We take security vulnerabilities seriously and will respond promptly to reports
|
|||
- Developing a patch or fix for the vulnerability.
|
||||
- Releasing the patch or fix as soon as possible.
|
||||
- Notifying users of the vulnerability and the patch or fix.
|
||||
|
||||
## Response Timelines
|
||||
|
||||
We aim to follow these timelines:
|
||||
|
||||
- **Initial Acknowledgment:** Within 72 hours of initial report.
|
||||
- **Completed Triage / Verification:** Within 7 days of initial acknowledgment.
|
||||
- **Critical Issue Remediation:** Within 90 days of completed triage.
|
||||
- **Non-Critical Issue Remediation:** Within 180 days of completed triage.
|
||||
- **CVE Publication:** Within 24 hours of remediation release.
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
47
apps/backend/src/api/routes/admin.controller.ts
Normal file
47
apps/backend/src/api/routes/admin.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -199,6 +199,19 @@ export class AuthController {
|
|||
};
|
||||
}
|
||||
|
||||
@Get('/oauth-mobile-callback')
|
||||
mobileCallback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Res({ passthrough: false }) response: Response
|
||||
) {
|
||||
const scheme = process.env.MOBILE_APP_SCHEME || 'postiz://auth/callback';
|
||||
const params = new URLSearchParams();
|
||||
if (code) params.set('code', code);
|
||||
if (state) params.set('state', state);
|
||||
return response.redirect(302, `${scheme}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@Get('/oauth/:provider')
|
||||
async oauthLink(@Param('provider') provider: string, @Query() query: any) {
|
||||
return this._authService.oauthLink(provider, query);
|
||||
|
|
@ -210,7 +223,10 @@ export class AuthController {
|
|||
@Body('datafast_visitor_id') datafast_visitor_id: string,
|
||||
@Res({ passthrough: false }) response: Response
|
||||
) {
|
||||
const activate = await this._authService.activate(code, datafast_visitor_id);
|
||||
const activate = await this._authService.activate(
|
||||
code,
|
||||
datafast_visitor_id
|
||||
);
|
||||
if (!activate) {
|
||||
return response.status(200).json({ can: false });
|
||||
}
|
||||
|
|
@ -254,10 +270,15 @@ export class AuthController {
|
|||
@Post('/oauth/:provider/exists')
|
||||
async oauthExists(
|
||||
@Body('code') code: string,
|
||||
@Body('redirect_uri') redirect_uri: string,
|
||||
@Param('provider') provider: string,
|
||||
@Res({ passthrough: false }) response: Response
|
||||
) {
|
||||
const { jwt, token } = await this._authService.checkExists(provider, code);
|
||||
const { jwt, token } = await this._authService.checkExists(
|
||||
provider,
|
||||
code,
|
||||
redirect_uri
|
||||
);
|
||||
|
||||
if (token) {
|
||||
return response.json({ token });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -195,6 +196,7 @@ export class IntegrationsController {
|
|||
@Param('integration') integration: string,
|
||||
@Query('refresh') refresh: string,
|
||||
@Query('externalUrl') externalUrl: string,
|
||||
@Query('redirectUrl') redirectUrl: string,
|
||||
@Query('onboarding') onboarding: string,
|
||||
@GetOrgFromRequest() org: Organization
|
||||
) {
|
||||
|
|
@ -232,6 +234,10 @@ export class IntegrationsController {
|
|||
await ioRedis.set(`onboarding:${state}`, 'true', 'EX', 3600);
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
await ioRedis.set(`redirect:${state}`, redirectUrl, 'EX', 3600);
|
||||
}
|
||||
|
||||
await ioRedis.set(`organization:${state}`, org.id, 'EX', 3600);
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 3600);
|
||||
await ioRedis.set(
|
||||
|
|
@ -448,9 +454,7 @@ export class IntegrationsController {
|
|||
}
|
||||
|
||||
@Post('/moltbook/register')
|
||||
async moltbookRegister(
|
||||
@Body() body: { name: string; description: string }
|
||||
) {
|
||||
async moltbookRegister(@Body() body: { name: string; description: string }) {
|
||||
try {
|
||||
const provider = new MoltbookProvider();
|
||||
const result = await provider.registerAgent(body.name, body.description);
|
||||
|
|
|
|||
|
|
@ -181,9 +181,10 @@ export class MediaController {
|
|||
@Get('/')
|
||||
getMedia(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Query('page') page: number
|
||||
@Query('page') page: number,
|
||||
@Query('search') search?: string
|
||||
) {
|
||||
return this._mediaService.getMedia(org.id, page);
|
||||
return this._mediaService.getMedia(org.id, page, search);
|
||||
}
|
||||
|
||||
@Get('/video-options')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { Readable, pipeline } from 'stream';
|
|||
import { promisify } from 'util';
|
||||
import { OnlyURL } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto';
|
||||
import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator';
|
||||
import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
|
||||
|
||||
const pump = promisify(pipeline);
|
||||
|
||||
|
|
@ -191,6 +192,8 @@ export class PublicController {
|
|||
r = await fetch(currentUrl, {
|
||||
signal: ac.signal,
|
||||
redirect: 'manual',
|
||||
// @ts-ignore — undici option, not in lib.dom fetch types
|
||||
dispatcher: ssrfSafeDispatcher,
|
||||
});
|
||||
|
||||
if (r.status >= 300 && r.status < 400) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ async function start() {
|
|||
allowedHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'auth',
|
||||
'showorg',
|
||||
'impersonate',
|
||||
'x-copilotkit-runtime-client-gql-version',
|
||||
],
|
||||
exposedHeaders: [
|
||||
|
|
@ -52,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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
|
||||
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
|
||||
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
|
||||
import { ChangePostStatusDto } from '@gitroom/nestjs-libraries/dtos/posts/change.post.status.dto';
|
||||
import {
|
||||
AuthorizationActions,
|
||||
Sections,
|
||||
|
|
@ -32,8 +33,8 @@ import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.fu
|
|||
import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto';
|
||||
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
|
||||
import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto';
|
||||
import axios from 'axios';
|
||||
import { Readable } from 'stream';
|
||||
import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { fromBuffer } = require('file-type');
|
||||
|
||||
|
|
@ -48,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';
|
||||
|
|
@ -95,11 +99,14 @@ export class PublicIntegrationsController {
|
|||
@Body() body: UploadDto
|
||||
) {
|
||||
Sentry.metrics.count('public_api-request', 1);
|
||||
const response = await axios.get(body.url, {
|
||||
responseType: 'arraybuffer',
|
||||
const response = await fetch(body.url, {
|
||||
// @ts-ignore — undici option, not in lib.dom fetch types
|
||||
dispatcher: ssrfSafeDispatcher,
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(response.data);
|
||||
if (!response.ok) {
|
||||
throw new HttpException({ msg: 'Failed to fetch URL' }, 400);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const detected = await fromBuffer(buffer);
|
||||
if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) {
|
||||
throw new HttpException({ msg: 'Unsupported file type.' }, 400);
|
||||
|
|
@ -163,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')
|
||||
|
|
@ -234,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
|
||||
);
|
||||
}
|
||||
|
|
@ -358,6 +392,16 @@ export class PublicIntegrationsController {
|
|||
return this._postsService.getMissingContent(org.id, id);
|
||||
}
|
||||
|
||||
@Put('/posts/:id/status')
|
||||
async changePostStatus(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ChangePostStatusDto
|
||||
) {
|
||||
Sentry.metrics.count('public_api-request', 1);
|
||||
return this._postsService.changePostStatus(org.id, id, body.status);
|
||||
}
|
||||
|
||||
@Put('/posts/:id/release-id')
|
||||
async updateReleaseId(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -290,9 +293,9 @@ export class AuthService {
|
|||
return providerInstance.generateLink(query);
|
||||
}
|
||||
|
||||
async checkExists(provider: string, code: string) {
|
||||
async checkExists(provider: string, code: string, redirectUri?: string) {
|
||||
const providerInstance = this._providerManager.getProvider(provider);
|
||||
const token = await providerInstance.getToken(code);
|
||||
const token = await providerInstance.getToken(code, redirectUri);
|
||||
const user = await providerInstance.getUser(token);
|
||||
if (!user) {
|
||||
throw new Error('Invalid user');
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||
|
||||
export abstract class AuthProviderAbstract {
|
||||
abstract generateLink(query?: any): Promise<string> | string;
|
||||
abstract getToken(code: string): Promise<string>;
|
||||
abstract getToken(code: string, redirectUri?: string): Promise<string>;
|
||||
abstract getUser(
|
||||
providerToken: string
|
||||
): Promise<{ email: string; id: string }> | false;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class FarcasterProvider extends AuthProviderAbstract {
|
|||
return '';
|
||||
}
|
||||
|
||||
async getToken(code: string) {
|
||||
async getToken(code: string, _redirectUri?: string) {
|
||||
const data = JSON.parse(Buffer.from(code, 'base64').toString());
|
||||
const status = await client.lookupSigner({ signerUuid: data.signer_uuid });
|
||||
if (status.status === 'approved') {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export class GithubProvider extends AuthProviderAbstract {
|
|||
)}`;
|
||||
}
|
||||
|
||||
async getToken(code: string): Promise<string> {
|
||||
async getToken(code: string, _redirectUri?: string): Promise<string> {
|
||||
const { access_token } = await (
|
||||
await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -1,48 +1,28 @@
|
|||
import { google } from 'googleapis';
|
||||
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
|
||||
import {
|
||||
AuthProvider,
|
||||
AuthProviderAbstract,
|
||||
} from '@gitroom/backend/services/auth/providers.interface';
|
||||
|
||||
const clientAndYoutube = () => {
|
||||
const client = new google.auth.OAuth2({
|
||||
const defaultRedirect = () =>
|
||||
`${process.env.FRONTEND_URL}/integrations/social/youtube`;
|
||||
|
||||
const makeClient = (redirectUri: string) =>
|
||||
new google.auth.OAuth2({
|
||||
clientId: process.env.YOUTUBE_CLIENT_ID,
|
||||
clientSecret: process.env.YOUTUBE_CLIENT_SECRET,
|
||||
redirectUri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
const youtube = (newClient: OAuth2Client) =>
|
||||
google.youtube({
|
||||
version: 'v3',
|
||||
auth: newClient,
|
||||
});
|
||||
|
||||
const youtubeAnalytics = (newClient: OAuth2Client) =>
|
||||
google.youtubeAnalytics({
|
||||
version: 'v2',
|
||||
auth: newClient,
|
||||
});
|
||||
|
||||
const oauth2 = (newClient: OAuth2Client) =>
|
||||
google.oauth2({
|
||||
version: 'v2',
|
||||
auth: newClient,
|
||||
});
|
||||
|
||||
return { client, youtube, oauth2, youtubeAnalytics };
|
||||
};
|
||||
|
||||
@AuthProvider({ provider: 'GOOGLE' })
|
||||
export class GoogleProvider extends AuthProviderAbstract {
|
||||
generateLink() {
|
||||
const state = 'login';
|
||||
const { client } = clientAndYoutube();
|
||||
return client.generateAuthUrl({
|
||||
generateLink(query?: { redirect_uri?: string }) {
|
||||
const redirectUri = query?.redirect_uri || defaultRedirect();
|
||||
return makeClient(redirectUri).generateAuthUrl({
|
||||
access_type: 'online',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`,
|
||||
state: 'login',
|
||||
redirect_uri: redirectUri,
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
|
|
@ -50,21 +30,22 @@ export class GoogleProvider extends AuthProviderAbstract {
|
|||
});
|
||||
}
|
||||
|
||||
async getToken(code: string) {
|
||||
const { client, oauth2 } = clientAndYoutube();
|
||||
async getToken(code: string, redirectUri?: string) {
|
||||
const client = makeClient(redirectUri || defaultRedirect());
|
||||
const { tokens } = await client.getToken(code);
|
||||
return tokens.access_token;
|
||||
return tokens.access_token!;
|
||||
}
|
||||
|
||||
async getUser(providerToken: string) {
|
||||
const { client, oauth2 } = clientAndYoutube();
|
||||
const client = makeClient(defaultRedirect());
|
||||
client.setCredentials({ access_token: providerToken });
|
||||
const user = oauth2(client);
|
||||
const { data } = await user.userinfo.get();
|
||||
const { data } = await google
|
||||
.oauth2({ version: 'v2', auth: client })
|
||||
.userinfo.get();
|
||||
|
||||
return {
|
||||
id: data.id!,
|
||||
email: data.email,
|
||||
email: data.email!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class OauthProvider extends AuthProviderAbstract {
|
|||
return `${authUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async getToken(code: string): Promise<string> {
|
||||
async getToken(code: string, _redirectUri?: string): Promise<string> {
|
||||
const { tokenUrl, clientId, clientSecret, frontendUrl } = this.getConfig();
|
||||
const response = await fetch(`${tokenUrl}`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export class WalletProvider extends AuthProviderAbstract {
|
|||
return challenge;
|
||||
}
|
||||
|
||||
async getToken(code: string) {
|
||||
async getToken(code: string, _redirectUri?: string) {
|
||||
const { publicKey, challenge, signature } = JSON.parse(
|
||||
Buffer.from(code, 'base64').toString()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
50
apps/frontend/scripts/fetch-gtm.mjs
Normal file
50
apps/frontend/scripts/fetch-gtm.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
|
||||
import { sanitizePostContent } from '@gitroom/helpers/utils/sanitize.post.content';
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { Metadata } from 'next';
|
||||
import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side';
|
||||
|
|
@ -11,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 = {
|
||||
|
|
@ -141,12 +143,18 @@ 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
|
||||
className="text-sm whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: p.content,
|
||||
__html: sanitizePostContent(p.content),
|
||||
}}
|
||||
/>
|
||||
<div className="flex w-full gap-[10px]">
|
||||
|
|
|
|||
17
apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx
Normal file
17
apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
|||
77
apps/frontend/src/app/(provider)/layout.tsx
Normal file
77
apps/frontend/src/app/(provider)/layout.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { MantineWrapper } from '@gitroom/react/helpers/mantine.wrapper';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import '../global.scss';
|
||||
import 'react-tooltip/dist/react-tooltip.css';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import LayoutContext from '@gitroom/frontend/components/layout/layout.context';
|
||||
import { ReactNode } from 'react';
|
||||
import { Plus_Jakarta_Sans } from 'next/font/google';
|
||||
import clsx from 'clsx';
|
||||
import { VariableContextComponent } from '@gitroom/react/helpers/variable.context';
|
||||
import UtmSaver from '@gitroom/helpers/utils/utm.saver';
|
||||
|
||||
const jakartaSans = Plus_Jakarta_Sans({
|
||||
weight: ['600', '500'],
|
||||
style: ['normal', 'italic'],
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
</head>
|
||||
<body
|
||||
className={clsx(jakartaSans.className, 'dark text-primary !bg-primary')}
|
||||
>
|
||||
<VariableContextComponent
|
||||
language="en"
|
||||
storageProvider={
|
||||
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
|
||||
}
|
||||
stripeClient=""
|
||||
environment={process.env.NODE_ENV!}
|
||||
backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}
|
||||
plontoKey={process.env.NEXT_PUBLIC_POLOTNO!}
|
||||
billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY}
|
||||
discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!}
|
||||
frontEndUrl={process.env.FRONTEND_URL!}
|
||||
isGeneral={!!process.env.IS_GENERAL}
|
||||
genericOauth={!!process.env.POSTIZ_GENERIC_OAUTH}
|
||||
oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!}
|
||||
oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!}
|
||||
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
|
||||
cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''}
|
||||
mainUrl={process.env.MAIN_URL || ''}
|
||||
mcpUrl={process.env.MCP_URL}
|
||||
dub={false}
|
||||
facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!}
|
||||
telegramBotName={process.env.TELEGRAM_BOT_NAME!}
|
||||
neynarClientId={process.env.NEYNAR_CLIENT_ID!}
|
||||
isSecured={!process.env.NOT_SECURED}
|
||||
disableImageCompression={!!process.env.DISABLE_IMAGE_COMPRESSION}
|
||||
disableXAnalytics={!!process.env.DISABLE_X_ANALYTICS}
|
||||
sentryDsn={process.env.NEXT_PUBLIC_SENTRY_DSN!}
|
||||
extensionId={process.env.EXTENSION_ID || ''}
|
||||
transloadit={
|
||||
process.env.TRANSLOADIT_AUTH && process.env.TRANSLOADIT_TEMPLATE
|
||||
? [
|
||||
process.env.TRANSLOADIT_AUTH!,
|
||||
process.env.TRANSLOADIT_TEMPLATE!,
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<MantineWrapper>
|
||||
<LayoutContext>
|
||||
<UtmSaver />
|
||||
{children}
|
||||
</LayoutContext>
|
||||
</MantineWrapper>
|
||||
</VariableContextComponent>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
94
apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx
Normal file
94
apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use client';
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ProviderPreviewComponent,
|
||||
type ProviderPreviewHandle,
|
||||
type ProviderPreviewProps,
|
||||
type ProviderPreviewValidation,
|
||||
} from '@gitroom/frontend/components/provider-preview/preview.provider.component';
|
||||
|
||||
type InitPayload = {
|
||||
value?: Record<string, unknown>;
|
||||
errors?: string[];
|
||||
integration?: ProviderPreviewProps['integration'];
|
||||
/**
|
||||
* Per-post media (outer array = thread entries, inner = media items).
|
||||
* Passed to the provider's `checkValidity` function during validation.
|
||||
*/
|
||||
posts?: Array<Array<{ path: string; thumbnail?: string }>>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROVIDER_INIT__?: InitPayload;
|
||||
__getProviderPreviewValues__?: () => Record<string, unknown>;
|
||||
__validateProviderPreview__?: () => Promise<ProviderPreviewValidation>;
|
||||
/**
|
||||
* Returns the provider's resolved character limit (number) or null when
|
||||
* the provider doesn't declare one. Resolution uses the seeded
|
||||
* __PROVIDER_INIT__.integration.additionalSettings (e.g. X bumps to
|
||||
* 4000 when {title:'Verified', value:true} is present).
|
||||
*/
|
||||
__getProviderMaxCharacters__?: () => number | null;
|
||||
}
|
||||
}
|
||||
|
||||
const ProviderPreviewBridge: FC<{ provider: string }> = ({
|
||||
provider,
|
||||
}) => {
|
||||
// Read __PROVIDER_INIT__ in an effect, not via a useState lazy
|
||||
// initializer. The initializer would run on the server (where `window`
|
||||
// is undefined → {}), and during hydration React reuses the server
|
||||
// state — so the seeded payload would never reach the form. Setting
|
||||
// state inside an effect guarantees the read happens client-side
|
||||
// after mount; useForm's `values` prop then reactively resets the
|
||||
// form to the seed AFTER any field-level `register('x', { value })`
|
||||
// defaults have been applied, so the seed wins.
|
||||
const [init, setInit] = useState<InitPayload>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.__PROVIDER_INIT__) {
|
||||
setInit(window.__PROVIDER_INIT__ || {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const controlRef = useRef<ProviderPreviewHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.__getProviderPreviewValues__ = () =>
|
||||
controlRef.current?.getValues() ?? {};
|
||||
window.__validateProviderPreview__ = async () =>
|
||||
controlRef.current
|
||||
? await controlRef.current.validate()
|
||||
: {
|
||||
isValid: false,
|
||||
value: {},
|
||||
errors: ['not-ready'],
|
||||
formValid: false,
|
||||
checkValidityError: null,
|
||||
};
|
||||
window.__getProviderMaxCharacters__ = () =>
|
||||
controlRef.current?.getMaximumCharacters() ?? null;
|
||||
return () => {
|
||||
delete window.__getProviderPreviewValues__;
|
||||
delete window.__validateProviderPreview__;
|
||||
delete window.__getProviderMaxCharacters__;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!init) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderPreviewComponent
|
||||
provider={provider}
|
||||
value={init.value}
|
||||
errors={init.errors}
|
||||
integration={init.integration}
|
||||
posts={init.posts}
|
||||
controlRef={controlRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderPreviewBridge;
|
||||
13
apps/frontend/src/app/(provider)/provider/[p]/in-bridge.tsx
Normal file
13
apps/frontend/src/app/(provider)/provider/[p]/in-bridge.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { FC } from 'react';
|
||||
const Bridge = dynamic(
|
||||
() =>
|
||||
import(
|
||||
'./bridge'
|
||||
).then((mod) => mod.default),
|
||||
{ ssr: false }
|
||||
);
|
||||
export const InBridge: FC<{ provider: string }> = ({ provider }) => {
|
||||
return <Bridge provider={provider} />;
|
||||
};
|
||||
67
apps/frontend/src/app/(provider)/provider/[p]/page.tsx
Normal file
67
apps/frontend/src/app/(provider)/provider/[p]/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Provider settings WebView bridge.
|
||||
*
|
||||
* URL: /provider/:p (e.g. /provider/tiktok, /provider/instagram)
|
||||
*
|
||||
* --- Auth (native -> WebView, via URL) ---
|
||||
* Append `?loggedAuth=<jwt>` to the URL. The shared fetch wrapper
|
||||
* (libraries/helpers/src/utils/custom.fetch.func.ts) reads that search
|
||||
* param on every request and attaches it as the `auth` header, so any
|
||||
* authenticated API call made by the SettingsComponent or checkValidity
|
||||
* just works. The (provider) route is also excluded from the 401->/
|
||||
* redirect logic in LayoutContext, so a stale token won't yank the
|
||||
* WebView away from the form.
|
||||
*
|
||||
* --- Initial state (native -> WebView, push once) ---
|
||||
* Before loading the URL, the native side injects a global:
|
||||
*
|
||||
* webView.injectJavaScript(`window.__PROVIDER_INIT__ = ${JSON.stringify({
|
||||
* value: { ...currentSettings }, // optional, shape = provider DTO
|
||||
* errors: ['...'], // optional, prior validation errors
|
||||
* integration: { ... }, // optional Partial<Integration>
|
||||
* })};`);
|
||||
*
|
||||
* The bridge reads this once on mount (see ./bridge.tsx).
|
||||
*
|
||||
* --- Reading values & validation (native -> WebView, pull on demand) ---
|
||||
* No messages are posted from the WebView. Instead, native calls these
|
||||
* globals (they are defined once the bridge's effect has run):
|
||||
*
|
||||
* // Returns the current form values, no validation:
|
||||
* webView.evaluateJavaScript('window.__getProviderPreviewValues__()')
|
||||
* // => { ...settings }
|
||||
*
|
||||
* // Triggers validation and returns isValid + flattened error strings:
|
||||
* webView.evaluateJavaScript('window.__validateProviderPreview__()')
|
||||
* // => Promise<{ isValid: boolean, value: {...}, errors: string[] }>
|
||||
*
|
||||
* // Returns the provider's resolved character limit (number) or null
|
||||
* // when the provider doesn't declare one. Uses the seeded
|
||||
* // __PROVIDER_INIT__.integration.additionalSettings:
|
||||
* webView.evaluateJavaScript('window.__getProviderMaxCharacters__()')
|
||||
* // => number | null
|
||||
*
|
||||
* React Native example (RN WebView ref):
|
||||
* const js = `window.__validateProviderPreview__().then(r =>
|
||||
* window.ReactNativeWebView.postMessage(JSON.stringify(r)));
|
||||
* true;`;
|
||||
* webViewRef.current?.injectJavaScript(js);
|
||||
*
|
||||
* Native should wait for page load (onLoadEnd / didFinishNavigation) before
|
||||
* calling these. If called before the bridge mounts, the validate getter
|
||||
* returns { isValid: false, errors: ['not-ready'] } and the values getter
|
||||
* returns {}.
|
||||
*
|
||||
* If a different channel is needed, adjust ./bridge.tsx — this page is only
|
||||
* a server wrapper that forwards the `:p` route param.
|
||||
*/
|
||||
import { InBridge } from '@gitroom/frontend/app/(provider)/provider/[p]/in-bridge';
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ p: string }>;
|
||||
}) {
|
||||
const { p } = await params;
|
||||
return <InBridge provider={p} />;
|
||||
}
|
||||
6
apps/frontend/src/app/(provider)/provider/add/page.tsx
Normal file
6
apps/frontend/src/app/(provider)/provider/add/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react';
|
||||
import { MobileIntegration } from '@gitroom/frontend/components/new-layout/mobile.integration';
|
||||
|
||||
export default async function Page() {
|
||||
return <MobileIntegration />;
|
||||
}
|
||||
411
apps/frontend/src/components/admin/admin-errors.component.tsx
Normal file
411
apps/frontend/src/components/admin/admin-errors.component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -93,10 +94,11 @@ const LoadMessages: FC<{ id: string }> = ({ id }) => {
|
|||
|
||||
const loadMessages = useCallback(async (idToSet: string) => {
|
||||
const data = await (await fetch(`/copilot/${idToSet}/list`)).json();
|
||||
console.log(data);
|
||||
setMessages(
|
||||
data.uiMessages.map((p: any) => {
|
||||
data.messages.map((p: any) => {
|
||||
return new TextMessage({
|
||||
content: p.content,
|
||||
content: p.content.content,
|
||||
role: p.role,
|
||||
});
|
||||
})
|
||||
|
|
@ -161,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}`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -325,7 +325,8 @@ const ChromeExtensionWarning: FC<{
|
|||
We will store your cookies securely to facilitate the connection.
|
||||
</li>
|
||||
<li>
|
||||
Postiz does not take responsibility for any issues arising or account termination due to the use of this method.
|
||||
Postiz does not take responsibility for any issues arising or account
|
||||
termination due to the use of this method.
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex gap-[10px] mt-[8px]">
|
||||
|
|
@ -380,8 +381,9 @@ export const AddProviderComponent: FC<{
|
|||
invite: boolean;
|
||||
update?: () => void;
|
||||
onboarding?: boolean;
|
||||
isMobile?: boolean;
|
||||
}> = (props) => {
|
||||
const { update, social, article, onboarding } = props;
|
||||
const { update, social, article, onboarding, isMobile } = props;
|
||||
const { isGeneral, extensionId } = useVariables();
|
||||
const toaster = useToaster();
|
||||
const router = useRouter();
|
||||
|
|
@ -418,26 +420,38 @@ export const AddProviderComponent: FC<{
|
|||
modal.openModal({
|
||||
title: `Add ${capitalize(identifier)}`,
|
||||
withCloseButton: true,
|
||||
...(isMobile ? { removeLayout: true, fullScreen: true } : {}),
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
children: (
|
||||
<Web3Providers
|
||||
onComplete={(code, newState) => {
|
||||
window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}${
|
||||
onboarding ? '&onboarding=true' : ''
|
||||
}`;
|
||||
}}
|
||||
nonce={url}
|
||||
/>
|
||||
<div
|
||||
{...(isMobile ? { className: 'h-full bg-black p-[20px]' } : {})}
|
||||
>
|
||||
<Web3Providers
|
||||
onComplete={(code, newState) => {
|
||||
window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}${
|
||||
onboarding ? '&onboarding=true' : ''
|
||||
}`;
|
||||
}}
|
||||
nonce={url}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
return;
|
||||
};
|
||||
const gotoIntegration = async (externalUrl?: string) => {
|
||||
// Mobile WebView: reuse the existing `externalUrl` param to
|
||||
// carry the `postiz://` deep link so the backend redirects
|
||||
// back to the iOS/Android app after OAuth completes, instead
|
||||
// of the default web redirect.
|
||||
const params = [
|
||||
externalUrl ? `externalUrl=${externalUrl}` : '',
|
||||
`externalUrl=${encodeURIComponent(externalUrl)}`,
|
||||
onboardingParam,
|
||||
isMobile
|
||||
? `redirectUrl=${encodeURIComponent('postiz://integrations')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
|
|
@ -467,6 +481,23 @@ export const AddProviderComponent: FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
// In the mobile WebView the OAuth provider (Google, Facebook,
|
||||
// etc.) typically refuses in-WebView sign-in. Post the URL
|
||||
// out to React Native so it can open the system browser;
|
||||
// `window.open`/`location.href` aren't reliable here because
|
||||
// RN WebView doesn't always route them through the native
|
||||
// navigation intercept. The backend redirects back to the
|
||||
// app via `postiz://` once OAuth completes.
|
||||
const rn = (window as any).ReactNativeWebView;
|
||||
if (rn && typeof rn.postMessage === 'function') {
|
||||
rn.postMessage(JSON.stringify({ type: 'open-external', url }));
|
||||
return;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
};
|
||||
if (isWeb3) {
|
||||
|
|
@ -577,6 +608,7 @@ export const AddProviderComponent: FC<{
|
|||
modal.openModal({
|
||||
title: 'URL',
|
||||
withCloseButton: true,
|
||||
...(isMobile ? { removeLayout: true, fullScreen: true } : {}),
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
|
|
@ -588,16 +620,21 @@ export const AddProviderComponent: FC<{
|
|||
modal.openModal({
|
||||
title: t('add_provider_title', 'Add Provider'),
|
||||
withCloseButton: true,
|
||||
...(isMobile ? { removeLayout: true, fullScreen: true } : {}),
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
children: (
|
||||
<CustomVariables
|
||||
identifier={identifier}
|
||||
gotoUrl={(url: string) => router.push(url)}
|
||||
variables={customFields}
|
||||
onboarding={onboarding}
|
||||
/>
|
||||
<div
|
||||
{...(isMobile ? { className: 'h-full bg-black p-[20px]' } : {})}
|
||||
>
|
||||
<CustomVariables
|
||||
identifier={identifier}
|
||||
gotoUrl={(url: string) => router.push(url)}
|
||||
variables={customFields}
|
||||
onboarding={onboarding}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
return;
|
||||
|
|
@ -614,8 +651,10 @@ export const AddProviderComponent: FC<{
|
|||
<div className="flex flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
'grid grid-cols-5 gap-[10px] justify-items-center justify-center',
|
||||
onboarding ? 'grid-cols-9' : 'grid-cols-5'
|
||||
isMobile && 'gap-[20px] flex flex-col',
|
||||
!isMobile &&
|
||||
'grid grid-cols-5 gap-[10px] justify-items-center justify-center',
|
||||
isMobile ? {} : onboarding ? 'grid-cols-9' : 'grid-cols-5'
|
||||
)}
|
||||
>
|
||||
{social
|
||||
|
|
@ -648,9 +687,12 @@ export const AddProviderComponent: FC<{
|
|||
'data-tooltip-content': item.toolTip,
|
||||
}
|
||||
: {})}
|
||||
className={
|
||||
'w-full h-[100px] text-[14px] p-[10px] rounded-[8px] bg-newTableHeader text-textColor relative justify-center items-center flex flex-col gap-[10px] cursor-pointer'
|
||||
}
|
||||
className={clsx(
|
||||
isMobile
|
||||
? 'flex-row h-[72px] p-[16px]'
|
||||
: 'flex-col p-[10px] h-[100px] justify-center',
|
||||
'w-full text-[14px] rounded-[8px] bg-newTableHeader text-textColor relative items-center flex gap-[10px] cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{item.identifier === 'youtube' ? (
|
||||
|
|
@ -666,9 +708,14 @@ export const AddProviderComponent: FC<{
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center">
|
||||
<div
|
||||
className={clsx(
|
||||
isMobile ? '' : 'whitespace-pre-wrap',
|
||||
'text-center'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{!!item.toolTip && (
|
||||
{!!item.toolTip && !isMobile && (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { FormProvider, useForm } from 'react-hook-form';
|
|||
import { useUser } from '@gitroom/frontend/components/layout/user.context';
|
||||
import { VideoContextWrapper } from '@gitroom/frontend/components/videos/video.context.wrapper';
|
||||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export const Modal: FC<{
|
||||
close: () => void;
|
||||
|
|
@ -73,71 +75,42 @@ export const Modal: FC<{
|
|||
onSubmit={form.handleSubmit(generate)}
|
||||
className="flex flex-col gap-[10px]"
|
||||
>
|
||||
{createPortal(
|
||||
<>{data?.credits || 0} credits left</>,
|
||||
document.querySelector('.top-title-content') || document.createElement('div')
|
||||
)}
|
||||
<FormProvider {...form}>
|
||||
<div className="text-textColor fixed start-0 top-0 bg-primary/80 z-[300] w-full h-full p-[60px] animate-fade justify-center flex bg-black/50">
|
||||
<div>
|
||||
<div className="flex gap-[10px] flex-col w-[500px] h-auto bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative">
|
||||
<div className="flex">
|
||||
<div className="flex-1">
|
||||
<TopTitle title={'Video Type'}>
|
||||
<div className="mr-[25px]">
|
||||
{data?.credits || 0} credits left
|
||||
</div>
|
||||
</TopTitle>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.close}
|
||||
className="outline-none absolute end-[10px] top-[10px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
<div>
|
||||
<div className="relative h-[400px]">
|
||||
<div className="absolute left-0 top-0 w-full h-full overflow-hidden overflow-y-auto">
|
||||
<div className="mt-[10px] flex w-full justify-center items-center gap-[10px]">
|
||||
<div className="flex-1 flex">
|
||||
<Button
|
||||
className="!flex-1"
|
||||
onClick={() => setPosition('vertical')}
|
||||
secondary={position === 'horizontal'}
|
||||
>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative h-[400px]">
|
||||
<div className="absolute left-0 top-0 w-full h-full overflow-hidden overflow-y-auto">
|
||||
<div className="mt-[10px] flex w-full justify-center items-center gap-[10px]">
|
||||
<div className="flex-1 flex">
|
||||
<Button
|
||||
className="!flex-1"
|
||||
onClick={() => setPosition('vertical')}
|
||||
secondary={position === 'horizontal'}
|
||||
>
|
||||
Vertical (Stories, Reels)
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 flex mt-[10px]">
|
||||
<Button
|
||||
className="!flex-1"
|
||||
onClick={() => setPosition('horizontal')}
|
||||
secondary={position === 'vertical'}
|
||||
>
|
||||
Horizontal (Normal Post)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<VideoWrapper identifier={type.identifier} />
|
||||
Vertical (Stories, Reels)
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 flex mt-[10px]">
|
||||
<Button
|
||||
className="!flex-1"
|
||||
onClick={() => setPosition('horizontal')}
|
||||
secondary={position === 'vertical'}
|
||||
>
|
||||
Horizontal (Normal Post)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button type="submit" className="flex-1">
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
<VideoWrapper identifier={type.identifier} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button type="submit" className="flex-1">
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</form>
|
||||
|
|
@ -153,9 +126,9 @@ export const AiVideo: FC<{
|
|||
const { value, onChange } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [type, setType] = useState<any | null>(null);
|
||||
const [modal, setModal] = useState(false);
|
||||
const fetch = useFetch();
|
||||
const { isTrailing } = useUser();
|
||||
const modals = useModals();
|
||||
|
||||
const loadVideoList = useCallback(async () => {
|
||||
return (await (await fetch('/media/video-options')).json()).filter(
|
||||
|
|
@ -175,7 +148,21 @@ export const AiVideo: FC<{
|
|||
const generateVideo = useCallback(
|
||||
(type: { identifier: string }) => async () => {
|
||||
setType(type);
|
||||
setModal(true);
|
||||
modals.openModal({
|
||||
title: <div className="top-title-content" />,
|
||||
children: (close) => (
|
||||
<Modal
|
||||
onChange={onChange}
|
||||
setLoading={setLoading}
|
||||
close={() => {
|
||||
close();
|
||||
setType(null);
|
||||
}}
|
||||
type={type}
|
||||
value={value}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
|
@ -186,18 +173,6 @@ export const AiVideo: FC<{
|
|||
|
||||
return (
|
||||
<>
|
||||
{modal && (
|
||||
<Modal
|
||||
onChange={onChange}
|
||||
setLoading={setLoading}
|
||||
close={() => {
|
||||
setModal(false);
|
||||
setType(null);
|
||||
}}
|
||||
type={type}
|
||||
value={props.value}
|
||||
/>
|
||||
)}
|
||||
<div className="relative group">
|
||||
<div
|
||||
{...(value.length < 30
|
||||
|
|
@ -247,7 +222,9 @@ export const AiVideo: FC<{
|
|||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[10px] font-[600] iconBreak:hidden block">{t('ai', 'AI')} Video</div>
|
||||
<div className="text-[10px] font-[600] iconBreak:hidden block">
|
||||
{t('ai', 'AI')} Video
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{value.length >= 30 && !loading && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createContext, useContext } from 'react';
|
|||
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import dayjs from 'dayjs';
|
||||
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
|
||||
export const IntegrationContext = createContext<{
|
||||
export type IntegrationContextType = {
|
||||
date: dayjs.Dayjs;
|
||||
integration: Integrations | undefined;
|
||||
allIntegrations: Integrations[];
|
||||
|
|
@ -16,7 +16,8 @@ export const IntegrationContext = createContext<{
|
|||
id: string;
|
||||
}>;
|
||||
}>;
|
||||
}>({
|
||||
};
|
||||
export const IntegrationContext = createContext<IntegrationContextType>({
|
||||
integration: undefined,
|
||||
value: [],
|
||||
date: newDayjs(),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
56
apps/frontend/src/components/layout/gtm.component.tsx
Normal file
56
apps/frontend/src/components/layout/gtm.component.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ function LayoutContextInner(params: { children: ReactNode }) {
|
|||
async (url: string, options: RequestInit, response: Response) => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.href.includes('/p/')
|
||||
(window.location.href.includes('/p/') ||
|
||||
window.location.pathname.startsWith('/provider/'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import clsx from 'clsx';
|
|||
import { EventEmitter } from 'events';
|
||||
|
||||
interface OpenModalInterface {
|
||||
title?: string;
|
||||
title?: any;
|
||||
closeOnClickOutside?: boolean;
|
||||
removeLayout?: boolean;
|
||||
fullScreen?: boolean;
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -51,6 +52,7 @@ import {
|
|||
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
const Polonto = dynamic(
|
||||
() => import('@gitroom/frontend/components/launches/polonto')
|
||||
);
|
||||
|
|
@ -205,13 +207,25 @@ export const MediaBox: FC<{
|
|||
closeModal: () => void;
|
||||
}> = ({ type, standalone, setMedia }) => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebounce(search, 300);
|
||||
const fetch = useFetch();
|
||||
const modals = useModals();
|
||||
const toaster = useToaster();
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [debouncedSearch]);
|
||||
const loadMedia = useCallback(async () => {
|
||||
return (await fetch(`/media?page=${page + 1}`)).json();
|
||||
}, [page]);
|
||||
const { data, mutate, isLoading } = useSWR(`get-media-${page}`, loadMedia);
|
||||
const params = new URLSearchParams({ page: String(page + 1) });
|
||||
if (debouncedSearch.trim()) {
|
||||
params.set('search', debouncedSearch.trim());
|
||||
}
|
||||
return (await fetch(`/media?${params.toString()}`)).json();
|
||||
}, [page, debouncedSearch]);
|
||||
const { data, mutate, isLoading } = useSWR(
|
||||
`get-media-${page}-${debouncedSearch}`,
|
||||
loadMedia
|
||||
);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const t = useT();
|
||||
const uploaderRef = useRef<any>(null);
|
||||
|
|
@ -338,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)}
|
||||
|
|
@ -404,23 +418,22 @@ export const MediaBox: FC<{
|
|||
<div className="flex flex-col flex-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex',
|
||||
!isLoading && !data?.results?.length && 'hidden'
|
||||
'flex items-center gap-[12px]',
|
||||
!isLoading &&
|
||||
!data?.results?.length &&
|
||||
!debouncedSearch &&
|
||||
'hidden'
|
||||
)}
|
||||
>
|
||||
{!isLoading && !!data?.results?.length && (
|
||||
<div className="flex-1 text-[14px] font-[600] whitespace-pre-line">
|
||||
{t(
|
||||
'select_or_upload_pictures_max_1gb',
|
||||
'Select or upload pictures (maximum 1 GB per upload).'
|
||||
)}
|
||||
{'\n'}
|
||||
{t(
|
||||
'you_can_drag_drop_pictures',
|
||||
'You can also drag & drop pictures.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_media_by_name', 'Search by file name')}
|
||||
className="w-full h-[44px] px-[14px] rounded-[8px] bg-newBgColorInner border border-newColColor text-[14px] outline-none focus:border-[#612BD3]"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploaderRef}
|
||||
|
|
@ -428,12 +441,10 @@ export const MediaBox: FC<{
|
|||
className="hidden"
|
||||
multiple={true}
|
||||
/>
|
||||
{!isLoading && !!data?.results?.length && (
|
||||
<div className="flex gap-[8px]">
|
||||
{btn}
|
||||
<ThirdPartyMediaLibrary onImported={() => mutate()} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-[8px]">
|
||||
{btn}
|
||||
<ThirdPartyMediaLibrary onImported={() => mutate()} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full pointer-events-none relative mt-[5px] mb-[5px]">
|
||||
<div className="w-full h-[46px] overflow-hidden absolute left-0 bg-newBgColorInner uppyChange">
|
||||
|
|
@ -471,10 +482,15 @@ export const MediaBox: FC<{
|
|||
<>
|
||||
<NoMediaIcon />
|
||||
<div className="text-[20px] font-[600]">
|
||||
{t(
|
||||
'you_dont_have_any_media_yet',
|
||||
"You don't have any media yet"
|
||||
)}
|
||||
{debouncedSearch
|
||||
? t(
|
||||
'no_media_match_search',
|
||||
'No media matches your search'
|
||||
)
|
||||
: t(
|
||||
'you_dont_have_any_media_yet',
|
||||
"You don't have any media yet"
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-line text-newTextColor/[0.6] text-center">
|
||||
{t(
|
||||
|
|
@ -510,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;
|
||||
})
|
||||
|
|
@ -564,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
|
||||
|
|
@ -788,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
|
||||
|
|
|
|||
|
|
@ -772,6 +772,7 @@ export const Editor: FC<{
|
|||
chars={chars}
|
||||
totalChars={valueWithoutHtml.length}
|
||||
totalAllowedChars={props.totalChars}
|
||||
text={valueWithoutHtml}
|
||||
/>
|
||||
}
|
||||
toolBar={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,11 +28,8 @@ class Empty {
|
|||
empty: string;
|
||||
}
|
||||
|
||||
export enum PostComment {
|
||||
ALL,
|
||||
POST,
|
||||
COMMENT,
|
||||
}
|
||||
export { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
|
||||
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
|
||||
|
||||
interface CharacterCondition {
|
||||
format: 'no-pictures' | 'with-pictures';
|
||||
|
|
@ -72,7 +69,7 @@ export const withProvider = function <T extends object>(params: {
|
|||
maximumCharacters,
|
||||
} = params;
|
||||
|
||||
return forwardRef((props: { id: string }, ref) => {
|
||||
const Wrapped = forwardRef((props: { id: string }, ref) => {
|
||||
const t = useT();
|
||||
const fetch = useFetch();
|
||||
const {
|
||||
|
|
@ -356,4 +353,36 @@ export const withProvider = function <T extends object>(params: {
|
|||
</IntegrationContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
// Expose the settings configuration as static metadata so the preview /
|
||||
// mobile settings page can render <SettingsComponent /> in isolation
|
||||
// without pulling the launch store + DOM portals.
|
||||
(Wrapped as any).__settings = {
|
||||
SettingsComponent,
|
||||
CustomPreviewComponent,
|
||||
dto,
|
||||
postComment,
|
||||
maximumCharacters,
|
||||
checkValidity,
|
||||
};
|
||||
|
||||
return Wrapped;
|
||||
};
|
||||
|
||||
/** Pulls the settings metadata off a withProvider-wrapped component. */
|
||||
export const getProviderSettingsMeta = (component: unknown) => {
|
||||
return (component as any)?.__settings as
|
||||
| {
|
||||
SettingsComponent: FC<{ values?: any }> | null;
|
||||
CustomPreviewComponent?: FC<{ maximumCharacters?: number }>;
|
||||
dto?: any;
|
||||
postComment: PostComment;
|
||||
maximumCharacters?: number | ((settings: any) => number);
|
||||
checkValidity?: (
|
||||
value: Array<Array<{ path: string; thumbnail?: string }>>,
|
||||
settings: any,
|
||||
additionalSettings: any
|
||||
) => Promise<string | true>;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export enum PostComment {
|
||||
ALL,
|
||||
POST,
|
||||
COMMENT,
|
||||
}
|
||||
|
|
@ -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?')}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { create } from 'zustand';
|
|||
import dayjs from 'dayjs';
|
||||
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
|
||||
import { createRef, RefObject } from 'react';
|
||||
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
|
||||
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
|
||||
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
|
||||
|
||||
interface Values {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { AddProviderComponent } from '@gitroom/frontend/components/launches/add.provider.component';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
|
||||
export const MobileIntegration: FC = () => {
|
||||
const [integrations, setIntegrations] = useState(null as any);
|
||||
const fetch = useFetch();
|
||||
|
||||
const loadIntegrations = useCallback(async () => {
|
||||
setIntegrations(await (await fetch('/integrations')).json());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadIntegrations();
|
||||
}, []);
|
||||
|
||||
if (!integrations) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AddProviderComponent
|
||||
isMobile={true}
|
||||
invite={false}
|
||||
update={() => {}}
|
||||
{...integrations}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
'use client';
|
||||
import 'reflect-metadata';
|
||||
import { FC, MutableRefObject, useEffect, useMemo } from 'react';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
|
||||
import { Providers } from '@gitroom/frontend/components/new-launch/providers/show.all.providers';
|
||||
import { getProviderSettingsMeta } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
|
||||
import {
|
||||
IntegrationContext,
|
||||
type IntegrationContextType,
|
||||
} from '@gitroom/frontend/components/launches/helpers/use.integration';
|
||||
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
|
||||
|
||||
type MockIntegration = IntegrationContextType['integration'];
|
||||
|
||||
export type ProviderPreviewValidation = {
|
||||
isValid: boolean;
|
||||
value: Record<string, unknown>;
|
||||
errors: string[];
|
||||
/** react-hook-form trigger() result. False = at least one DTO field failed. */
|
||||
formValid: boolean;
|
||||
/** Non-null when the provider's checkValidity() returned a string. */
|
||||
checkValidityError: string | null;
|
||||
};
|
||||
|
||||
export type ProviderPreviewHandle = {
|
||||
getValues: () => Record<string, unknown>;
|
||||
validate: () => Promise<ProviderPreviewValidation>;
|
||||
/**
|
||||
* Resolves the provider's `maximumCharacters` against the seeded
|
||||
* integration.additionalSettings. Returns null when the provider doesn't
|
||||
* declare a limit (caller should treat as unbounded / fall back).
|
||||
*/
|
||||
getMaximumCharacters: () => number | null;
|
||||
};
|
||||
|
||||
export type ProviderPreviewProps = {
|
||||
/** Provider identifier (e.g. "tiktok", "instagram", "youtube"). */
|
||||
provider: string;
|
||||
/** Initial settings value (shape matches the provider's DTO). */
|
||||
value?: Record<string, unknown>;
|
||||
/**
|
||||
* Called on every form change with the current settings value — for the
|
||||
* mobile WebView bridge this is what you postMessage back.
|
||||
*/
|
||||
onChange?: (value: Record<string, unknown>) => void;
|
||||
/** Validator error messages from a previous failed save, rendered above the form. */
|
||||
errors?: string[];
|
||||
/**
|
||||
* Stub integration to feed the SettingsComponent via IntegrationContext.
|
||||
* Some providers (e.g. TikTok title) branch on `integration.additionalSettings`
|
||||
* or `value[0].image` — pass what you have, leave the rest to defaults.
|
||||
*/
|
||||
integration?: Partial<MockIntegration>;
|
||||
/**
|
||||
* Per-post media (outer array = thread entries, inner = media items).
|
||||
* Forwarded to the provider's `checkValidity` during validate().
|
||||
*/
|
||||
posts?: Array<Array<{ path: string; thumbnail?: string }>>;
|
||||
/**
|
||||
* Imperative handle populated on mount. The parent calls
|
||||
* `controlRef.current?.validate()` / `.getValues()` to pull state on demand.
|
||||
*/
|
||||
controlRef?: MutableRefObject<ProviderPreviewHandle | null>;
|
||||
};
|
||||
|
||||
const DEFAULT_INTEGRATION: MockIntegration = {
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
identifier: '',
|
||||
picture: '',
|
||||
display: '',
|
||||
type: 'social',
|
||||
editor: 'normal' as const,
|
||||
disabled: false,
|
||||
inBetweenSteps: false,
|
||||
additionalSettings: '[]',
|
||||
changeProfilePicture: false,
|
||||
changeNickName: false,
|
||||
time: [] as { time: number }[],
|
||||
};
|
||||
|
||||
/** Emits onChange whenever the form changes. Mounted inside FormProvider. */
|
||||
const FormChangeEmitter: FC<{
|
||||
onChange?: (value: Record<string, unknown>) => void;
|
||||
}> = ({ onChange }) => {
|
||||
const values = useWatch();
|
||||
useEffect(() => {
|
||||
if (onChange) onChange(values ?? {});
|
||||
}, [values, onChange]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const flattenFormErrors = (errs: unknown): string[] => {
|
||||
const out: string[] = [];
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (typeof n.message === 'string') out.push(n.message);
|
||||
if (n.types && typeof n.types === 'object') {
|
||||
for (const t of Object.values(n.types as Record<string, unknown>)) {
|
||||
if (typeof t === 'string') out.push(t);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(n)) {
|
||||
if (['message', 'type', 'types', 'ref', 'root'].includes(key)) continue;
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(errs);
|
||||
return out;
|
||||
};
|
||||
|
||||
export const ProviderPreviewComponent: FC<ProviderPreviewProps> = ({
|
||||
provider,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
integration,
|
||||
posts,
|
||||
controlRef,
|
||||
}) => {
|
||||
const meta = useMemo(() => {
|
||||
const entry = Providers.find((p) => p.identifier === provider);
|
||||
if (!entry) return null;
|
||||
return getProviderSettingsMeta(entry.component);
|
||||
}, [provider]);
|
||||
|
||||
// When `value` is absent or `{}`, don't feed it to react-hook-form at all —
|
||||
// passing an empty object as `values` wipes out DTO-level defaults that the
|
||||
// SettingsComponent relies on (e.g. tiktok privacy = PUBLIC).
|
||||
const hasSeededValue =
|
||||
!!value && typeof value === 'object' && Object.keys(value).length > 0;
|
||||
|
||||
const form = useForm({
|
||||
resolver: meta?.dto ? classValidatorResolver(meta.dto) : undefined,
|
||||
defaultValues: hasSeededValue ? value : undefined,
|
||||
values: hasSeededValue ? value : undefined,
|
||||
mode: 'all',
|
||||
criteriaMode: 'all',
|
||||
reValidateMode: 'onChange',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!controlRef) return;
|
||||
const resolveAdditionalSettings = (): unknown[] => {
|
||||
const additional = (integration?.additionalSettings as
|
||||
| string
|
||||
| unknown[]
|
||||
| undefined) ?? '[]';
|
||||
if (Array.isArray(additional)) return additional;
|
||||
try {
|
||||
const parsed = JSON.parse(additional || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
controlRef.current = {
|
||||
getValues: () => form.getValues() as Record<string, unknown>,
|
||||
getMaximumCharacters: () => {
|
||||
const max = meta?.maximumCharacters;
|
||||
if (typeof max === 'number') return max;
|
||||
if (typeof max === 'function') {
|
||||
try {
|
||||
return max(resolveAdditionalSettings());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
validate: async () => {
|
||||
const formValid = await form.trigger(undefined, { shouldFocus: false });
|
||||
const errs = flattenFormErrors(form.formState.errors);
|
||||
let customError: string | true = true;
|
||||
if (meta?.checkValidity) {
|
||||
try {
|
||||
customError = await meta.checkValidity(
|
||||
posts ?? [],
|
||||
form.getValues(),
|
||||
resolveAdditionalSettings(),
|
||||
);
|
||||
} catch (e: any) {
|
||||
customError = e?.message ?? 'checkValidity threw';
|
||||
}
|
||||
}
|
||||
const checkValidityError =
|
||||
customError === true ? null : customError;
|
||||
if (checkValidityError) errs.push(checkValidityError);
|
||||
return {
|
||||
isValid: formValid && checkValidityError === null,
|
||||
value: form.getValues() as Record<string, unknown>,
|
||||
errors: errs,
|
||||
formValid,
|
||||
checkValidityError,
|
||||
};
|
||||
},
|
||||
};
|
||||
return () => {
|
||||
if (controlRef.current) controlRef.current = null;
|
||||
};
|
||||
}, [controlRef, form, meta, integration, posts]);
|
||||
|
||||
const contextValue = useMemo<IntegrationContextType>(
|
||||
() => ({
|
||||
date: newDayjs(),
|
||||
integration: {
|
||||
...(DEFAULT_INTEGRATION as MockIntegration),
|
||||
identifier: provider,
|
||||
...integration,
|
||||
} as MockIntegration,
|
||||
allIntegrations: [],
|
||||
value: [],
|
||||
}),
|
||||
[provider, integration],
|
||||
);
|
||||
|
||||
if (!meta) {
|
||||
return <div>Provider "{provider}" not found</div>;
|
||||
}
|
||||
|
||||
const { SettingsComponent } = meta;
|
||||
if (!SettingsComponent) {
|
||||
return (
|
||||
<div className="p-4 text-sm">
|
||||
This provider has no configurable settings.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationContext.Provider value={contextValue}>
|
||||
<FormProvider {...form}>
|
||||
<div className="flex flex-col text-white p-[10px]">
|
||||
{errors && errors.length > 0 && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
<ul className="list-disc ps-5">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<FormChangeEmitter onChange={onChange} />
|
||||
<SettingsComponent />
|
||||
</div>
|
||||
</FormProvider>
|
||||
</IntegrationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -213,10 +213,17 @@ const McpSection = ({
|
|||
user.publicApi
|
||||
);
|
||||
|
||||
const remoteUrl = `${mcpBase}/mcp/${user.publicApi}`;
|
||||
const cliUrl = `${mcpBase}/mcp`;
|
||||
|
||||
const maskedConfig = revealed
|
||||
? config
|
||||
: config.replace(new RegExp(user.publicApi.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '*'.repeat(user.publicApi.length));
|
||||
|
||||
const maskedRemoteUrl = revealed
|
||||
? remoteUrl
|
||||
: remoteUrl.replace(user.publicApi, '*'.repeat(user.publicApi.length));
|
||||
|
||||
return (
|
||||
<div className="bg-newBgColorInnerInner rounded-[12px] border border-newBorder overflow-hidden">
|
||||
<div className="bg-newBgColorInner px-[20px] py-[14px] border-b border-newBorder flex items-start justify-between gap-[12px]">
|
||||
|
|
@ -261,40 +268,47 @@ const McpSection = ({
|
|||
onClick={() => setMethod(m)}
|
||||
>
|
||||
{m === 'header'
|
||||
? t('authorization_header', 'Authorization Header')
|
||||
: t('api_key_in_url', 'API Key in URL')}
|
||||
? t('cli_claude_code_codex', 'CLI (Claude Code / Codex)')
|
||||
: t('remote_servers', 'Remote servers (ChatGPT, Claude)')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div className="text-[13px] font-[600] text-customColor18">
|
||||
{t('mcp_client', 'Client')}
|
||||
{method === 'header' && (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div className="text-[13px] font-[600] text-customColor18">
|
||||
{t('mcp_client', 'Client')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{mcpClients.map((client) => (
|
||||
<button
|
||||
key={client}
|
||||
type="button"
|
||||
className={clsx(
|
||||
'cursor-pointer px-[14px] h-[36px] text-[13px] font-[500] rounded-[8px] transition-colors',
|
||||
activeClient === client
|
||||
? 'bg-[#612BD3] text-white'
|
||||
: 'bg-btnSimple text-customColor18 hover:bg-boxHover hover:text-textColor'
|
||||
)}
|
||||
onClick={() => setActiveClient(client)}
|
||||
>
|
||||
{client}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{mcpClients.map((client) => (
|
||||
<button
|
||||
key={client}
|
||||
type="button"
|
||||
className={clsx(
|
||||
'cursor-pointer px-[14px] h-[36px] text-[13px] font-[500] rounded-[8px] transition-colors',
|
||||
activeClient === client
|
||||
? 'bg-[#612BD3] text-white'
|
||||
: 'bg-btnSimple text-customColor18 hover:bg-boxHover hover:text-textColor'
|
||||
)}
|
||||
onClick={() => setActiveClient(client)}
|
||||
>
|
||||
{client}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-[8px]">
|
||||
<div className="text-[12px] text-customColor18 font-[500]">
|
||||
{hint}
|
||||
{method === 'header'
|
||||
? hint
|
||||
: t(
|
||||
'remote_server_url_hint',
|
||||
'Paste this URL into your remote MCP client (ChatGPT, Claude, etc.).'
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-newBgColorInner border border-newBorder rounded-[8px] p-[16px] text-[13px] whitespace-pre-wrap break-all overflow-x-auto leading-[1.6]">
|
||||
{maskedConfig}
|
||||
{method === 'header' ? maskedConfig : maskedRemoteUrl}
|
||||
</pre>
|
||||
<div className="flex gap-[8px]">
|
||||
<button
|
||||
|
|
@ -327,7 +341,16 @@ const McpSection = ({
|
|||
</svg>
|
||||
{revealed ? t('hide', 'Hide') : t('reveal', 'Reveal')}
|
||||
</button>
|
||||
<CopyButton text={config} label={t('copy', 'Copy')} />
|
||||
<CopyButton
|
||||
text={method === 'header' ? config : remoteUrl}
|
||||
label={t('copy', 'Copy')}
|
||||
/>
|
||||
{method === 'header' && (
|
||||
<CopyButton
|
||||
text={cliUrl}
|
||||
label={t('copy_url', 'Copy URL')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -335,13 +358,28 @@ const McpSection = ({
|
|||
);
|
||||
};
|
||||
|
||||
const cliSteps = [
|
||||
const localCliSteps = [
|
||||
{
|
||||
label: 'Install the CLI',
|
||||
code: 'npm install -g postiz',
|
||||
},
|
||||
{
|
||||
label: 'Set your API key, copy it to your secret files',
|
||||
label: 'Run: postiz auth:login',
|
||||
code: 'postiz auth:login',
|
||||
},
|
||||
{
|
||||
label: 'Install the Postiz skill for your AI agent',
|
||||
code: 'npx skills add gitroomhq/postiz-agent',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ciCliSteps = [
|
||||
{
|
||||
label: 'Install the CLI',
|
||||
code: 'npm install -g postiz',
|
||||
},
|
||||
{
|
||||
label: 'Set your API key as an environment variable',
|
||||
code: 'export POSTIZ_API_KEY="{API_KEY}"',
|
||||
},
|
||||
{
|
||||
|
|
@ -350,31 +388,29 @@ const cliSteps = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
const CliSection = ({
|
||||
apiKey,
|
||||
backendUrl,
|
||||
}: {
|
||||
apiKey: string;
|
||||
backendUrl: string;
|
||||
}) => {
|
||||
const CliSection = ({ apiKey }: { apiKey: string }) => {
|
||||
const t = useT();
|
||||
const toaster = useToaster();
|
||||
const [mode, setMode] = useState<'local' | 'ci'>('local');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const steps = cliSteps.map((step) => ({
|
||||
...step,
|
||||
code: step.code.replace('{API_KEY}', apiKey),
|
||||
}));
|
||||
const steps =
|
||||
mode === 'local'
|
||||
? localCliSteps.map((step) => ({ ...step }))
|
||||
: ciCliSteps.map((step) => ({
|
||||
...step,
|
||||
code: step.code.replace('{API_KEY}', apiKey),
|
||||
}));
|
||||
|
||||
const maskedSteps = steps.map((step) => ({
|
||||
...step,
|
||||
code: revealed
|
||||
? step.code
|
||||
: step.code.replace(
|
||||
new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||
'*'.repeat(apiKey.length)
|
||||
),
|
||||
}));
|
||||
const displaySteps =
|
||||
mode === 'ci' && !revealed
|
||||
? steps.map((step) => ({
|
||||
...step,
|
||||
code: step.code.replace(
|
||||
new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||
'*'.repeat(apiKey.length)
|
||||
),
|
||||
}))
|
||||
: steps;
|
||||
|
||||
return (
|
||||
<div className="bg-newBgColorInnerInner rounded-[12px] border border-newBorder overflow-hidden">
|
||||
|
|
@ -402,7 +438,26 @@ const CliSection = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="p-[20px] flex flex-col gap-[16px]">
|
||||
{maskedSteps.map((step, i) => (
|
||||
<div className="flex gap-[6px]">
|
||||
{(['local', 'ci'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={clsx(
|
||||
'cursor-pointer px-[14px] h-[36px] text-[13px] font-[500] rounded-[8px] transition-colors',
|
||||
mode === m
|
||||
? 'bg-[#612BD3] text-white'
|
||||
: 'bg-btnSimple text-customColor18 hover:bg-boxHover hover:text-textColor'
|
||||
)}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{m === 'local'
|
||||
? t('locally', 'Locally')
|
||||
: t('ci_remote_servers', 'CI / Remote servers')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{displaySteps.map((step, i) => (
|
||||
<div key={i} className="flex flex-col gap-[6px]">
|
||||
<div className="text-[13px] font-[600] text-customColor18">
|
||||
{i + 1}. {step.label}
|
||||
|
|
@ -413,36 +468,38 @@ const CliSection = ({
|
|||
</div>
|
||||
))}
|
||||
<div className="flex gap-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
className="cursor-pointer px-[16px] h-[36px] bg-btnSimple hover:bg-boxHover transition-colors rounded-[8px] text-[13px] font-[600] flex items-center gap-[6px]"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{mode === 'ci' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
className="cursor-pointer px-[16px] h-[36px] bg-btnSimple hover:bg-boxHover transition-colors rounded-[8px] text-[13px] font-[600] flex items-center gap-[6px]"
|
||||
>
|
||||
{revealed ? (
|
||||
<>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{revealed ? t('hide', 'Hide') : t('reveal', 'Reveal')}
|
||||
</button>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{revealed ? (
|
||||
<>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{revealed ? t('hide', 'Hide') : t('reveal', 'Reveal')}
|
||||
</button>
|
||||
)}
|
||||
<CopyButton
|
||||
text={steps.map((s) => s.code).join(' && ')}
|
||||
label={t('copy_all', 'Copy All')}
|
||||
|
|
@ -643,7 +700,7 @@ const PublicApiContent = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<CliSection apiKey={user.publicApi} backendUrl={backendUrl} />
|
||||
<CliSection apiKey={user.publicApi} />
|
||||
|
||||
<McpSection user={user} mcpBase={mcpBase} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export async function proxy(request: NextRequest) {
|
|||
if (
|
||||
nextUrl.pathname.startsWith('/uploads/') ||
|
||||
nextUrl.pathname.startsWith('/p/') ||
|
||||
nextUrl.pathname.startsWith('/provider/') ||
|
||||
nextUrl.pathname.startsWith('/icons/')
|
||||
) {
|
||||
return topResponse;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ services:
|
|||
# CLOUDFLARE_REGION: 'auto'
|
||||
|
||||
# === Social Media API Settings
|
||||
X_URL: ''
|
||||
X_API_KEY: ''
|
||||
X_API_SECRET: ''
|
||||
LINKEDIN_CLIENT_ID: ''
|
||||
|
|
|
|||
10
libraries/helpers/src/utils/has.extension.ts
Normal file
10
libraries/helpers/src/utils/has.extension.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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> = {
|
||||
|
|
|
|||
36
libraries/helpers/src/utils/sanitize.post.content.ts
Normal file
36
libraries/helpers/src/utils/sanitize.post.content.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'u',
|
||||
'a',
|
||||
'ul',
|
||||
'li',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'span',
|
||||
];
|
||||
|
||||
const ALLOWED_ATTR = [
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'class',
|
||||
'data-mention-id',
|
||||
'data-mention-label',
|
||||
];
|
||||
|
||||
export const sanitizePostContent = (value: unknown): string => {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(value, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/|#)/i,
|
||||
});
|
||||
};
|
||||
17
libraries/helpers/src/utils/strip.links.ts
Normal file
17
libraries/helpers/src/utils/strip.links.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -223,7 +223,7 @@ If the tools return errors, you would need to rerun it with the right parameters
|
|||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 'MCP');
|
||||
finalOutput.push(...output);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ export class AutopostService {
|
|||
},
|
||||
],
|
||||
})),
|
||||
});
|
||||
}, 'AUTOPOST');
|
||||
}
|
||||
|
||||
async updateUrl(state: WorkflowChannelsState) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ export class IntegrationRepository {
|
|||
async checkPreviousConnections(org: string, id: string) {
|
||||
const findIt = await this._integration.model.integration.findMany({
|
||||
where: {
|
||||
rootInternalId: id.split('_').pop(),
|
||||
rootInternalId: id,
|
||||
},
|
||||
select: {
|
||||
organizationId: true,
|
||||
|
|
@ -263,7 +263,7 @@ export class IntegrationRepository {
|
|||
...postTimes,
|
||||
organizationId: org,
|
||||
refreshNeeded: false,
|
||||
rootInternalId: internalId.split('_').pop(),
|
||||
rootInternalId: internalId,
|
||||
...(customInstanceDetails ? { customInstanceDetails } : {}),
|
||||
additionalSettings: additionalSettings
|
||||
? JSON.stringify(additionalSettings)
|
||||
|
|
@ -304,7 +304,7 @@ export class IntegrationRepository {
|
|||
internalId: internalId,
|
||||
},
|
||||
})
|
||||
)?.rootInternalId || internalId.split('_').pop()!;
|
||||
)?.rootInternalId || internalId;
|
||||
|
||||
await this._integration.model.integration.updateMany({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -72,13 +72,24 @@ export class MediaRepository {
|
|||
});
|
||||
}
|
||||
|
||||
async getMedia(org: string, page: number) {
|
||||
async getMedia(org: string, page: number, search?: string) {
|
||||
const pageNum = (page || 1) - 1;
|
||||
const trimmedSearch = search?.trim();
|
||||
const searchFilter = trimmedSearch
|
||||
? {
|
||||
originalName: {
|
||||
contains: trimmedSearch,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const query = {
|
||||
where: {
|
||||
organization: {
|
||||
id: org,
|
||||
},
|
||||
deletedAt: null,
|
||||
...searchFilter,
|
||||
},
|
||||
};
|
||||
const pages = Math.ceil((await this._media.model.media.count(query)) / 18);
|
||||
|
|
@ -86,6 +97,7 @@ export class MediaRepository {
|
|||
where: {
|
||||
organizationId: org,
|
||||
deletedAt: null,
|
||||
...searchFilter,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ export class MediaService {
|
|||
return this._mediaRepository.saveFile(org, fileName, filePath, originalName);
|
||||
}
|
||||
|
||||
getMedia(org: string, page: number) {
|
||||
return this._mediaRepository.getMedia(org, page);
|
||||
getMedia(org: string, page: number, search?: string) {
|
||||
return this._mediaRepository.getMedia(org, page, search);
|
||||
}
|
||||
|
||||
saveMediaInformation(org: string, data: SaveMediaInformationDto) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
? {}
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
@ -784,6 +815,31 @@ export class PostsService {
|
|||
return this._postRepository.changeState(id, state, err, body);
|
||||
}
|
||||
|
||||
async changePostStatus(
|
||||
orgId: string,
|
||||
id: string,
|
||||
status: 'draft' | 'schedule'
|
||||
) {
|
||||
const getPostById = await this._postRepository.getPostById(id, orgId);
|
||||
if (!getPostById) {
|
||||
throw new BadRequestException('Post not found');
|
||||
}
|
||||
|
||||
const state: State = status === 'draft' ? 'DRAFT' : 'QUEUE';
|
||||
await this._postRepository.changeState(id, state);
|
||||
|
||||
try {
|
||||
await this.startWorkflow(
|
||||
getPostById.integration.providerIdentifier.split('-')[0].toLowerCase(),
|
||||
getPostById.id,
|
||||
orgId,
|
||||
state
|
||||
);
|
||||
} catch (err) {}
|
||||
|
||||
return { id, state };
|
||||
}
|
||||
|
||||
async changeDate(
|
||||
orgId: string,
|
||||
id: string,
|
||||
|
|
@ -805,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'
|
||||
|
|
@ -856,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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@ export class CreateOAuthAppDto {
|
|||
|
||||
@IsString()
|
||||
@IsDefined()
|
||||
@IsUrl()
|
||||
@IsUrl({ require_tld: false })
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@ export class UpdateOAuthAppDto {
|
|||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@IsUrl({ require_tld: false })
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { IsIn } from 'class-validator';
|
||||
|
||||
export class ChangePostStatusDto {
|
||||
@IsIn(['draft', 'schedule'])
|
||||
status: 'draft' | 'schedule';
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
|
||||
import {
|
||||
allProviders,
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
EmptySettings,
|
||||
} from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings';
|
||||
import { ValidContent } from '@gitroom/helpers/utils/valid.images';
|
||||
import { sanitizePostContent } from '@gitroom/helpers/utils/sanitize.post.content';
|
||||
|
||||
export class Integration {
|
||||
@IsDefined()
|
||||
|
|
@ -31,6 +32,7 @@ export class PostContent {
|
|||
@IsDefined()
|
||||
@IsString()
|
||||
@Validate(ValidContent)
|
||||
@Transform(({ value }) => sanitizePostContent(value))
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { Agent } from 'undici';
|
||||
import dns from 'node:dns';
|
||||
import net from 'node:net';
|
||||
import { isBlockedIp } from './webhook.url.validator';
|
||||
|
||||
// Pins DNS resolution: every resolved IP is checked with `isBlockedIp` and
|
||||
// the caller (undici) connects to that same set. Closes the TOCTOU window
|
||||
// `isSafePublicHttpsUrl` alone leaves open (see GHSA-f7jj-p389-4w45).
|
||||
export const ssrfSafeDispatcher = new Agent({
|
||||
connect: {
|
||||
lookup(hostname, options, callback) {
|
||||
if (net.isIP(hostname)) {
|
||||
const family = net.isIP(hostname);
|
||||
if (isBlockedIp(hostname)) {
|
||||
return callback(new Error('Blocked IP'), '', 0);
|
||||
}
|
||||
return options && (options as any).all
|
||||
? callback(null, [{ address: hostname, family }] as any, family)
|
||||
: callback(null, hostname, family);
|
||||
}
|
||||
|
||||
dns.lookup(hostname, options, (err, address: any, family: any) => {
|
||||
if (err) return callback(err, '', 0);
|
||||
if (Array.isArray(address)) {
|
||||
for (const entry of address) {
|
||||
if (isBlockedIp(entry.address)) {
|
||||
return callback(new Error('Blocked IP'), '', 0);
|
||||
}
|
||||
}
|
||||
return callback(null, address as any, 0);
|
||||
}
|
||||
if (isBlockedIp(address)) {
|
||||
return callback(new Error('Blocked IP'), '', 0);
|
||||
}
|
||||
callback(null, address, family);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -656,3 +657,4 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue